├── .gitignore ├── Dockerfile ├── README.md ├── models └── .gitignore └── src ├── DeepSeekTokenizer.php ├── ModelGenerator.php ├── composer.json ├── composer.lock ├── data ├── tokenizer.json └── tokenizer_config.json └── run.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | node_modules/ 3 | /.idea 4 | /.vscode 5 | .DS_Store -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.3 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | 5 | RUN apt-get update && apt-get install -y \ 6 | software-properties-common \ 7 | curl \ 8 | git \ 9 | unzip \ 10 | build-essential \ 11 | libffi-dev 12 | 13 | COPY --from=composer:latest /usr/bin/composer /usr/bin/composer 14 | 15 | RUN docker-php-ext-install ffi 16 | 17 | RUN echo 'memory_limit = 1024M' >> /usr/local/etc/php/conf.d/docker-php-memlimit.ini; 18 | 19 | WORKDIR /app 20 | 21 | COPY src/ /app/ 22 | 23 | RUN composer install 24 | 25 | ENTRYPOINT ["php", "run.php"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # php-deepseek 2 | 3 | This is an experiment in trying to run an LLM using nothing but PHP. It's (mildly) successful, in that text is generated from a DeepSeek model but tends to be a little incoherent at times. 4 | 5 | ## How to use 6 | 7 | You can either use the provided Dockerfile or execute the `run.php` file with a local PHP installation. 8 | 9 | You'll need to download an appropriate ONNX model, for my purposes I used this [1.5B distilled DeepSeek](https://huggingface.co/onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX) one. Add it to the `models` folder. 10 | 11 | To use the Dockerfile, build the image first and then run it: 12 | 13 | ```bash 14 | docker build -t php-deepseek . 15 | docker run -it --rm -v $(pwd)/models:/models php-deepseek {prompt} 16 | ``` 17 | 18 | To use your local PHP installation, ensure you have Composer installed and run: 19 | 20 | ```bash 21 | cd src && composer install 22 | cd .. 23 | php run.php {prompt} 24 | ``` 25 | 26 | ## Additional info 27 | 28 | - **Can this support GPUs?** Theoretically yes, but I don't have one to test. 29 | - **Are you planning on adding any more to this?** I might in the future but for now I'm happy just getting the proof-of-concept working(ish). 30 | - **Why did I do this?** I wanted to see if I could and I thought it would be funny. 31 | 32 | Special thanks to **ankane** for the [onnxruntime-php](https://github.com/ankane/onnxruntime-php) library. -------------------------------------------------------------------------------- /models/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /src/DeepSeekTokenizer.php: -------------------------------------------------------------------------------- 1 | vocab = $config['model']['vocab']; 11 | $this->reversedVocab = array_flip($this->vocab); 12 | $this->modelMaxLength = $config['model_max_length'] ?? PHP_INT_MAX; 13 | } 14 | 15 | public function encode( 16 | string $text, 17 | ?string $textPair = null, 18 | bool $addSpecialTokens = true, 19 | bool|string $padding = false, 20 | bool|string $truncation = false, 21 | ?int $maxLength = null 22 | ): array { 23 | // First, perform pre-tokenization steps 24 | $tokens = $this->preTokenize($text); 25 | 26 | // Convert to byte-level representation 27 | $byteLevelTokens = $this->byteLevelEncode($tokens); 28 | 29 | // Merge tokens using BPE 30 | $mergedTokens = $this->mergeBPE($byteLevelTokens); 31 | 32 | // Convert tokens to IDs 33 | return $this->convertTokensToIds($mergedTokens); 34 | } 35 | 36 | private function preTokenize(string $text): array { 37 | // Split on numbers (1-3 digits) 38 | $text = preg_replace('/(\d{1,3})/', ' $1 ', $text); 39 | 40 | // Split on CJK characters 41 | $text = preg_replace('/([\x{4E00}-\x{9FFF}\x{3040}-\x{309F}\x{30A0}-\x{30FF}])/u', ' $1 ', $text); 42 | 43 | // Split on other patterns as defined in the tokenizer config 44 | $pattern = '/[!\"#$%&\'()*+,\-.\/:;<=>?@\[\\\]^_`{|}~][A-Za-z]+|[^\r\n\p{L}\p{P}\p{S}]?[\p{L}\p{M}]+| ?[\p{P}\p{S}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+/u'; 45 | 46 | preg_match_all($pattern, $text, $matches); 47 | return $matches[0]; 48 | } 49 | 50 | private function byteLevelEncode(array $tokens): array { 51 | $byteLevelTokens = []; 52 | 53 | foreach ($tokens as $token) { 54 | // Add space prefix for non-first tokens that don't start with space 55 | if (!empty($byteLevelTokens) && !preg_match('/^\s/', $token)) { 56 | $token = ' ' . $token; 57 | } 58 | 59 | // Convert to bytes and add Ġ prefix for spaces 60 | $bytes = []; 61 | for ($i = 0; $i < strlen($token); $i++) { 62 | $char = $token[$i]; 63 | if ($char === ' ') { 64 | $bytes[] = 'Ġ'; 65 | } else { 66 | $bytes[] = $char; 67 | } 68 | } 69 | 70 | $byteLevelTokens[] = implode('', $bytes); 71 | } 72 | 73 | return $byteLevelTokens; 74 | } 75 | 76 | private function mergeBPE(array $tokens): array { 77 | $mergedTokens = []; 78 | 79 | foreach ($tokens as $token) { 80 | // Split token into characters 81 | $chars = preg_split('//u', $token, -1, PREG_SPLIT_NO_EMPTY); 82 | 83 | // Start with the longest possible tokens that exist in vocab 84 | $currentTokens = []; 85 | $currentBuffer = ''; 86 | 87 | for ($i = 0; $i < count($chars); $i++) { 88 | $currentBuffer .= $chars[$i]; 89 | 90 | // Try to find the longest possible token from current position 91 | $longestMatch = ''; 92 | $tempBuffer = $currentBuffer; 93 | 94 | // Look ahead up to 10 characters (adjust this based on your vocabulary) 95 | for ($j = 1; $j <= 10 && ($i + $j) < count($chars); $j++) { 96 | $tempBuffer .= $chars[$i + $j]; 97 | if (isset($this->vocab[$tempBuffer])) { 98 | $longestMatch = $tempBuffer; 99 | } 100 | } 101 | 102 | if ($longestMatch !== '') { 103 | // Found a match in vocab 104 | $currentTokens[] = $longestMatch; 105 | $i += strlen($longestMatch) - strlen($currentBuffer); 106 | $currentBuffer = ''; 107 | } elseif (isset($this->vocab[$currentBuffer])) { 108 | // Current buffer is a valid token 109 | $currentTokens[] = $currentBuffer; 110 | $currentBuffer = ''; 111 | } 112 | } 113 | 114 | // Add any remaining buffer if it's a valid token 115 | if ($currentBuffer !== '' && isset($this->vocab[$currentBuffer])) { 116 | $currentTokens[] = $currentBuffer; 117 | } 118 | 119 | $mergedTokens = array_merge($mergedTokens, $currentTokens); 120 | } 121 | 122 | return $mergedTokens; 123 | } 124 | 125 | private function convertTokensToIds(array $tokens): array { 126 | $ids = []; 127 | 128 | foreach ($tokens as $token) { 129 | if (isset($this->vocab[$token])) { 130 | $ids[] = $this->vocab[$token]; 131 | } 132 | } 133 | 134 | return $ids; 135 | } 136 | 137 | public function decode(array $ids, bool $skipSpecialTokens = false): string { 138 | $tokens = []; 139 | 140 | foreach ($ids as $id) { 141 | if (isset($this->reversedVocab[$id])) { 142 | $token = $this->reversedVocab[$id]; 143 | if (!$skipSpecialTokens || !$this->isSpecialToken($token)) { 144 | $tokens[] = $token; 145 | } 146 | } 147 | } 148 | 149 | $text = implode('', $tokens); 150 | 151 | // Clean up the text 152 | return $this->cleanUp($text); 153 | } 154 | 155 | private function isSpecialToken(string $token): bool { 156 | return strpos($token, '<') === 0 && strpos($token, '>') === strlen($token) - 1; 157 | } 158 | 159 | /** 160 | * Get next token prediction from model output 161 | */ 162 | public function getNextToken(array $modelOutput, ?int $lastUsedLogitSet = null): string { 163 | $bestProb = 0; 164 | $bestToken = ''; 165 | $bestSet = -1; 166 | $bestLogits = []; 167 | 168 | // Look at each set of logits 169 | foreach ($modelOutput[0] as $setIndex => $logits) { 170 | // Skip if not an array of logits 171 | if (!is_array($logits) || empty($logits)) { 172 | continue; 173 | } 174 | 175 | // Filter logits to only include tokens in our vocabulary 176 | $validLogits = []; 177 | foreach ($logits as $idx => $logit) { 178 | if (isset($this->reversedVocab[$idx])) { 179 | $validLogits[$idx] = $logit; 180 | } 181 | } 182 | 183 | // Convert to probabilities using softmax 184 | $probs = $this->softmax($validLogits); 185 | 186 | // Get the most likely token 187 | $maxProb = max($probs); 188 | $tokenId = array_search($maxProb, $probs); 189 | 190 | // If this is the most confident prediction so far, save it 191 | if (($maxProb > $bestProb) && $setIndex !== $lastUsedLogitSet) { 192 | $bestProb = $maxProb; 193 | $bestToken = $this->reversedVocab[$tokenId]; 194 | $bestSet = $setIndex; 195 | $bestLogits[$tokenId] = $maxProb; 196 | } 197 | } 198 | 199 | // die(var_dump($bestLogits)); 200 | 201 | // Debug output for the best set 202 | // "Using logit set: " . $bestSet . "\n"; 203 | // echo "Best probability: " . $bestProb . "\n\n"; 204 | 205 | // Get top 5 from the best set 206 | $validLogits = []; 207 | foreach ($bestLogits as $idx => $logit) { 208 | if (isset($this->reversedVocab[$idx])) { 209 | $validLogits[$idx] = $logit; 210 | } 211 | } 212 | $probs = $this->softmax($validLogits); 213 | arsort($probs); 214 | $topProbs = array_slice($probs, 0, 5, true); 215 | 216 | $temperature = 0.5; 217 | $scaled = []; 218 | foreach ($topProbs as $id => $prob) { 219 | $scaled[$id] = pow($prob, 1/$temperature); 220 | } 221 | 222 | // die(var_dump($scaled)); 223 | 224 | // Normalize scaled probabilities 225 | $sum = array_sum($scaled); 226 | $scaled = array_map(function($v) use ($sum) { return $v/$sum; }, $scaled); 227 | 228 | // Sample token 229 | $r = mt_rand() / mt_getrandmax(); 230 | $cumsum = 0; 231 | foreach ($scaled as $id => $prob) { 232 | $cumsum += $prob; 233 | if ($r <= $cumsum) { 234 | $selectedId = $id; 235 | break; 236 | } 237 | } 238 | 239 | // Fallback to highest probability 240 | if (!isset($selectedId)) { 241 | $selectedId = array_key_first($topProbs); 242 | } 243 | 244 | // die(var_dump($topProbs)); 245 | 246 | $selectedId = array_key_first($topProbs); 247 | 248 | // die(var_dump($selectedId)); 249 | 250 | // echo "Top 5 tokens from best set:\n"; 251 | // foreach ($top5 as $tid => $prob) { 252 | // $token = $this->reversedVocab[$tid]; 253 | // $originalLogit = $bestLogits[$tid]; 254 | // echo sprintf( 255 | // "ID: %d Token: '%s' Logit: %.4f Prob: %.6f\n", 256 | // $tid, 257 | // str_replace('Ġ', ' ', $token), 258 | // $originalLogit, 259 | // $prob 260 | // ); 261 | // } 262 | 263 | // return [$this->cleanUp($bestToken), $bestSet]; 264 | // return $bestToken; 265 | return $this->reversedVocab[$selectedId]; 266 | } 267 | 268 | /** 269 | * Convert logits to probabilities using softmax 270 | */ 271 | private function softmax(array $x): array { 272 | if (!is_array($x) || !count($x)) { 273 | throw new Exception('The softmax function requires an array of real numbers.'); 274 | } 275 | 276 | // Find max for numerical stability 277 | $max = max($x); 278 | $exp = []; 279 | $sum = 0; 280 | 281 | foreach ($x as $idx => $value) { 282 | $e = exp($value - $max); 283 | $exp[$idx] = $e; 284 | $sum += $e; 285 | } 286 | 287 | return array_map(function ($value) use ($sum) { 288 | return $value / $sum; 289 | }, $exp); 290 | } 291 | 292 | private function cleanUp(string $text): string { 293 | // Replace Ġ with space and normalize spaces 294 | // return trim(str_replace('Ġ', ' ', $text)); 295 | return str_replace('Ġ', ' ', $text); 296 | } 297 | } -------------------------------------------------------------------------------- /src/ModelGenerator.php: -------------------------------------------------------------------------------- 1 | session = new InferenceSession($modelPath); 24 | $this->tokenizer = new DeepSeekTokenizer($tokenizerPath); 25 | $this->maxNewTokens = $maxNewTokens; 26 | } 27 | 28 | public function generate(string $prompt): string { 29 | $this->generatedTokens = $this->tokenizer->encode($prompt); 30 | $this->pastKeyValues = []; 31 | $this->recentTokens = []; 32 | 33 | $fullText = $prompt; 34 | 35 | for ($i = 0; $i < $this->maxNewTokens; $i++) { 36 | // Prepare input feed 37 | $inputFeed = $this->prepareInputFeed(); 38 | 39 | // Run inference 40 | $outputs = $this->session->run(null, $inputFeed); 41 | 42 | // Get next token 43 | $nextToken = $this->tokenizer->getNextToken($outputs[0]); 44 | 45 | echo str_replace('Ġ', ' ', $nextToken); 46 | 47 | // Check for degenerate patterns 48 | if ($this->isDegenerate($nextToken)) { 49 | echo "\nStopping due to degenerate pattern\n"; 50 | break; 51 | } 52 | 53 | // Update state 54 | $tokenId = $this->tokenizer->encode($nextToken)[0]; 55 | $this->generatedTokens[] = $tokenId; 56 | $this->recentTokens[] = $nextToken; 57 | if (count($this->recentTokens) > 10) { 58 | array_shift($this->recentTokens); 59 | } 60 | 61 | // Update past key values 62 | $this->updatePastKeyValues($outputs); 63 | 64 | // Append to text 65 | $fullText .= $nextToken; 66 | 67 | // Check for stopping criteria 68 | if ($this->shouldStop($nextToken)) { 69 | break; 70 | } 71 | } 72 | 73 | return $fullText; 74 | } 75 | 76 | private function prepareInputFeed(): array { 77 | $inputFeed = []; 78 | 79 | // Use only last token if we have context 80 | $useTokens = empty($this->pastKeyValues) 81 | ? $this->generatedTokens 82 | : [end($this->generatedTokens)]; 83 | 84 | // Prepare tensors 85 | $inputFeed['input_ids'] = OrtValue::fromArray([$useTokens], ElementType::Int64); 86 | $inputFeed['attention_mask'] = OrtValue::fromArray([array_fill(0, count($useTokens), 1)], ElementType::Int64); 87 | 88 | // Position IDs start from current position 89 | $startPos = count($this->generatedTokens) - count($useTokens); 90 | $inputFeed['position_ids'] = OrtValue::fromArray([range($startPos, $startPos + count($useTokens) - 1)], ElementType::Int64); 91 | 92 | // Add past key values 93 | if (!empty($this->pastKeyValues)) { 94 | foreach ($this->pastKeyValues as $layerIdx => $layer) { 95 | $inputFeed["past_key_values.$layerIdx.key"] = $layer['key']; 96 | $inputFeed["past_key_values.$layerIdx.value"] = $layer['value']; 97 | } 98 | } else { 99 | // Initialize empty 100 | for ($i = 0; $i < 28; $i++) { 101 | $emptyShape = [1, 2, 0, 128]; 102 | $inputFeed["past_key_values.$i.key"] = OrtValue::fromShapeAndType($emptyShape, ElementType::Float); 103 | $inputFeed["past_key_values.$i.value"] = OrtValue::fromShapeAndType($emptyShape, ElementType::Float); 104 | } 105 | } 106 | 107 | return $inputFeed; 108 | } 109 | 110 | private function updatePastKeyValues(array $outputs): void { 111 | $this->pastKeyValues = []; 112 | for ($i = 0; $i < 28; $i++) { 113 | $keyIdx = $i * 2 + 1; 114 | $valueIdx = $i * 2 + 2; 115 | $this->pastKeyValues[$i] = [ 116 | 'key' => $outputs[$keyIdx], 117 | 'value' => $outputs[$valueIdx] 118 | ]; 119 | } 120 | } 121 | 122 | private function shouldStop(string $token): bool { 123 | return $token === '<|end▁of▁sentence|>'; 124 | } 125 | 126 | private function isDegenerate(string $token): bool { 127 | if (count($this->recentTokens) < 5) { 128 | return false; 129 | } 130 | 131 | // Check for token repetition 132 | $lastFive = array_slice($this->recentTokens, -5); 133 | if (count(array_unique($lastFive)) === 1) { 134 | return true; 135 | } 136 | 137 | // Check for space repetition 138 | $recentText = implode('', $this->recentTokens); 139 | if (substr_count($recentText, ' ') > 2) { 140 | return true; 141 | } 142 | 143 | // Check for number repetition 144 | if (preg_match('/(\d)\1{3,}/', $recentText)) { 145 | return true; 146 | } 147 | 148 | return false; 149 | } 150 | } -------------------------------------------------------------------------------- /src/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "ankane/onnxruntime": "^0.2.4" 4 | }, 5 | "scripts": { 6 | "post-install-cmd": "OnnxRuntime\\Vendor::check", 7 | "post-update-cmd": "OnnxRuntime\\Vendor::check" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "678d406f75eeefbad14dcdf6f04dc253", 8 | "packages": [ 9 | { 10 | "name": "ankane/onnxruntime", 11 | "version": "v0.2.4", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/ankane/onnxruntime-php.git", 15 | "reference": "6ba9b2fd980db69f5299ee466f1170bcdb526d04" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/ankane/onnxruntime-php/zipball/6ba9b2fd980db69f5299ee466f1170bcdb526d04", 20 | "reference": "6ba9b2fd980db69f5299ee466f1170bcdb526d04", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "ext-ffi": ">= 8.1", 25 | "php": ">= 8.1" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit": "^10" 29 | }, 30 | "type": "library", 31 | "autoload": { 32 | "psr-4": { 33 | "OnnxRuntime\\": "src/" 34 | } 35 | }, 36 | "notification-url": "https://packagist.org/downloads/", 37 | "license": [ 38 | "MIT" 39 | ], 40 | "authors": [ 41 | { 42 | "name": "Andrew Kane", 43 | "email": "andrew@ankane.org" 44 | } 45 | ], 46 | "description": "Run ONNX models in PHP", 47 | "support": { 48 | "issues": "https://github.com/ankane/onnxruntime-php/issues", 49 | "source": "https://github.com/ankane/onnxruntime-php" 50 | }, 51 | "time": "2024-11-01T18:42:30+00:00" 52 | } 53 | ], 54 | "packages-dev": [], 55 | "aliases": [], 56 | "minimum-stability": "stable", 57 | "stability-flags": {}, 58 | "prefer-stable": false, 59 | "prefer-lowest": false, 60 | "platform": {}, 61 | "platform-dev": {}, 62 | "plugin-api-version": "2.6.0" 63 | } 64 | -------------------------------------------------------------------------------- /src/data/tokenizer_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "add_bos_token": false, 3 | "add_eos_token": false, 4 | "bos_token": { 5 | "__type": "AddedToken", 6 | "content": "<|begin▁of▁sentence|>", 7 | "lstrip": false, 8 | "normalized": true, 9 | "rstrip": false, 10 | "single_word": false 11 | }, 12 | "clean_up_tokenization_spaces": false, 13 | "eos_token": { 14 | "__type": "AddedToken", 15 | "content": "<|end▁of▁sentence|>", 16 | "lstrip": false, 17 | "normalized": true, 18 | "rstrip": false, 19 | "single_word": false 20 | }, 21 | "legacy": true, 22 | "model_max_length": 131072, 23 | "pad_token": { 24 | "__type": "AddedToken", 25 | "content": "<|end▁of▁sentence|>", 26 | "lstrip": false, 27 | "normalized": true, 28 | "rstrip": false, 29 | "single_word": false 30 | }, 31 | "sp_model_kwargs": {}, 32 | "unk_token": null, 33 | "tokenizer_class": "LlamaTokenizerFast", 34 | "chat_template": "{% if not add_generation_prompt is defined %}{% set add_generation_prompt = false %}{% endif %}{% set ns = namespace(is_first=false, is_tool=false, is_output_first=true, system_prompt='', is_first_sp=true) %}{%- for message in messages %}{%- if message['role'] == 'system' %}{%- if ns.is_first_sp %}{% set ns.system_prompt = ns.system_prompt + message['content'] %}{% set ns.is_first_sp = false %}{%- else %}{% set ns.system_prompt = ns.system_prompt + '\\n\\n' + message['content'] %}{%- endif %}{%- endif %}{%- endfor %}{{ bos_token }}{{ ns.system_prompt }}{%- for message in messages %}{%- if message['role'] == 'user' %}{%- set ns.is_tool = false -%}{{'<|User|>' + message['content']}}{%- endif %}{%- if message['role'] == 'assistant' and 'tool_calls' in message %}{%- set ns.is_tool = false -%}{%- for tool in message['tool_calls'] %}{%- if not ns.is_first %}{%- if message['content'] is none %}{{'<|Assistant|><|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>' + tool['function']['name'] + '\\n' + '```json' + '\\n' + tool['function']['arguments'] + '\\n' + '```' + '<|tool▁call▁end|>'}}{%- else %}{{'<|Assistant|>' + message['content'] + '<|tool▁calls▁begin|><|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>' + tool['function']['name'] + '\\n' + '```json' + '\\n' + tool['function']['arguments'] + '\\n' + '```' + '<|tool▁call▁end|>'}}{%- endif %}{%- set ns.is_first = true -%}{%- else %}{{'\\n' + '<|tool▁call▁begin|>' + tool['type'] + '<|tool▁sep|>' + tool['function']['name'] + '\\n' + '```json' + '\\n' + tool['function']['arguments'] + '\\n' + '```' + '<|tool▁call▁end|>'}}{%- endif %}{%- endfor %}{{'<|tool▁calls▁end|><|end▁of▁sentence|>'}}{%- endif %}{%- if message['role'] == 'assistant' and 'tool_calls' not in message %}{%- if ns.is_tool %}{{'<|tool▁outputs▁end|>' + message['content'] + '<|end▁of▁sentence|>'}}{%- set ns.is_tool = false -%}{%- else %}{% set content = message['content'] %}{% if '' in content %}{% set content = content.split('')[-1] %}{% endif %}{{'<|Assistant|>' + content + '<|end▁of▁sentence|>'}}{%- endif %}{%- endif %}{%- if message['role'] == 'tool' %}{%- set ns.is_tool = true -%}{%- if ns.is_output_first %}{{'<|tool▁outputs▁begin|><|tool▁output▁begin|>' + message['content'] + '<|tool▁output▁end|>'}}{%- set ns.is_output_first = false %}{%- else %}{{'<|tool▁output▁begin|>' + message['content'] + '<|tool▁output▁end|>'}}{%- endif %}{%- endif %}{%- endfor -%}{% if ns.is_tool %}{{'<|tool▁outputs▁end|>'}}{% endif %}{% if add_generation_prompt and not ns.is_tool %}{{'<|Assistant|>'}}{% endif %}" 35 | } -------------------------------------------------------------------------------- /src/run.php: -------------------------------------------------------------------------------- 1 | generate($prompt); 29 | 30 | echo "\n"; --------------------------------------------------------------------------------