Implement downloadable tokenizers Closes #2574, #2754
| @@ -95,6 +95,9 @@ requestOverrides: [] | ||
| 95 | 95 | enableExtensions: true |
| 96 | 96 | # Automatically update extensions when a release version changes |
| 97 | 97 | enableExtensionsAutoUpdate: true |
| 98 | +# Additional model tokenizers can be downloaded on demand. | |
| 99 | +# Disabling will fallback to another locally available tokenizer. | |
| 100 | +enableDownloadableTokenizers: true | |
| 98 | 101 | # Extension settings |
| 99 | 102 | extras: |
| 100 | 103 | # Disables automatic model download from HuggingFace |
| @@ -3283,6 +3283,8 @@ | ||
| 3283 | 3283 | <option value="12">Llama 3</option> |
| 3284 | 3284 | <option value="13">Gemma / Gemini</option> |
| 3285 | 3285 | <option value="14">Jamba</option> |
| 3286 | + <option value="15">Qwen2</option> | |
| 3287 | + <option value="16">Command-R</option> | |
| 3286 | 3288 | <option value="4">NerdStash (NovelAI Clio)</option> |
| 3287 | 3289 | <option value="5">NerdStash v2 (NovelAI Kayra)</option> |
| 3288 | 3290 | <option value="7">Mistral</option> |
| @@ -590,6 +590,9 @@ function calculateOpenRouterCost() { | ||
| 590 | 590 | export function getCurrentOpenRouterModelTokenizer() { |
| 591 | 591 | const modelId = textgen_settings.openrouter_model; |
| 592 | 592 | const model = openRouterModels.find(x => x.id === modelId); |
| 593 | + if (modelId?.includes('jamba')) { | |
| 594 | + return tokenizers.JAMBA; | |
| 595 | + } | |
| 593 | 596 | switch (model?.architecture?.tokenizer) { |
| 594 | 597 | case 'Llama2': |
| 595 | 598 | return tokenizers.LLAMA; |
| @@ -603,6 +606,10 @@ export function getCurrentOpenRouterModelTokenizer() { | ||
| 603 | 606 | return tokenizers.GEMMA; |
| 604 | 607 | case 'Claude': |
| 605 | 608 | return tokenizers.CLAUDE; |
| 609 | + case 'Cohere': | |
| 610 | + return tokenizers.COMMAND_R; | |
| 611 | + case 'Qwen': | |
| 612 | + return tokenizers.QWEN2; | |
| 606 | 613 | default: |
| 607 | 614 | return tokenizers.OPENAI; |
| 608 | 615 | } |
| @@ -28,6 +28,8 @@ export const tokenizers = { | ||
| 28 | 28 | LLAMA3: 12, |
| 29 | 29 | GEMMA: 13, |
| 30 | 30 | JAMBA: 14, |
| 31 | + QWEN2: 15, | |
| 32 | + COMMAND_R: 16, | |
| 31 | 33 | BEST_MATCH: 99, |
| 32 | 34 | }; |
| 33 | 35 | |
| @@ -105,6 +107,16 @@ const TOKENIZER_URLS = { | ||
| 105 | 107 | decode: '/api/tokenizers/jamba/decode', |
| 106 | 108 | count: '/api/tokenizers/jamba/encode', |
| 107 | 109 | }, |
| 110 | + [tokenizers.QWEN2]: { | |
| 111 | + encode: '/api/tokenizers/qwen2/encode', | |
| 112 | + decode: '/api/tokenizers/qwen2/decode', | |
| 113 | + count: '/api/tokenizers/qwen2/encode', | |
| 114 | + }, | |
| 115 | + [tokenizers.COMMAND_R]: { | |
| 116 | + encode: '/api/tokenizers/command-r/encode', | |
| 117 | + decode: '/api/tokenizers/command-r/decode', | |
| 118 | + count: '/api/tokenizers/command-r/encode', | |
| 119 | + }, | |
| 108 | 120 | [tokenizers.API_TEXTGENERATIONWEBUI]: { |
| 109 | 121 | encode: '/api/tokenizers/remote/textgenerationwebui/encode', |
| 110 | 122 | count: '/api/tokenizers/remote/textgenerationwebui/encode', |
| @@ -293,6 +305,12 @@ export function getTokenizerBestMatch(forApi) { | ||
| 293 | 305 | if (model.includes('jamba')) { |
| 294 | 306 | return tokenizers.JAMBA; |
| 295 | 307 | } |
| 308 | + if (model.includes('command-r')) { | |
| 309 | + return tokenizers.COMMAND_R; | |
| 310 | + } | |
| 311 | + if (model.includes('qwen2')) { | |
| 312 | + return tokenizers.QWEN2; | |
| 313 | + } | |
| 296 | 314 | } |
| 297 | 315 | |
| 298 | 316 | return tokenizers.LLAMA; |
| @@ -511,6 +529,8 @@ export function getTokenizerModel() { | ||
| 511 | 529 | const yiTokenizer = 'yi'; |
| 512 | 530 | const gemmaTokenizer = 'gemma'; |
| 513 | 531 | const jambaTokenizer = 'jamba'; |
| 532 | + const qwen2Tokenizer = 'qwen2'; | |
| 533 | + const commandRTokenizer = 'command-r'; | |
| 514 | 534 | |
| 515 | 535 | // Assuming no one would use it for different models.. right? |
| 516 | 536 | if (oai_settings.chat_completion_source == chat_completion_sources.SCALE) { |
| @@ -558,6 +578,12 @@ export function getTokenizerModel() { | ||
| 558 | 578 | else if (model?.architecture?.tokenizer === 'Gemini') { |
| 559 | 579 | return gemmaTokenizer; |
| 560 | 580 | } |
| 581 | + else if (model?.architecture?.tokenizer === 'Qwen') { | |
| 582 | + return qwen2Tokenizer; | |
| 583 | + } | |
| 584 | + else if (model?.architecture?.tokenizer === 'Cohere') { | |
| 585 | + return commandRTokenizer; | |
| 586 | + } | |
| 561 | 587 | else if (oai_settings.openrouter_model.includes('gpt-4o')) { |
| 562 | 588 | return gpt4oTokenizer; |
| 563 | 589 | } |
| @@ -581,6 +607,10 @@ export function getTokenizerModel() { | ||
| 581 | 607 | } |
| 582 | 608 | } |
| 583 | 609 | |
| 610 | + if (oai_settings.chat_completion_source == chat_completion_sources.COHERE) { | |
| 611 | + return commandRTokenizer; | |
| 612 | + } | |
| 613 | + | |
| 584 | 614 | if (oai_settings.chat_completion_source == chat_completion_sources.MAKERSUITE) { |
| 585 | 615 | return gemmaTokenizer; |
| 586 | 616 | } |
| @@ -4,13 +4,12 @@ const express = require('express'); | ||
| 4 | 4 | const { SentencePieceProcessor } = require('@agnai/sentencepiece-js'); |
| 5 | 5 | const tiktoken = require('tiktoken'); |
| 6 | 6 | const { Tokenizer } = require('@agnai/web-tokenizers'); |
| 7 | 7 | const { convertClaudePrompt, convertGooglePrompt } = require('../prompt-converters'); |
| 8 | -const { readSecret, SECRET_KEYS } = require('./secrets'); | |
| 9 | 8 | const { TEXTGEN_TYPES } = require('../constants'); |
| 10 | 9 | const { jsonParser } = require('../express-common'); |
| 11 | 10 | const { setAdditionalHeaders } = require('../additional-headers'); |
| 12 | - | |
| 11 | +const { getConfigValue, isValidUrl } = require('../util'); | |
| 13 | -const API_MAKERSUITE = 'https://generativelanguage.googleapis.com'; | |
| 12 | +const writeFileAtomicSync = require('write-file-atomic').sync; | |
| 14 | 13 | |
| 15 | 14 | /** |
| 16 | 15 | * @typedef { (req: import('express').Request, res: import('express').Response) => Promise<any> } TokenizationHandler |
| @@ -53,6 +52,65 @@ const TEXT_COMPLETION_MODELS = [ | ||
| 53 | 52 | ]; |
| 54 | 53 | |
| 55 | 54 | const CHARS_PER_TOKEN = 3.35; |
| 55 | +const IS_DOWNLOAD_ALLOWED = getConfigValue('enableDownloadableTokenizers', true); | |
| 56 | + | |
| 57 | +/** | |
| 58 | + * Gets a path to the tokenizer model. Downloads the model if it's a URL. | |
| 59 | + * @param {string} model Model URL or path | |
| 60 | + * @param {string|undefined} fallbackModel Fallback model path\ | |
| 61 | + * @returns {Promise<string>} Path to the tokenizer model | |
| 62 | + */ | |
| 63 | +async function getPathToTokenizer(model, fallbackModel) { | |
| 64 | + if (!isValidUrl(model)) { | |
| 65 | + return model; | |
| 66 | + } | |
| 67 | + | |
| 68 | + try { | |
| 69 | + const url = new URL(model); | |
| 70 | + | |
| 71 | + if (!['https:', 'http:'].includes(url.protocol)) { | |
| 72 | + throw new Error('Invalid URL protocol'); | |
| 73 | + } | |
| 74 | + | |
| 75 | + const fileName = url.pathname.split('/').pop(); | |
| 76 | + | |
| 77 | + if (!fileName) { | |
| 78 | + throw new Error('Failed to extract the file name from the URL'); | |
| 79 | + } | |
| 80 | + | |
| 81 | + const CACHE_PATH = path.join(global.DATA_ROOT, '_cache'); | |
| 82 | + if (!fs.existsSync(CACHE_PATH)) { | |
| 83 | + fs.mkdirSync(CACHE_PATH, { recursive: true }); | |
| 84 | + } | |
| 85 | + | |
| 86 | + const cachedFile = path.join(CACHE_PATH, fileName); | |
| 87 | + if (fs.existsSync(cachedFile)) { | |
| 88 | + return cachedFile; | |
| 89 | + } | |
| 90 | + | |
| 91 | + if (!IS_DOWNLOAD_ALLOWED) { | |
| 92 | + throw new Error('Downloading tokenizers is disabled, the model is not cached'); | |
| 93 | + } | |
| 94 | + | |
| 95 | + console.log('Downloading tokenizer model:', model); | |
| 96 | + const response = await fetch(model); | |
| 97 | + if (!response.ok) { | |
| 98 | + throw new Error(`Failed to fetch the model: ${response.status} ${response.statusText}`); | |
| 99 | + } | |
| 100 | + | |
| 101 | + const arrayBuffer = await response.arrayBuffer(); | |
| 102 | + writeFileAtomicSync(cachedFile, Buffer.from(arrayBuffer)); | |
| 103 | + return cachedFile; | |
| 104 | + } catch (error) { | |
| 105 | + const getLastSegment = str => str?.split('/')?.pop() || ''; | |
| 106 | + if (fallbackModel) { | |
| 107 | + console.log(`Could not get a tokenizer from ${getLastSegment(model)}. Reason: ${error.message}. Using a fallback model: ${getLastSegment(fallbackModel)}.`); | |
| 108 | + return fallbackModel; | |
| 109 | + } | |
| 110 | + | |
| 111 | + throw new Error(`Failed to instantiate a tokenizer and fallback is not provided. Reason: ${error.message}`); | |
| 112 | + } | |
| 113 | +} | |
| 56 | 114 | |
| 57 | 115 | /** |
| 58 | 116 | * Sentencepiece tokenizer for tokenizing text. |
| @@ -66,13 +124,19 @@ class SentencePieceTokenizer { | ||
| 66 | 124 | * @type {string} Path to the tokenizer model |
| 67 | 125 | */ |
| 68 | 126 | #model; |
| 127 | + /** | |
| 128 | + * @type {string|undefined} Path to the fallback model | |
| 129 | + */ | |
| 130 | + #fallbackModel; | |
| 69 | 131 | |
| 70 | 132 | /** |
| 71 | 133 | * Creates a new Sentencepiece tokenizer. |
| 72 | 134 | * @param {string} model Path to the tokenizer model |
| 135 | + * @param {string} [fallbackModel] Path to the fallback model | |
| 73 | 136 | */ |
| 74 | 137 | constructor(model, fallbackModel) { |
| 75 | 138 | this.#model = model; |
| 139 | + this.#fallbackModel = fallbackModel; | |
| 76 | 140 | } |
| 77 | 141 | |
| 78 | 142 | /** |
| @@ -85,9 +149,10 @@ class SentencePieceTokenizer { | ||
| 85 | 149 | } |
| 86 | 150 | |
| 87 | 151 | try { |
| 152 | + const pathToModel = await getPathToTokenizer(this.#model, this.#fallbackModel); | |
| 88 | 153 | this.#instance = new SentencePieceProcessor(); |
| 89 | 154 | await this.#instance.load(this.#modelpathToModel); |
| 90 | 155 | console.log('Instantiated the tokenizer for', path.parse(this.#modelpathToModel).name); |
| 91 | 156 | return this.#instance; |
| 92 | 157 | } catch (error) { |
| 93 | 158 | console.error('Sentencepiece tokenizer failed to load: ' + this.#model, error); |
| @@ -108,13 +173,19 @@ class WebTokenizer { | ||
| 108 | 173 | * @type {string} Path to the tokenizer model |
| 109 | 174 | */ |
| 110 | 175 | #model; |
| 176 | + /** | |
| 177 | + * @type {string|undefined} Path to the fallback model | |
| 178 | + */ | |
| 179 | + #fallbackModel; | |
| 111 | 180 | |
| 112 | 181 | /** |
| 113 | 182 | * Creates a new Web tokenizer. |
| 114 | 183 | * @param {string} model Path to the tokenizer model |
| 184 | + * @param {string} [fallbackModel] Path to the fallback model | |
| 115 | 185 | */ |
| 116 | 186 | constructor(model, fallbackModel) { |
| 117 | 187 | this.#model = model; |
| 188 | + this.#fallbackModel = fallbackModel; | |
| 118 | 189 | } |
| 119 | 190 | |
| 120 | 191 | /** |
| @@ -127,9 +198,10 @@ class WebTokenizer { | ||
| 127 | 198 | } |
| 128 | 199 | |
| 129 | 200 | try { |
| 130 | 201 | const arrayBufferpathToModel = fs.readFileSyncawait getPathToTokenizer(this.#model), this.buffer#fallbackModel); |
| 202 | + const arrayBuffer = fs.readFileSync(pathToModel).buffer; | |
| 131 | 203 | this.#instance = await Tokenizer.fromJSON(arrayBuffer); |
| 132 | 204 | console.log('Instantiated the tokenizer for', path.parse(this.#modelpathToModel).name); |
| 133 | 205 | return this.#instance; |
| 134 | 206 | } catch (error) { |
| 135 | 207 | console.error('Web tokenizer failed to load: ' + this.#model, error); |
| @@ -147,6 +219,8 @@ const spp_gemma = new SentencePieceTokenizer('src/tokenizers/gemma.model'); | ||
| 147 | 219 | const spp_jamba = new SentencePieceTokenizer('src/tokenizers/jamba.model'); |
| 148 | 220 | const claude_tokenizer = new WebTokenizer('src/tokenizers/claude.json'); |
| 149 | 221 | const llama3_tokenizer = new WebTokenizer('src/tokenizers/llama3.json'); |
| 222 | +const commandTokenizer = new WebTokenizer('https://github.com/SillyTavern/SillyTavern-Tokenizers/raw/main/command-r.json', 'src/tokenizers/llama3.json'); | |
| 223 | +const qwen2Tokenizer = new WebTokenizer('https://github.com/SillyTavern/SillyTavern-Tokenizers/raw/main/qwen2.json', 'src/tokenizers/llama3.json'); | |
| 150 | 224 | |
| 151 | 225 | const sentencepieceTokenizers = [ |
| 152 | 226 | 'llama', |
| @@ -332,6 +406,14 @@ function getTokenizerModel(requestModel) { | ||
| 332 | 406 | return 'jamba'; |
| 333 | 407 | } |
| 334 | 408 | |
| 409 | + if (requestModel.includes('qwen2')) { | |
| 410 | + return 'qwen2'; | |
| 411 | + } | |
| 412 | + | |
| 413 | + if (requestModel.includes('command-r')) { | |
| 414 | + return 'command-r'; | |
| 415 | + } | |
| 416 | + | |
| 335 | 417 | // default |
| 336 | 418 | return 'gpt-3.5-turbo'; |
| 337 | 419 | } |
| @@ -557,6 +639,8 @@ router.post('/jamba/encode', jsonParser, createSentencepieceEncodingHandler(spp_ | ||
| 557 | 639 | router.post('/gpt2/encode', jsonParser, createTiktokenEncodingHandler('gpt2')); |
| 558 | 640 | router.post('/claude/encode', jsonParser, createWebTokenizerEncodingHandler(claude_tokenizer)); |
| 559 | 641 | router.post('/llama3/encode', jsonParser, createWebTokenizerEncodingHandler(llama3_tokenizer)); |
| 642 | +router.post('/qwen2/encode', jsonParser, createWebTokenizerEncodingHandler(qwen2Tokenizer)); | |
| 643 | +router.post('/command-r/encode', jsonParser, createWebTokenizerEncodingHandler(commandTokenizer)); | |
| 560 | 644 | router.post('/llama/decode', jsonParser, createSentencepieceDecodingHandler(spp_llama)); |
| 561 | 645 | router.post('/nerdstash/decode', jsonParser, createSentencepieceDecodingHandler(spp_nerd)); |
| 562 | 646 | router.post('/nerdstash_v2/decode', jsonParser, createSentencepieceDecodingHandler(spp_nerd_v2)); |
| @@ -567,6 +651,8 @@ router.post('/jamba/decode', jsonParser, createSentencepieceDecodingHandler(spp_ | ||
| 567 | 651 | router.post('/gpt2/decode', jsonParser, createTiktokenDecodingHandler('gpt2')); |
| 568 | 652 | router.post('/claude/decode', jsonParser, createWebTokenizerDecodingHandler(claude_tokenizer)); |
| 569 | 653 | router.post('/llama3/decode', jsonParser, createWebTokenizerDecodingHandler(llama3_tokenizer)); |
| 654 | +router.post('/qwen2/decode', jsonParser, createWebTokenizerDecodingHandler(qwen2Tokenizer)); | |
| 655 | +router.post('/command-r/decode', jsonParser, createWebTokenizerDecodingHandler(commandTokenizer)); | |
| 570 | 656 | |
| 571 | 657 | router.post('/openai/encode', jsonParser, async function (req, res) { |
| 572 | 658 | try { |
| @@ -607,6 +693,16 @@ router.post('/openai/encode', jsonParser, async function (req, res) { | ||
| 607 | 693 | return handler(req, res); |
| 608 | 694 | } |
| 609 | 695 | |
| 696 | + if (queryModel.includes('qwen2')) { | |
| 697 | + const handler = createWebTokenizerEncodingHandler(qwen2Tokenizer); | |
| 698 | + return handler(req, res); | |
| 699 | + } | |
| 700 | + | |
| 701 | + if (queryModel.includes('command-r')) { | |
| 702 | + const handler = createWebTokenizerEncodingHandler(commandTokenizer); | |
| 703 | + return handler(req, res); | |
| 704 | + } | |
| 705 | + | |
| 610 | 706 | const model = getTokenizerModel(queryModel); |
| 611 | 707 | const handler = createTiktokenEncodingHandler(model); |
| 612 | 708 | return handler(req, res); |
| @@ -655,6 +751,16 @@ router.post('/openai/decode', jsonParser, async function (req, res) { | ||
| 655 | 751 | return handler(req, res); |
| 656 | 752 | } |
| 657 | 753 | |
| 754 | + if (queryModel.includes('qwen2')) { | |
| 755 | + const handler = createWebTokenizerDecodingHandler(qwen2Tokenizer); | |
| 756 | + return handler(req, res); | |
| 757 | + } | |
| 758 | + | |
| 759 | + if (queryModel.includes('command-r')) { | |
| 760 | + const handler = createWebTokenizerDecodingHandler(commandTokenizer); | |
| 761 | + return handler(req, res); | |
| 762 | + } | |
| 763 | + | |
| 658 | 764 | const model = getTokenizerModel(queryModel); |
| 659 | 765 | const handler = createTiktokenDecodingHandler(model); |
| 660 | 766 | return handler(req, res); |
| @@ -711,6 +817,20 @@ router.post('/openai/count', jsonParser, async function (req, res) { | ||
| 711 | 817 | return res.send({ 'token_count': num_tokens }); |
| 712 | 818 | } |
| 713 | 819 | |
| 820 | + if (model === 'qwen2') { | |
| 821 | + const instance = await qwen2Tokenizer.get(); | |
| 822 | + if (!instance) throw new Error('Failed to load the Qwen2 tokenizer'); | |
| 823 | + num_tokens = countWebTokenizerTokens(instance, req.body); | |
| 824 | + return res.send({ 'token_count': num_tokens }); | |
| 825 | + } | |
| 826 | + | |
| 827 | + if (model === 'command-r') { | |
| 828 | + const instance = await commandTokenizer.get(); | |
| 829 | + if (!instance) throw new Error('Failed to load the Command-R tokenizer'); | |
| 830 | + num_tokens = countWebTokenizerTokens(instance, req.body); | |
| 831 | + return res.send({ 'token_count': num_tokens }); | |
| 832 | + } | |
| 833 | + | |
| 714 | 834 | const tokensPerName = queryModel.includes('gpt-3.5-turbo-0301') ? -1 : 1; |
| 715 | 835 | const tokensPerMessage = queryModel.includes('gpt-3.5-turbo-0301') ? 4 : 3; |
| 716 | 836 | const tokensPadding = 3; |
| @@ -647,6 +647,20 @@ function getSeparator(n) { | ||
| 647 | 647 | return '='.repeat(n); |
| 648 | 648 | } |
| 649 | 649 | |
| 650 | +/** | |
| 651 | + * Checks if the string is a valid URL. | |
| 652 | + * @param {string} url String to check | |
| 653 | + * @returns {boolean} If the URL is valid | |
| 654 | + */ | |
| 655 | +function isValidUrl(url) { | |
| 656 | + try { | |
| 657 | + new URL(url); | |
| 658 | + return true; | |
| 659 | + } catch (error) { | |
| 660 | + return false; | |
| 661 | + } | |
| 662 | +} | |
| 663 | + | |
| 650 | 664 | module.exports = { |
| 651 | 665 | getConfig, |
| 652 | 666 | getConfigValue, |
| @@ -676,4 +690,5 @@ module.exports = { | ||
| 676 | 690 | makeHttp2Request, |
| 677 | 691 | removeColorFormatting, |
| 678 | 692 | getSeparator, |
| 693 | + isValidUrl, | |
| 679 | 694 | }; |