OpenRouter: Add as vector storage provider (#4736) * OpenRouter: Add as vector storage provider * Better array filtering
Signed| @@ -62,6 +62,7 @@ const settings = { | ||
| 62 | 62 | togetherai_model: 'togethercomputer/m2-bert-80M-32k-retrieval', |
| 63 | 63 | openai_model: 'text-embedding-ada-002', |
| 64 | 64 | electronhub_model: 'text-embedding-3-small', |
| 65 | + openrouter_model: 'openai/text-embedding-3-large', | |
| 65 | 66 | cohere_model: 'embed-english-v3.0', |
| 66 | 67 | ollama_model: 'mxbai-embed-large', |
| 67 | 68 | ollama_keep: false, |
| @@ -790,6 +791,9 @@ function getVectorsRequestBody(args = {}) { | ||
| 790 | 791 | case 'electronhub': |
| 791 | 792 | body.model = extension_settings.vectors.electronhub_model; |
| 792 | 793 | break; |
| 794 | + case 'openrouter': | |
| 795 | + body.model = extension_settings.vectors.openrouter_model; | |
| 796 | + break; | |
| 793 | 797 | case 'togetherai': |
| 794 | 798 | body.model = extension_settings.vectors.togetherai_model; |
| 795 | 799 | break; |
| @@ -909,6 +913,7 @@ async function insertVectorItems(collectionId, items) { | ||
| 909 | 913 | function throwIfSourceInvalid() { |
| 910 | 914 | if (settings.source === 'openai' && !secret_state[SECRET_KEYS.OPENAI] || |
| 911 | 915 | settings.source === 'electronhub' && !secret_state[SECRET_KEYS.ELECTRONHUB] || |
| 916 | + settings.source === 'openrouter' && !secret_state[SECRET_KEYS.OPENROUTER] || | |
| 912 | 917 | settings.source === 'palm' && !secret_state[SECRET_KEYS.MAKERSUITE] || |
| 913 | 918 | settings.source === 'vertexai' && !secret_state[SECRET_KEYS.VERTEXAI] && !secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT] || |
| 914 | 919 | settings.source === 'mistral' && !secret_state[SECRET_KEYS.MISTRALAI] || |
| @@ -1122,6 +1127,7 @@ function toggleSettings() { | ||
| 1122 | 1127 | $('#together_vectorsModel').toggle(settings.source === 'togetherai'); |
| 1123 | 1128 | $('#openai_vectorsModel').toggle(settings.source === 'openai'); |
| 1124 | 1129 | $('#electronhub_vectorsModel').toggle(settings.source === 'electronhub'); |
| 1130 | + $('#openrouter_vectorsModel').toggle(settings.source === 'openrouter'); | |
| 1125 | 1131 | $('#cohere_vectorsModel').toggle(settings.source === 'cohere'); |
| 1126 | 1132 | $('#ollama_vectorsModel').toggle(settings.source === 'ollama'); |
| 1127 | 1133 | $('#llamacpp_vectorsModel').toggle(settings.source === 'llamacpp'); |
| @@ -1138,6 +1144,9 @@ function toggleSettings() { | ||
| 1138 | 1144 | case 'electronhub': |
| 1139 | 1145 | loadElectronHubModels(); |
| 1140 | 1146 | break; |
| 1147 | + case 'openrouter': | |
| 1148 | + loadOpenRouterModels(); | |
| 1149 | + break; | |
| 1141 | 1150 | } |
| 1142 | 1151 | } |
| 1143 | 1152 | |
| @@ -1180,6 +1189,44 @@ function populateElectronHubModelSelect(models) { | ||
| 1180 | 1189 | $('#vectors_electronhub_model').val(settings.electronhub_model); |
| 1181 | 1190 | } |
| 1182 | 1191 | |
| 1192 | +async function loadOpenRouterModels() { | |
| 1193 | + try { | |
| 1194 | + const response = await fetch('/api/openrouter/models/embedding', { | |
| 1195 | + method: 'POST', | |
| 1196 | + headers: getRequestHeaders(), | |
| 1197 | + }); | |
| 1198 | + if (!response.ok) { | |
| 1199 | + throw new Error(`HTTP ${response.status}`); | |
| 1200 | + } | |
| 1201 | + /** @type {Array<any>} */ | |
| 1202 | + const data = await response.json(); | |
| 1203 | + const models = Array.isArray(data) ? data : []; | |
| 1204 | + populateOpenRouterModelSelect(models); | |
| 1205 | + } catch (err) { | |
| 1206 | + console.warn('OpenRouter models fetch failed', err); | |
| 1207 | + populateOpenRouterModelSelect([]); | |
| 1208 | + } | |
| 1209 | +} | |
| 1210 | + | |
| 1211 | +/** | |
| 1212 | + * Populates the OpenRouter model select element. | |
| 1213 | + * @param {{ id: string, name: string }[]} models OpenRouter models | |
| 1214 | + */ | |
| 1215 | +function populateOpenRouterModelSelect(models) { | |
| 1216 | + const select = $('#vectors_openrouter_model'); | |
| 1217 | + select.empty(); | |
| 1218 | + for (const m of models) { | |
| 1219 | + const option = document.createElement('option'); | |
| 1220 | + option.value = m.id; | |
| 1221 | + option.text = m.name || m.id; | |
| 1222 | + select.append(option); | |
| 1223 | + } | |
| 1224 | + if (!settings.openrouter_model && models.length) { | |
| 1225 | + settings.openrouter_model = models[0].id; | |
| 1226 | + } | |
| 1227 | + $('#vectors_openrouter_model').val(settings.openrouter_model); | |
| 1228 | +} | |
| 1229 | + | |
| 1183 | 1230 | /** |
| 1184 | 1231 | * Executes a function with WebLLM error handling. |
| 1185 | 1232 | * @param {function(): Promise<T>} func Function to execute |
| @@ -1583,6 +1630,11 @@ jQuery(async () => { | ||
| 1583 | 1630 | Object.assign(extension_settings.vectors, settings); |
| 1584 | 1631 | saveSettingsDebounced(); |
| 1585 | 1632 | }); |
| 1633 | + $('#vectors_openrouter_model').val(settings.openrouter_model).on('change', () => { | |
| 1634 | + settings.openrouter_model = String($('#vectors_openrouter_model').val()); | |
| 1635 | + Object.assign(extension_settings.vectors, settings); | |
| 1636 | + saveSettingsDebounced(); | |
| 1637 | + }); | |
| 1586 | 1638 | $('#vectors_cohere_model').val(settings.cohere_model).on('change', () => { |
| 1587 | 1639 | settings.cohere_model = String($('#vectors_cohere_model').val()); |
| 1588 | 1640 | Object.assign(extension_settings.vectors, settings); |
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | <option value="nomicai">NomicAI</option> |
| 23 | 23 | <option value="ollama">Ollama</option> |
| 24 | 24 | <option value="openai">OpenAI</option> |
| 25 | + <option value="openrouter">OpenRouter</option> | |
| 25 | 26 | <option value="togetherai">TogetherAI</option> |
| 26 | 27 | <option value="vllm">vLLM</option> |
| 27 | 28 | <option value="webllm" data-i18n="WebLLM Extension">WebLLM Extension</option> |
| @@ -163,6 +164,15 @@ | ||
| 163 | 164 | <span data-i18n="Click to set">Click to set</span> |
| 164 | 165 | </div> |
| 165 | 166 | </div> |
| 167 | + <div class="flex-container flexFlowColumn" id="openrouter_vectorsModel"> | |
| 168 | + <label for="vectors_openrouter_model" data-i18n="Vectorization Model"> | |
| 169 | + Vectorization Model | |
| 170 | + </label> | |
| 171 | + <select id="vectors_openrouter_model" class="text_pole"></select> | |
| 172 | + <i data-i18n="Hint: Set your OpenRouter API key in API Connections."> | |
| 173 | + Hint: Set your OpenRouter API key in API Connections. | |
| 174 | + </i> | |
| 175 | + </div> | |
| 166 | 176 | |
| 167 | 177 | <div class="flex-container marginTopBot5"> |
| 168 | 178 | <div class="flex-container flex1 flexFlowColumn" title="How many last messages will be matched for relevance."> |
| @@ -55,3 +55,37 @@ router.post('/models/multimodal', async (_req, res) => { | ||
| 55 | 55 | return res.sendStatus(500); |
| 56 | 56 | } |
| 57 | 57 | }); |
| 58 | + | |
| 59 | +router.post('/models/embedding', async (_req, res) => { | |
| 60 | + try { | |
| 61 | + // The endpoint is available without authentication | |
| 62 | + const response = await fetch(`${API_OPENROUTER}/models`, { | |
| 63 | + method: 'GET', | |
| 64 | + headers: { | |
| 65 | + 'Accept': 'application/json', | |
| 66 | + }, | |
| 67 | + }); | |
| 68 | + | |
| 69 | + if (!response.ok) { | |
| 70 | + console.warn('OpenRouter API request failed', response.statusText); | |
| 71 | + return res.json([]); | |
| 72 | + } | |
| 73 | + | |
| 74 | + /** @type {any} */ | |
| 75 | + const data = await response.json(); | |
| 76 | + | |
| 77 | + if (!Array.isArray(data?.data)) { | |
| 78 | + console.warn('OpenRouter API response was not an array'); | |
| 79 | + return res.json([]); | |
| 80 | + } | |
| 81 | + | |
| 82 | + const embeddingModels = data.data | |
| 83 | + .filter(m => Array.isArray(m?.architecture?.output_modalities)) | |
| 84 | + .filter(m => m.architecture.output_modalities.includes('embeddings')); | |
| 85 | + | |
| 86 | + return res.json(embeddingModels); | |
| 87 | + } catch (error) { | |
| 88 | + console.error(error); | |
| 89 | + return res.sendStatus(500); | |
| 90 | + } | |
| 91 | +}); | |
| @@ -35,6 +35,7 @@ const SOURCES = [ | ||
| 35 | 35 | 'koboldcpp', |
| 36 | 36 | 'vertexai', |
| 37 | 37 | 'electronhub', |
| 38 | + 'openrouter', | |
| 38 | 39 | ]; |
| 39 | 40 | |
| 40 | 41 | /** |
| @@ -56,6 +57,8 @@ async function getVector(source, sourceSettings, text, isQuery, directories) { | ||
| 56 | 57 | return getOpenAIVector(text, source, directories, sourceSettings.model); |
| 57 | 58 | case 'electronhub': |
| 58 | 59 | return getOpenAIVector(text, source, directories, sourceSettings.model); |
| 60 | + case 'openrouter': | |
| 61 | + return getOpenAIVector(text, source, directories, sourceSettings.model); | |
| 59 | 62 | case 'transformers': |
| 60 | 63 | return getTransformersVector(text); |
| 61 | 64 | case 'extras': |
| @@ -108,6 +111,9 @@ async function getBatchVector(source, sourceSettings, texts, isQuery, directorie | ||
| 108 | 111 | case 'electronhub': |
| 109 | 112 | results.push(...await getOpenAIBatchVector(batch, source, directories, sourceSettings.model)); |
| 110 | 113 | break; |
| 114 | + case 'openrouter': | |
| 115 | + results.push(...await getOpenAIBatchVector(batch, source, directories, sourceSettings.model)); | |
| 116 | + break; | |
| 111 | 117 | case 'transformers': |
| 112 | 118 | results.push(...await getTransformersBatchVector(batch)); |
| 113 | 119 | break; |
| @@ -166,6 +172,10 @@ function getSourceSettings(source, request) { | ||
| 166 | 172 | return { |
| 167 | 173 | model: String(request.body.model || 'text-embedding-3-small'), |
| 168 | 174 | }; |
| 175 | + case 'openrouter': | |
| 176 | + return { | |
| 177 | + model: String(request.body.model) || 'openai/text-embedding-3-large', | |
| 178 | + }; | |
| 169 | 179 | case 'cohere': |
| 170 | 180 | return { |
| 171 | 181 | model: String(request.body.model), |
| @@ -1,26 +1,37 @@ | ||
| 1 | 1 | import fetch from 'node-fetch'; |
| 2 | 2 | import { SECRET_KEYS, readSecret } from '../endpoints/secrets.js'; |
| 3 | +import { OPENROUTER_HEADERS } from '../constants.js'; | |
| 3 | 4 | |
| 4 | 5 | const SOURCES = { |
| 5 | 6 | 'togetherai': { |
| 6 | 7 | secretKey: SECRET_KEYS.TOGETHERAI, |
| 7 | 8 | url: 'https://api.together.xyz/v1', |
| 8 | 9 | model: 'togethercomputer/m2-bert-80M-32k-retrieval', |
| 10 | + headers: {}, | |
| 9 | 11 | }, |
| 10 | 12 | 'mistral': { |
| 11 | 13 | secretKey: SECRET_KEYS.MISTRALAI, |
| 12 | 14 | url: 'https://api.mistral.ai/v1', |
| 13 | 15 | model: 'mistral-embed', |
| 16 | + headers: {}, | |
| 14 | 17 | }, |
| 15 | 18 | 'openai': { |
| 16 | 19 | secretKey: SECRET_KEYS.OPENAI, |
| 17 | 20 | url: 'https://api.openai.com/v1', |
| 18 | 21 | model: 'text-embedding-ada-002', |
| 22 | + headers: {}, | |
| 19 | 23 | }, |
| 20 | 24 | 'electronhub': { |
| 21 | 25 | secretKey: SECRET_KEYS.ELECTRONHUB, |
| 22 | 26 | url: 'https://api.electronhub.ai/v1', |
| 23 | 27 | model: 'text-embedding-3-small', |
| 28 | + headers: {}, | |
| 29 | + }, | |
| 30 | + 'openrouter': { | |
| 31 | + secretKey: SECRET_KEYS.OPENROUTER, | |
| 32 | + url: 'https://openrouter.ai/api/v1', | |
| 33 | + model: 'openai/text-embedding-3-large', | |
| 34 | + headers: { ...OPENROUTER_HEADERS }, | |
| 24 | 35 | }, |
| 25 | 36 | }; |
| 26 | 37 | |
| @@ -48,11 +59,12 @@ export async function getOpenAIBatchVector(texts, source, directories, model = ' | ||
| 48 | 59 | } |
| 49 | 60 | |
| 50 | 61 | const url = config.url; |
| 51 | 62 | const response = await fetch(`https://${url}/v1/embeddings`, { |
| 52 | 63 | method: 'POST', |
| 53 | 64 | headers: { |
| 54 | 65 | 'Content-Type': 'application/json', |
| 55 | 66 | 'Authorization': `Bearer ${key}`, |
| 67 | + ...config.headers, | |
| 56 | 68 | }, |
| 57 | 69 | body: JSON.stringify({ |
| 58 | 70 | input: texts, |