diff --git a/src/_locales/de/main.json b/src/_locales/de/main.json index 450f97e8e..1c7122429 100644 --- a/src/_locales/de/main.json +++ b/src/_locales/de/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Benutzerdefiniertes Modell", + "Custom Provider": "Benutzerdefinierter Anbieter", "Balanced": "Ausgeglichen", "Creative": "Kreativ", "Precise": "Präzise", @@ -114,6 +115,7 @@ "Modules": "Module", "API Params": "API-Parameter", "API Url": "API-URL", + "Provider": "Anbieter", "Others": "Andere", "API Modes": "API-Modi", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Deaktivieren Sie die Verlaufsfunktion im Webmodus für besseren Datenschutz. Beachten Sie jedoch, dass die Gespräche nach einer gewissen Zeit nicht mehr verfügbar sind", @@ -136,6 +138,7 @@ "Custom Claude API Url": "Benutzerdefinierte Claude-API-URL", "Cancel": "Abbrechen", "Name is required": "Name ist erforderlich", + "Please enter a full Chat Completions URL": "Bitte geben Sie eine vollständige Chat Completions URL ein", "Prompt template should include {{selection}}": "Die Vorlage sollte {{selection}} enthalten", "Save": "Speichern", "Name": "Name", diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index 174f99afa..0215a1938 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Custom Model", + "Custom Provider": "Custom Provider", "Balanced": "Balanced", "Creative": "Creative", "Precise": "Precise", @@ -114,6 +115,7 @@ "Modules": "Modules", "API Params": "API Params", "API Url": "API Url", + "Provider": "Provider", "Others": "Others", "API Modes": "API Modes", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time", @@ -136,6 +138,7 @@ "Custom Claude API Url": "Custom Claude API Url", "Cancel": "Cancel", "Name is required": "Name is required", + "Please enter a full Chat Completions URL": "Please enter a full Chat Completions URL", "Prompt template should include {{selection}}": "Prompt template should include {{selection}}", "Save": "Save", "Name": "Name", diff --git a/src/_locales/es/main.json b/src/_locales/es/main.json index df4c8a4a6..5eb1bf22f 100644 --- a/src/_locales/es/main.json +++ b/src/_locales/es/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Modelo personalizado", + "Custom Provider": "Proveedor personalizado", "Balanced": "Equilibrado", "Creative": "Creativo", "Precise": "Preciso", @@ -114,6 +115,7 @@ "Modules": "Módulos", "API Params": "Parámetros de la API", "API Url": "URL de la API", + "Provider": "Proveedor", "Others": "Otros", "API Modes": "Modos de la API", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Desactivar el historial del modo web para una mejor protección de la privacidad, pero esto resultará en conversaciones no disponibles después de un período de tiempo.", @@ -136,6 +138,7 @@ "Custom Claude API Url": "URL personalizada de la API de Claude", "Cancel": "Cancelar", "Name is required": "Se requiere un nombre", + "Please enter a full Chat Completions URL": "Introduzca una URL completa de Chat Completions", "Prompt template should include {{selection}}": "La plantilla de sugerencias debe incluir {{selection}}", "Save": "Guardar", "Name": "Nombre", diff --git a/src/_locales/fr/main.json b/src/_locales/fr/main.json index c8e76ca4b..572c3c47e 100644 --- a/src/_locales/fr/main.json +++ b/src/_locales/fr/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Modèle personnalisé", + "Custom Provider": "Fournisseur personnalisé", "Balanced": "Équilibré", "Creative": "Créatif", "Precise": "Précis", @@ -114,6 +115,7 @@ "Modules": "Modules", "API Params": "Paramètres de l'API", "API Url": "URL de l'API", + "Provider": "Fournisseur", "Others": "Autres", "API Modes": "Modes de l'API", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Désactivez l'historique du mode web pour une meilleure protection de la vie privée, mais cela entraînera des conversations non disponibles après un certain temps", @@ -136,6 +138,7 @@ "Custom Claude API Url": "URL API Claude personnalisée", "Cancel": "Annuler", "Name is required": "Le nom est requis", + "Please enter a full Chat Completions URL": "Veuillez saisir une URL complète de Chat Completions", "Prompt template should include {{selection}}": "Le modèle de suggestion doit inclure {{selection}}", "Save": "Enregistrer", "Name": "Nom", diff --git a/src/_locales/in/main.json b/src/_locales/in/main.json index 064372ffc..ee2a80006 100644 --- a/src/_locales/in/main.json +++ b/src/_locales/in/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Model Kustom", + "Custom Provider": "Penyedia Kustom", "Balanced": "Seimbang", "Creative": "Kreatif", "Precise": "Tepat", @@ -114,6 +115,7 @@ "Modules": "Modul", "API Params": "Parameter API", "API Url": "URL API", + "Provider": "Penyedia", "Others": "Lainnya", "API Modes": "Mode API", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Nonaktifkan riwayat mode web untuk perlindungan privasi yang lebih baik, tetapi ini akan menyebabkan percakapan tidak tersedia setelah jangka waktu tertentu", @@ -136,6 +138,7 @@ "Custom Claude API Url": "URL API Claude Kustom", "Cancel": "Batal", "Name is required": "Nama diperlukan", + "Please enter a full Chat Completions URL": "Masukkan URL Chat Completions lengkap", "Prompt template should include {{selection}}": "Template prompt harus mencakup {{selection}}", "Save": "Simpan", "Name": "Nama", diff --git a/src/_locales/it/main.json b/src/_locales/it/main.json index 87c9e46c6..8fa4055ca 100644 --- a/src/_locales/it/main.json +++ b/src/_locales/it/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Modello personalizzato", + "Custom Provider": "Provider personalizzato", "Balanced": "Bilanciato", "Creative": "Creativo", "Precise": "Preciso", @@ -114,6 +115,7 @@ "Modules": "Moduli", "API Params": "Parametri API", "API Url": "URL API", + "Provider": "Provider", "Others": "Altri", "API Modes": "Modalità API", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Disabilita la cronologia della modalità web per una migliore protezione della privacy, ma ciò comporterà conversazioni non disponibili dopo un certo periodo di tempo", @@ -136,6 +138,7 @@ "Custom Claude API Url": "URL API Claude personalizzato", "Cancel": "Annulla", "Name is required": "Il nome è obbligatorio", + "Please enter a full Chat Completions URL": "Inserisci un URL completo di Chat Completions", "Prompt template should include {{selection}}": "Il modello di prompt dovrebbe includere {{selection}}", "Save": "Salva", "Name": "Nome", diff --git a/src/_locales/ja/main.json b/src/_locales/ja/main.json index 4f6ebf809..9ab9a0120 100644 --- a/src/_locales/ja/main.json +++ b/src/_locales/ja/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "カスタムモデル", + "Custom Provider": "カスタムプロバイダー", "Balanced": "バランスの取れた", "Creative": "創造的な", "Precise": "正確な", @@ -114,6 +115,7 @@ "Modules": "モジュール", "API Params": "APIパラメータ", "API Url": "API URL", + "Provider": "プロバイダー", "Others": "その他", "API Modes": "APIモード", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "プライバシー保護の向上のためにWebモードの履歴を無効にしますが、一定期間後に会話が利用できなくなります", @@ -136,6 +138,7 @@ "Custom Claude API Url": "カスタムClaude APIのURL", "Cancel": "キャンセル", "Name is required": "名前は必須です", + "Please enter a full Chat Completions URL": "完全な Chat Completions URL を入力してください", "Prompt template should include {{selection}}": "プロンプトテンプレートには {{selection}} を含める必要があります", "Save": "保存", "Name": "名前", diff --git a/src/_locales/ko/main.json b/src/_locales/ko/main.json index 92fe01a2a..d221bd3a2 100644 --- a/src/_locales/ko/main.json +++ b/src/_locales/ko/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "사용자 정의 모델", + "Custom Provider": "사용자 정의 공급자", "Balanced": "균형 잡힌", "Creative": "창의적인", "Precise": "정확한", @@ -114,6 +115,7 @@ "Modules": "모듈", "API Params": "API 매개변수", "API Url": "API 주소", + "Provider": "공급자", "Others": "기타", "API Modes": "API 모드", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "개인 정보 보호를 위해 웹 모드 기록을 비활성화하지만 일정 시간 이후에 대화를 사용할 수 없게 됩니다.", @@ -136,6 +138,7 @@ "Custom Claude API Url": "사용자 정의 Claude API URL", "Cancel": "취소", "Name is required": "이름은 필수입니다", + "Please enter a full Chat Completions URL": "전체 Chat Completions URL을 입력하세요", "Prompt template should include {{selection}}": "프롬프트 템플릿에는 {{selection}} 이 포함되어야 합니다", "Save": "저장", "Name": "이름", diff --git a/src/_locales/pt/main.json b/src/_locales/pt/main.json index 1cb7ef464..90265434b 100644 --- a/src/_locales/pt/main.json +++ b/src/_locales/pt/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Modelo Personalizado", + "Custom Provider": "Provedor Personalizado", "Balanced": "Equilibrado", "Creative": "Criativo", "Precise": "Preciso", @@ -114,6 +115,7 @@ "Modules": "Módulos", "API Params": "Parâmetros da API", "API Url": "URL da API", + "Provider": "Provedor", "Others": "Outros", "API Modes": "Modos da API", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Desative o histórico do modo web para uma melhor proteção de privacidade, mas isso resultará em conversas indisponíveis após um certo tempo.", @@ -136,6 +138,7 @@ "Custom Claude API Url": "URL da API Personalizada do Claude", "Cancel": "Cancelar", "Name is required": "Nome é obrigatório", + "Please enter a full Chat Completions URL": "Insira uma URL completa de Chat Completions", "Prompt template should include {{selection}}": "O modelo de prompt deve incluir {{selection}}", "Save": "Salvar", "Name": "Nome", diff --git a/src/_locales/ru/main.json b/src/_locales/ru/main.json index 08b701e34..3e3bed712 100644 --- a/src/_locales/ru/main.json +++ b/src/_locales/ru/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32к)", "GPT-3.5": "GPT-3.5", "Custom Model": "Пользовательская модель", + "Custom Provider": "Пользовательский провайдер", "Balanced": "Сбалансированный", "Creative": "Креативный", "Precise": "Точный", @@ -114,6 +115,7 @@ "Modules": "Модули", "API Params": "Параметры API", "API Url": "URL API", + "Provider": "Провайдер", "Others": "Другие", "API Modes": "Режимы API", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Отключить историю веб-режима для лучшей защиты конфиденциальности, но это приведет к недоступности разговоров после определенного времени", @@ -136,6 +138,7 @@ "Custom Claude API Url": "Пользовательский URL API Claude", "Cancel": "Отмена", "Name is required": "Имя обязательно", + "Please enter a full Chat Completions URL": "Введите полный URL Chat Completions", "Prompt template should include {{selection}}": "Шаблон запроса должен включать {{selection}}", "Save": "Сохранить", "Name": "Имя", diff --git a/src/_locales/tr/main.json b/src/_locales/tr/main.json index 7ecad89d7..23c2353a7 100644 --- a/src/_locales/tr/main.json +++ b/src/_locales/tr/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "Özel Model", + "Custom Provider": "Özel Sağlayıcı", "Balanced": "Dengeli", "Creative": "Yaratıcı", "Precise": "Duyarlı", @@ -114,6 +115,7 @@ "Modules": "Modüller", "API Params": "API Parametreleri", "API Url": "API Url'si", + "Provider": "Sağlayıcı", "Others": "Diğerleri", "API Modes": "API Modları", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "Daha iyi gizlilik koruması için web modu geçmişini devre dışı bırakın, ancak bir süre sonra kullanılamayan konuşmalara neden olacaktır", @@ -136,6 +138,7 @@ "Custom Claude API Url": "Özel Claude API Url'si", "Cancel": "İptal", "Name is required": "İsim gereklidir", + "Please enter a full Chat Completions URL": "Lütfen tam bir Chat Completions URL'si girin", "Prompt template should include {{selection}}": "Prompt şablonu {{selection}} içermelidir", "Save": "Kaydet", "Name": "İsim", diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json index 80d06c85b..1c8ccb3bc 100644 --- a/src/_locales/zh-hans/main.json +++ b/src/_locales/zh-hans/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "自定义模型", + "Custom Provider": "自定义提供商", "Balanced": "平衡", "Creative": "有创造力", "Precise": "精确", @@ -114,6 +115,7 @@ "Modules": "模块", "API Params": "API参数", "API Url": "API地址", + "Provider": "提供商", "Others": "其他", "API Modes": "API模式", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "禁用网页版模式历史记录以获得更好的隐私保护, 但会导致对话在一段时间后不可用", @@ -136,6 +138,7 @@ "Custom Claude API Url": "自定义的Claude API地址", "Cancel": "取消", "Name is required": "名称是必须的", + "Please enter a full Chat Completions URL": "请输入完整的 Chat Completions URL", "Prompt template should include {{selection}}": "提示模板应该包含 {{selection}}", "Save": "保存", "Name": "名称", diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json index e8edea882..e05e222a7 100644 --- a/src/_locales/zh-hant/main.json +++ b/src/_locales/zh-hant/main.json @@ -78,6 +78,7 @@ "ChatGPT (GPT-4-32k)": "ChatGPT (GPT-4-32k)", "GPT-3.5": "GPT-3.5", "Custom Model": "自訂模型", + "Custom Provider": "自訂供應商", "Balanced": "平衡", "Creative": "有創意", "Precise": "精確", @@ -114,6 +115,7 @@ "Modules": "模組", "API Params": "API 參數", "API Url": "API 網址", + "Provider": "供應商", "Others": "其他", "API Modes": "API 模式", "Disable web mode history for better privacy protection, but it will result in unavailable conversations after a period of time": "停用網頁版模式歷史記錄以提升隱私保護,但會導致對話記錄在一段時間後無法使用", @@ -136,6 +138,7 @@ "Custom Claude API Url": "自訂 Claude API 網址", "Cancel": "取消", "Name is required": "名稱是必填的", + "Please enter a full Chat Completions URL": "請輸入完整的 Chat Completions URL", "Prompt template should include {{selection}}": "提示範本應該包含 {{selection}}", "Save": "儲存", "Name": "名稱", diff --git a/src/background/index.mjs b/src/background/index.mjs index ec5092fde..6094fc9c0 100644 --- a/src/background/index.mjs +++ b/src/background/index.mjs @@ -5,18 +5,10 @@ import { sendMessageFeedback, } from '../services/apis/chatgpt-web' import { generateAnswersWithBingWebApi } from '../services/apis/bing-web.mjs' -import { - generateAnswersWithChatgptApi, - generateAnswersWithGptCompletionApi, -} from '../services/apis/openai-api' -import { generateAnswersWithCustomApi } from '../services/apis/custom-api.mjs' -import { generateAnswersWithOllamaApi } from '../services/apis/ollama-api.mjs' +import { generateAnswersWithOpenAICompatibleApi } from '../services/apis/openai-api' import { generateAnswersWithAzureOpenaiApi } from '../services/apis/azure-openai-api.mjs' import { generateAnswersWithClaudeApi } from '../services/apis/claude-api.mjs' -import { generateAnswersWithChatGLMApi } from '../services/apis/chatglm-api.mjs' import { generateAnswersWithWaylaidwandererApi } from '../services/apis/waylaidwanderer-api.mjs' -import { generateAnswersWithOpenRouterApi } from '../services/apis/openrouter-api.mjs' -import { generateAnswersWithAimlApi } from '../services/apis/aiml-api.mjs' import { defaultConfig, getUserConfig, @@ -52,10 +44,8 @@ import { refreshMenu } from './menus.mjs' import { registerCommands } from './commands.mjs' import { generateAnswersWithBardWebApi } from '../services/apis/bard-web.mjs' import { generateAnswersWithClaudeWebApi } from '../services/apis/claude-web.mjs' -import { generateAnswersWithMoonshotCompletionApi } from '../services/apis/moonshot-api.mjs' import { generateAnswersWithMoonshotWebApi } from '../services/apis/moonshot-web.mjs' import { isUsingModelName } from '../utils/model-name-convert.mjs' -import { generateAnswersWithDeepSeekApi } from '../services/apis/deepseek-api.mjs' import { redactSensitiveFields } from './redact.mjs' const RECONNECT_CONFIG = { @@ -346,6 +336,20 @@ function setPortProxy(port, proxyTabId) { } } +function isUsingOpenAICompatibleApiSession(session) { + return ( + isUsingCustomModel(session) || + isUsingChatgptApiModel(session) || + isUsingMoonshotApiModel(session) || + isUsingChatGLMApiModel(session) || + isUsingDeepSeekApiModel(session) || + isUsingOllamaApiModel(session) || + isUsingOpenRouterApiModel(session) || + isUsingAimlApiModel(session) || + isUsingGptCompletionApiModel(session) + ) +} + async function executeApi(session, port, config) { console.log( `[background] executeApi called for model: ${session.modelName}, apiMode: ${session.apiMode}`, @@ -361,29 +365,7 @@ async function executeApi(session, port, config) { ) } try { - if (isUsingCustomModel(session)) { - console.debug('[background] Using Custom Model API') - if (!session.apiMode) - await generateAnswersWithCustomApi( - port, - session.question, - session, - config.customModelApiUrl.trim() || 'http://localhost:8000/v1/chat/completions', - config.customApiKey, - config.customModelName, - ) - else - await generateAnswersWithCustomApi( - port, - session.question, - session, - session.apiMode.customUrl?.trim() || - config.customModelApiUrl.trim() || - 'http://localhost:8000/v1/chat/completions', - session.apiMode.apiKey?.trim() || config.customApiKey, - session.apiMode.customName, - ) - } else if (isUsingChatgptWebModel(session)) { + if (isUsingChatgptWebModel(session)) { console.debug('[background] Using ChatGPT Web Model') let tabId if ( @@ -508,46 +490,15 @@ async function executeApi(session, port, config) { console.debug('[background] Using Gemini Web Model') const cookies = await getBardCookies() await generateAnswersWithBardWebApi(port, session.question, session, cookies) - } else if (isUsingChatgptApiModel(session)) { - console.debug('[background] Using ChatGPT API Model') - await generateAnswersWithChatgptApi(port, session.question, session, config.apiKey) + } else if (isUsingOpenAICompatibleApiSession(session)) { + console.debug('[background] Using OpenAI-compatible API provider') + await generateAnswersWithOpenAICompatibleApi(port, session.question, session, config) } else if (isUsingClaudeApiModel(session)) { console.debug('[background] Using Claude API Model') await generateAnswersWithClaudeApi(port, session.question, session) - } else if (isUsingMoonshotApiModel(session)) { - console.debug('[background] Using Moonshot API Model') - await generateAnswersWithMoonshotCompletionApi( - port, - session.question, - session, - config.moonshotApiKey, - ) - } else if (isUsingChatGLMApiModel(session)) { - console.debug('[background] Using ChatGLM API Model') - await generateAnswersWithChatGLMApi(port, session.question, session) - } else if (isUsingDeepSeekApiModel(session)) { - console.debug('[background] Using DeepSeek API Model') - await generateAnswersWithDeepSeekApi(port, session.question, session, config.deepSeekApiKey) - } else if (isUsingOllamaApiModel(session)) { - console.debug('[background] Using Ollama API Model') - await generateAnswersWithOllamaApi(port, session.question, session) - } else if (isUsingOpenRouterApiModel(session)) { - console.debug('[background] Using OpenRouter API Model') - await generateAnswersWithOpenRouterApi( - port, - session.question, - session, - config.openRouterApiKey, - ) - } else if (isUsingAimlApiModel(session)) { - console.debug('[background] Using AIML API Model') - await generateAnswersWithAimlApi(port, session.question, session, config.aimlApiKey) } else if (isUsingAzureOpenAiApiModel(session)) { console.debug('[background] Using Azure OpenAI API Model') await generateAnswersWithAzureOpenaiApi(port, session.question, session) - } else if (isUsingGptCompletionApiModel(session)) { - console.debug('[background] Using GPT Completion API Model') - await generateAnswersWithGptCompletionApi(port, session.question, session, config.apiKey) } else if (isUsingGithubThirdPartyApiModel(session)) { console.debug('[background] Using Github Third Party API Model') await generateAnswersWithWaylaidwandererApi(port, session.question, session) diff --git a/src/config/index.mjs b/src/config/index.mjs index a27932e83..bc1eb6275 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -7,6 +7,10 @@ import { modelNameToDesc, } from '../utils/model-name-convert.mjs' import { t } from 'i18next' +import { + LEGACY_SECRET_KEY_TO_PROVIDER_ID, + OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID as API_MODE_GROUP_TO_PROVIDER_ID, +} from './openai-provider-mappings.mjs' export const TriggerMode = { always: 'Always', @@ -564,9 +568,13 @@ export const defaultConfig = { customName: '', customUrl: '', apiKey: '', + providerId: '', active: false, }, ], + customOpenAIProviders: [], + providerSecrets: {}, + configSchemaVersion: 1, activeSelectionTools: ['translate', 'translateToEn', 'summary', 'polish', 'code', 'ask'], customSelectionTools: [ { @@ -739,15 +747,584 @@ export async function getPreferredLanguageKey() { return config.preferredLanguage } +const CONFIG_SCHEMA_VERSION = 1 + +function normalizeText(value) { + return typeof value === 'string' ? value.trim() : '' +} + +function normalizeProviderId(value) { + return normalizeText(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function normalizeEndpointUrlForCompare(value) { + return normalizeText(value).replace(/\/+$/, '') +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function areStringRecordValuesEqual(leftRecord, rightRecord) { + const leftIsRecord = isPlainObject(leftRecord) + const rightIsRecord = isPlainObject(rightRecord) + if (!leftIsRecord || !rightIsRecord) { + return !leftIsRecord && !rightIsRecord && leftRecord === rightRecord + } + const left = leftRecord + const right = rightRecord + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + if (leftKeys.length !== rightKeys.length) return false + for (const key of leftKeys) { + if (!Object.hasOwn(right, key)) return false + if (normalizeText(left[key]) !== normalizeText(right[key])) return false + } + return true +} + +function ensureUniqueProviderId(providerIdSet, preferredId) { + let id = preferredId || 'custom-provider' + let suffix = 2 + while (providerIdSet.has(id)) { + id = `${preferredId || 'custom-provider'}-${suffix}` + suffix += 1 + } + return id +} + +function normalizeCustomProviderForStorage(provider, index, providerIdSet) { + if (!provider || typeof provider !== 'object') return null + const originalRawId = normalizeText(provider.id) + const originalId = normalizeProviderId(provider.id) + const preferredId = originalId || `custom-provider-${index + 1}` + const id = ensureUniqueProviderId(providerIdSet, preferredId) + providerIdSet.add(id) + return { + originalId, + originalRawId, + provider: { + id, + name: normalizeText(provider.name) || `Custom Provider ${index + 1}`, + baseUrl: normalizeText(provider.baseUrl), + chatCompletionsPath: normalizeText(provider.chatCompletionsPath) || '/v1/chat/completions', + completionsPath: normalizeText(provider.completionsPath) || '/v1/completions', + chatCompletionsUrl: normalizeText(provider.chatCompletionsUrl), + completionsUrl: normalizeText(provider.completionsUrl), + enabled: provider.enabled !== false, + allowLegacyResponseField: Boolean(provider.allowLegacyResponseField), + }, + } +} + +function migrateUserConfig(options) { + const migrated = { ...options } + let dirty = false + + if (migrated.customChatGptWebApiUrl === 'https://chat.openai.com') { + migrated.customChatGptWebApiUrl = 'https://chatgpt.com' + dirty = true + } + + const hasProviderSecretsRecord = isPlainObject(migrated.providerSecrets) + const providerSecrets = hasProviderSecretsRecord ? { ...migrated.providerSecrets } : {} + if (!hasProviderSecretsRecord) { + dirty = true + } + for (const [legacyKey, providerId] of Object.entries(LEGACY_SECRET_KEY_TO_PROVIDER_ID)) { + const legacyKeyValue = normalizeText(migrated[legacyKey]) + const hasProviderSecret = Object.hasOwn(providerSecrets, providerId) + if (legacyKeyValue && !hasProviderSecret) { + providerSecrets[providerId] = legacyKeyValue + dirty = true + } + } + + const builtinProviderIds = new Set( + Object.values(API_MODE_GROUP_TO_PROVIDER_ID) + .map((providerId) => normalizeText(providerId)) + .filter((providerId) => providerId), + ) + const providerIdSet = new Set(builtinProviderIds) + const providerIdRenameLookup = new Map() + const providerIdRenames = [] + const rawCustomOpenAIProviders = Array.isArray(migrated.customOpenAIProviders) + ? migrated.customOpenAIProviders + : [] + const legacyCustomProviderIds = new Set( + rawCustomOpenAIProviders + .map((provider) => normalizeProviderId(provider?.id)) + .filter((providerId) => providerId), + ) + const normalizedProviderResults = rawCustomOpenAIProviders + .map((provider, index) => normalizeCustomProviderForStorage(provider, index, providerIdSet)) + .filter((result) => result && result.provider) + const unchangedProviderIds = new Set( + normalizedProviderResults + .filter( + ({ originalId, provider }) => originalId && originalId === normalizeProviderId(provider.id), + ) + .map(({ provider }) => normalizeProviderId(provider.id)) + .filter((id) => id), + ) + const customOpenAIProviders = normalizedProviderResults.map( + ({ originalId, originalRawId, provider }) => { + if (originalId && originalId !== provider.id) { + providerIdRenames.push({ oldId: originalId, oldRawId: originalRawId, newId: provider.id }) + if (!providerIdRenameLookup.has(originalId) && !unchangedProviderIds.has(originalId)) { + providerIdRenameLookup.set(originalId, provider.id) + } + dirty = true + } + return provider + }, + ) + if (!Array.isArray(migrated.customOpenAIProviders)) dirty = true + + for (let index = providerIdRenames.length - 1; index >= 0; index -= 1) { + const { + oldId: oldProviderId, + oldRawId: oldRawProviderId, + newId: newProviderId, + } = providerIdRenames[index] + if (oldProviderId === newProviderId) continue + if (!legacyCustomProviderIds.has(oldProviderId)) continue + const rawIdSecret = normalizeText(providerSecrets[oldRawProviderId]) + const normalizedIdSecret = normalizeText(providerSecrets[oldProviderId]) + const oldSecret = + rawIdSecret || (builtinProviderIds.has(oldProviderId) ? '' : normalizedIdSecret) + if (oldSecret && normalizeText(providerSecrets[newProviderId]) !== oldSecret) { + providerSecrets[newProviderId] = oldSecret + dirty = true + } + } + + for (const { originalRawId, provider } of normalizedProviderResults) { + const rawProviderId = normalizeText(originalRawId) + const normalizedProviderId = normalizeText(provider?.id) + if (!rawProviderId || !normalizedProviderId || rawProviderId === normalizedProviderId) continue + const rawSecret = normalizeText(providerSecrets[rawProviderId]) + if (!rawSecret) continue + if (!normalizeText(providerSecrets[normalizedProviderId])) { + providerSecrets[normalizedProviderId] = rawSecret + dirty = true + } + } + + const customApiModes = Array.isArray(migrated.customApiModes) + ? migrated.customApiModes.map((apiMode) => ({ ...apiMode })) + : [] + if (!Array.isArray(migrated.customApiModes)) dirty = true + + let customProviderCounter = customOpenAIProviders.length + let customApiModesDirty = false + let customProvidersDirty = false + const migratedCustomModeProviderIds = new Map() + const legacyCustomProviderSecret = normalizeText(providerSecrets['legacy-custom-default']) + const hasOwnProviderSecret = (providerId) => + Object.prototype.hasOwnProperty.call(providerSecrets, providerId) + const getCustomModeMigrationSignature = (apiMode) => + JSON.stringify({ + groupName: normalizeText(apiMode?.groupName), + itemName: normalizeText(apiMode?.itemName), + isCustom: Boolean(apiMode?.isCustom), + customName: normalizeText(apiMode?.customName), + customUrl: normalizeEndpointUrlForCompare(normalizeText(apiMode?.customUrl)), + providerId: normalizeProviderId( + typeof apiMode?.providerId === 'string' ? apiMode.providerId : '', + ), + apiKey: normalizeText(apiMode?.apiKey), + }) + const isProviderSecretCompatibleForCustomMode = (modeApiKey, providerSecret) => { + const effectiveModeKey = normalizeText(modeApiKey) || legacyCustomProviderSecret + if (effectiveModeKey) { + return !providerSecret || providerSecret === effectiveModeKey + } + return !providerSecret + } + const materializeCustomProviderForMode = (targetProviderId, preferredName) => { + customProviderCounter += 1 + const sourceProvider = customOpenAIProviders.find((item) => item.id === targetProviderId) + const providerName = + normalizeText(preferredName) || + normalizeText(sourceProvider?.name) || + `Custom Provider ${customProviderCounter}` + const preferredId = + normalizeProviderId(preferredName) || + normalizeProviderId(sourceProvider?.name) || + `custom-provider-${customProviderCounter}` + const providerId = ensureUniqueProviderId(providerIdSet, preferredId) + providerIdSet.add(providerId) + const provider = sourceProvider + ? { + ...sourceProvider, + id: providerId, + name: providerName, + } + : { + id: providerId, + name: providerName, + baseUrl: '', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + chatCompletionsUrl: + normalizeText(migrated.customModelApiUrl) || defaultConfig.customModelApiUrl, + completionsUrl: '', + enabled: true, + allowLegacyResponseField: true, + } + customOpenAIProviders.push(provider) + customProvidersDirty = true + dirty = true + return providerId + } + const promoteCustomModeApiKeyToProvider = (apiMode, apiModeKey) => { + const targetProviderId = normalizeText(apiMode.providerId) || 'legacy-custom-default' + const existingProviderSecret = normalizeText(providerSecrets[targetProviderId]) + if (!hasOwnProviderSecret(targetProviderId)) { + providerSecrets[targetProviderId] = apiModeKey + dirty = true + return targetProviderId + } + if (existingProviderSecret === apiModeKey) { + return targetProviderId + } + const reassignedProviderId = materializeCustomProviderForMode( + targetProviderId, + apiMode.customName, + ) + providerSecrets[reassignedProviderId] = apiModeKey + dirty = true + return reassignedProviderId + } + for (const apiMode of customApiModes) { + if (!apiMode || typeof apiMode !== 'object') continue + if (apiMode.groupName !== 'customApiModelKeys') { + const nonCustomApiModeKey = normalizeText(apiMode.apiKey) + if (nonCustomApiModeKey) { + const targetProviderId = + API_MODE_GROUP_TO_PROVIDER_ID[normalizeText(apiMode.groupName)] || + normalizeText(apiMode.providerId) + if (targetProviderId) { + if (!normalizeText(providerSecrets[targetProviderId])) { + providerSecrets[targetProviderId] = nonCustomApiModeKey + dirty = true + } + apiMode.apiKey = '' + customApiModesDirty = true + } + } + if (normalizeText(apiMode.providerId)) { + apiMode.providerId = '' + customApiModesDirty = true + } + continue + } + + const originalCustomModeSignature = getCustomModeMigrationSignature(apiMode) + const existingProviderIdRaw = typeof apiMode.providerId === 'string' ? apiMode.providerId : '' + const existingProviderId = normalizeProviderId(existingProviderIdRaw) + if (existingProviderId && existingProviderIdRaw !== existingProviderId) { + apiMode.providerId = existingProviderId + customApiModesDirty = true + } + let providerIdAssignedFromLegacyCustomUrl = false + const renamedProviderId = providerIdRenameLookup.get(existingProviderId) + if (renamedProviderId && normalizeText(apiMode.providerId) !== renamedProviderId) { + apiMode.providerId = renamedProviderId + customApiModesDirty = true + } + + if (!normalizeText(apiMode.providerId)) { + const customUrl = normalizeText(apiMode.customUrl) + const normalizedCustomUrl = normalizeEndpointUrlForCompare(customUrl) + if (customUrl) { + const apiModeKeyForMatch = normalizeText(apiMode.apiKey) + let provider = customOpenAIProviders.find((item) => { + if (normalizeEndpointUrlForCompare(item.chatCompletionsUrl) !== normalizedCustomUrl) + return false + const existingSecret = normalizeText(providerSecrets[item.id]) + return isProviderSecretCompatibleForCustomMode(apiModeKeyForMatch, existingSecret) + }) + if (!provider) { + customProviderCounter += 1 + const preferredId = + normalizeProviderId(apiMode.customName) || `custom-provider-${customProviderCounter}` + const providerId = ensureUniqueProviderId(providerIdSet, preferredId) + providerIdSet.add(providerId) + provider = { + id: providerId, + name: normalizeText(apiMode.customName) || `Custom Provider ${customProviderCounter}`, + baseUrl: '', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + chatCompletionsUrl: customUrl, + completionsUrl: '', + enabled: true, + allowLegacyResponseField: true, + } + customOpenAIProviders.push(provider) + customProvidersDirty = true + } + apiMode.providerId = provider.id + if (normalizeText(apiMode.customUrl)) { + apiMode.customUrl = '' + } + providerIdAssignedFromLegacyCustomUrl = true + } else { + apiMode.providerId = 'legacy-custom-default' + } + customApiModesDirty = true + } + + const apiModeKey = normalizeText(apiMode.apiKey) + if (apiModeKey) { + const promotedProviderId = promoteCustomModeApiKeyToProvider(apiMode, apiModeKey) + if (normalizeText(apiMode.providerId) !== promotedProviderId) { + apiMode.providerId = promotedProviderId + customApiModesDirty = true + } + if (normalizeText(apiMode.apiKey)) { + // Mode-level custom keys are treated as legacy data; after migration, + // providerSecrets is the single source of truth. + apiMode.apiKey = '' + customApiModesDirty = true + } + } else if (legacyCustomProviderSecret && providerIdAssignedFromLegacyCustomUrl) { + if (!hasOwnProviderSecret(apiMode.providerId)) { + providerSecrets[apiMode.providerId] = legacyCustomProviderSecret + dirty = true + } + } + + migratedCustomModeProviderIds.set( + originalCustomModeSignature, + normalizeText(apiMode.providerId), + ) + } + + if (migrated.apiMode && typeof migrated.apiMode === 'object') { + const selectedApiMode = { ...migrated.apiMode } + let selectedApiModeDirty = false + const selectedIsCustom = selectedApiMode.groupName === 'customApiModelKeys' + let selectedProviderIdAssignedFromLegacyCustomUrl = false + const originalSelectedCustomModeSignature = selectedIsCustom + ? getCustomModeMigrationSignature(selectedApiMode) + : '' + + if (selectedIsCustom) { + const existingSelectedProviderIdRaw = + typeof selectedApiMode.providerId === 'string' ? selectedApiMode.providerId : '' + const existingSelectedProviderId = normalizeProviderId(existingSelectedProviderIdRaw) + if ( + existingSelectedProviderId && + existingSelectedProviderIdRaw !== existingSelectedProviderId + ) { + selectedApiMode.providerId = existingSelectedProviderId + selectedApiModeDirty = true + } + const renamedSelectedProviderId = providerIdRenameLookup.get(existingSelectedProviderId) + if ( + renamedSelectedProviderId && + normalizeText(selectedApiMode.providerId) !== renamedSelectedProviderId + ) { + selectedApiMode.providerId = renamedSelectedProviderId + selectedApiModeDirty = true + } + } + + if (selectedIsCustom) { + const migratedProviderId = migratedCustomModeProviderIds.get( + originalSelectedCustomModeSignature, + ) + if (migratedProviderId && normalizeText(selectedApiMode.providerId) !== migratedProviderId) { + selectedApiMode.providerId = migratedProviderId + selectedApiModeDirty = true + } + } + + if (selectedIsCustom && !normalizeText(selectedApiMode.providerId)) { + const customUrl = normalizeText(selectedApiMode.customUrl) + const normalizedCustomUrl = normalizeEndpointUrlForCompare(customUrl) + if (customUrl) { + const selectedApiModeKeyForMatch = normalizeText(selectedApiMode.apiKey) + let provider = customOpenAIProviders.find((item) => { + if (normalizeEndpointUrlForCompare(item.chatCompletionsUrl) !== normalizedCustomUrl) + return false + const existingSecret = normalizeText(providerSecrets[item.id]) + return isProviderSecretCompatibleForCustomMode(selectedApiModeKeyForMatch, existingSecret) + }) + if (!provider) { + customProviderCounter += 1 + const preferredId = + normalizeProviderId(selectedApiMode.customName) || + `custom-provider-${customProviderCounter}` + const providerId = ensureUniqueProviderId(providerIdSet, preferredId) + providerIdSet.add(providerId) + provider = { + id: providerId, + name: + normalizeText(selectedApiMode.customName) || + `Custom Provider ${customProviderCounter}`, + baseUrl: '', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + chatCompletionsUrl: customUrl, + completionsUrl: '', + enabled: true, + allowLegacyResponseField: true, + } + customOpenAIProviders.push(provider) + customProvidersDirty = true + } + selectedApiMode.providerId = provider.id + if (normalizeText(selectedApiMode.customUrl)) { + selectedApiMode.customUrl = '' + selectedApiModeDirty = true + } + selectedProviderIdAssignedFromLegacyCustomUrl = true + } else { + selectedApiMode.providerId = 'legacy-custom-default' + } + selectedApiModeDirty = true + } + + const selectedApiModeKey = normalizeText(selectedApiMode.apiKey) + const selectedTargetProviderId = selectedIsCustom + ? normalizeText(selectedApiMode.providerId) || 'legacy-custom-default' + : API_MODE_GROUP_TO_PROVIDER_ID[normalizeText(selectedApiMode.groupName)] || + normalizeText(selectedApiMode.providerId) + if ( + selectedIsCustom && + selectedProviderIdAssignedFromLegacyCustomUrl && + !selectedApiModeKey && + legacyCustomProviderSecret && + selectedTargetProviderId && + !hasOwnProviderSecret(selectedTargetProviderId) + ) { + providerSecrets[selectedTargetProviderId] = legacyCustomProviderSecret + dirty = true + } + if (selectedApiModeKey) { + const migratedProviderId = selectedIsCustom + ? migratedCustomModeProviderIds.get(originalSelectedCustomModeSignature) + : '' + if (migratedProviderId) { + if (normalizeText(selectedApiMode.providerId) !== migratedProviderId) { + selectedApiMode.providerId = migratedProviderId + selectedApiModeDirty = true + } + selectedApiMode.apiKey = '' + selectedApiModeDirty = true + } else { + const targetProviderId = selectedIsCustom + ? promoteCustomModeApiKeyToProvider(selectedApiMode, selectedApiModeKey) + : API_MODE_GROUP_TO_PROVIDER_ID[normalizeText(selectedApiMode.groupName)] || + normalizeText(selectedApiMode.providerId) + if (targetProviderId && normalizeText(selectedApiMode.providerId) !== targetProviderId) { + selectedApiMode.providerId = targetProviderId + selectedApiModeDirty = true + } + if (targetProviderId && !selectedIsCustom && !hasOwnProviderSecret(targetProviderId)) { + providerSecrets[targetProviderId] = selectedApiModeKey + dirty = true + } + if (targetProviderId) { + selectedApiMode.apiKey = '' + selectedApiModeDirty = true + } + } + } + + if (!selectedIsCustom && normalizeText(selectedApiMode.providerId)) { + selectedApiMode.providerId = '' + selectedApiModeDirty = true + } + + if (selectedApiModeDirty) { + migrated.apiMode = selectedApiMode + dirty = true + } + } + + if (customProvidersDirty) dirty = true + if (customApiModesDirty) dirty = true + + if (migrated.configSchemaVersion !== CONFIG_SCHEMA_VERSION) { + migrated.configSchemaVersion = CONFIG_SCHEMA_VERSION + dirty = true + } + + migrated.providerSecrets = providerSecrets + migrated.customOpenAIProviders = customOpenAIProviders + migrated.customApiModes = customApiModes + + // Reverse-sync providerSecrets to legacy fields for backward compatibility + // so that older extension versions can still read the keys. + for (const [legacyKey, providerId] of Object.entries(LEGACY_SECRET_KEY_TO_PROVIDER_ID)) { + const hasProviderSecret = Object.hasOwn(providerSecrets, providerId) + const providerSecret = normalizeText(providerSecrets[providerId]) + if (providerSecret && normalizeText(migrated[legacyKey]) !== providerSecret) { + migrated[legacyKey] = providerSecret + dirty = true + } else if (hasProviderSecret && !providerSecret && normalizeText(migrated[legacyKey])) { + migrated[legacyKey] = '' + dirty = true + } + } + + return { migrated, dirty } +} + /** * get user config from local storage * @returns {Promise} */ export async function getUserConfig() { const options = await Browser.storage.local.get(Object.keys(defaultConfig)) - if (options.customChatGptWebApiUrl === 'https://chat.openai.com') - options.customChatGptWebApiUrl = 'https://chatgpt.com' - return defaults(options, defaultConfig) + const { migrated, dirty } = migrateUserConfig(options) + if (dirty) { + const payload = {} + if (JSON.stringify(options.customApiModes) !== JSON.stringify(migrated.customApiModes)) { + payload.customApiModes = migrated.customApiModes + } + if ( + JSON.stringify(options.customOpenAIProviders) !== + JSON.stringify(migrated.customOpenAIProviders) + ) { + payload.customOpenAIProviders = migrated.customOpenAIProviders + } + if (!areStringRecordValuesEqual(options.providerSecrets, migrated.providerSecrets)) { + payload.providerSecrets = migrated.providerSecrets + } + if (options.configSchemaVersion !== migrated.configSchemaVersion) { + payload.configSchemaVersion = migrated.configSchemaVersion + } + if (migrated.customChatGptWebApiUrl !== undefined) { + if (options.customChatGptWebApiUrl !== migrated.customChatGptWebApiUrl) { + payload.customChatGptWebApiUrl = migrated.customChatGptWebApiUrl + } + } + if (migrated.apiMode !== undefined) { + if (JSON.stringify(options.apiMode ?? null) !== JSON.stringify(migrated.apiMode ?? null)) { + payload.apiMode = migrated.apiMode + } + } + for (const legacyKey of Object.keys(LEGACY_SECRET_KEY_TO_PROVIDER_ID)) { + if (migrated[legacyKey] !== undefined) { + if (options[legacyKey] !== migrated[legacyKey]) { + payload[legacyKey] = migrated[legacyKey] + } + } + } + if (Object.keys(payload).length > 0) { + await Browser.storage.local.set(payload) + } + } + return defaults(migrated, defaultConfig) } /** diff --git a/src/config/openai-provider-mappings.mjs b/src/config/openai-provider-mappings.mjs new file mode 100644 index 000000000..b7a534875 --- /dev/null +++ b/src/config/openai-provider-mappings.mjs @@ -0,0 +1,30 @@ +export const LEGACY_API_KEY_FIELD_BY_PROVIDER_ID = { + openai: 'apiKey', + deepseek: 'deepSeekApiKey', + moonshot: 'moonshotApiKey', + openrouter: 'openRouterApiKey', + aiml: 'aimlApiKey', + chatglm: 'chatglmApiKey', + ollama: 'ollamaApiKey', + 'legacy-custom-default': 'customApiKey', +} + +export const LEGACY_SECRET_KEY_TO_PROVIDER_ID = Object.fromEntries( + Object.entries(LEGACY_API_KEY_FIELD_BY_PROVIDER_ID).map(([providerId, legacyKey]) => [ + legacyKey, + providerId, + ]), +) + +export const OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID = { + chatgptApiModelKeys: 'openai', + gptApiModelKeys: 'openai', + moonshotApiModelKeys: 'moonshot', + deepSeekApiModelKeys: 'deepseek', + openRouterApiModelKeys: 'openrouter', + aimlModelKeys: 'aiml', + aimlApiModelKeys: 'aiml', + chatglmApiModelKeys: 'chatglm', + ollamaApiModelKeys: 'ollama', + customApiModelKeys: 'legacy-custom-default', +} diff --git a/src/popup/sections/ApiModes.jsx b/src/popup/sections/ApiModes.jsx index 7fdff7f38..5e5f778d3 100644 --- a/src/popup/sections/ApiModes.jsx +++ b/src/popup/sections/ApiModes.jsx @@ -7,19 +7,30 @@ import { modelNameToDesc, } from '../../utils/index.mjs' import { PencilIcon, TrashIcon } from '@primer/octicons-react' -import { useLayoutEffect, useState } from 'react' +import { useLayoutEffect, useMemo, useRef, useState } from 'react' +import { AlwaysCustomGroups, ModelGroups } from '../../config/index.mjs' import { - AlwaysCustomGroups, - CustomApiKeyGroups, - CustomUrlGroups, - ModelGroups, -} from '../../config/index.mjs' + getCustomOpenAIProviders, + OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID, +} from '../../services/apis/provider-registry.mjs' +import { + applyPendingProviderChanges, + buildEditedProvider, + createProviderId, + parseChatCompletionsEndpointUrl, + resolveEditingProviderIdForGroupChange, + resolveSelectableProviderId, + resolveProviderChatEndpointUrl, + shouldPersistPendingProviderChanges, +} from './api-modes-provider-utils.mjs' ApiModes.propTypes = { config: PropTypes.object.isRequired, updateConfig: PropTypes.func.isRequired, } +const LEGACY_CUSTOM_PROVIDER_ID = 'legacy-custom-default' + const defaultApiMode = { groupName: 'chatgptWebModelKeys', itemName: 'chatgptFree35', @@ -27,9 +38,31 @@ const defaultApiMode = { customName: '', customUrl: 'http://localhost:8000/v1/chat/completions', apiKey: '', + providerId: '', active: true, } +const defaultProviderDraft = { + name: '', + apiUrl: '', +} + +const defaultProviderDraftValidation = { + name: false, + apiUrl: false, +} + +function sanitizeApiModeForSave(apiMode) { + const nextApiMode = { ...apiMode } + if (nextApiMode.groupName !== 'customApiModelKeys') { + nextApiMode.providerId = '' + nextApiMode.apiKey = '' + return nextApiMode + } + if (!nextApiMode.providerId) nextApiMode.providerId = LEGACY_CUSTOM_PROVIDER_ID + return nextApiMode +} + export function ApiModes({ config, updateConfig }) { const { t } = useTranslation() const [editing, setEditing] = useState(false) @@ -37,14 +70,28 @@ export function ApiModes({ config, updateConfig }) { const [editingIndex, setEditingIndex] = useState(-1) const [apiModes, setApiModes] = useState([]) const [apiModeStringArray, setApiModeStringArray] = useState([]) + const [customProviders, setCustomProviders] = useState([]) + const [pendingNewProvider, setPendingNewProvider] = useState(null) + const [pendingEditedProvidersById, setPendingEditedProvidersById] = useState({}) + const [providerSelector, setProviderSelector] = useState(LEGACY_CUSTOM_PROVIDER_ID) + const [isProviderEditorOpen, setIsProviderEditorOpen] = useState(false) + const [providerEditingId, setProviderEditingId] = useState('') + const [providerDraft, setProviderDraft] = useState(defaultProviderDraft) + const [providerDraftValidation, setProviderDraftValidation] = useState( + defaultProviderDraftValidation, + ) + const providerNameInputRef = useRef(null) + const providerBaseUrlInputRef = useRef(null) useLayoutEffect(() => { - const apiModes = getApiModesFromConfig(config) - setApiModes(apiModes) - setApiModeStringArray(apiModes.map(apiModeToModelName)) + const nextApiModes = getApiModesFromConfig(config) + setApiModes(nextApiModes) + setApiModeStringArray(nextApiModes.map(apiModeToModelName)) + setCustomProviders(getCustomOpenAIProviders(config)) }, [ config.activeApiModes, config.customApiModes, + config.customOpenAIProviders, config.azureDeploymentName, config.ollamaModelName, ]) @@ -61,6 +108,163 @@ export function ApiModes({ config, updateConfig }) { }) } + const shouldEditProvider = editingApiMode.groupName === 'customApiModelKeys' + const effectiveProviders = useMemo( + () => + applyPendingProviderChanges(customProviders, pendingEditedProvidersById, pendingNewProvider), + [customProviders, pendingEditedProvidersById, pendingNewProvider], + ) + const selectedCustomProvider = effectiveProviders.find( + (provider) => provider.id === providerSelector, + ) + const hasPendingProviderChanges = + Boolean(pendingNewProvider) || Object.keys(pendingEditedProvidersById).length > 0 + + const clearPendingProviderChanges = () => { + setPendingNewProvider(null) + setPendingEditedProvidersById({}) + } + + const persistApiMode = (nextApiMode) => { + const payload = { + activeApiModes: [], + customApiModes: + editingIndex === -1 + ? [...apiModes, nextApiMode] + : apiModes.map((apiMode, index) => (index === editingIndex ? nextApiMode : apiMode)), + } + if (shouldPersistPendingProviderChanges(nextApiMode, hasPendingProviderChanges)) { + payload.customOpenAIProviders = effectiveProviders + } + if (editingIndex !== -1 && isApiModeSelected(apiModes[editingIndex], config)) { + payload.apiMode = nextApiMode + } + updateConfig(payload) + clearPendingProviderChanges() + } + + const closeProviderEditor = () => { + setIsProviderEditorOpen(false) + setProviderEditingId('') + setProviderDraft(defaultProviderDraft) + setProviderDraftValidation(defaultProviderDraftValidation) + } + + const openCreateProviderEditor = (event) => { + event.preventDefault() + setProviderEditingId('') + setProviderDraft(defaultProviderDraft) + setProviderDraftValidation(defaultProviderDraftValidation) + setIsProviderEditorOpen(true) + } + + const openEditProviderEditor = (event) => { + event.preventDefault() + if (!selectedCustomProvider) return + setProviderEditingId(selectedCustomProvider.id) + setProviderDraft({ + name: selectedCustomProvider.name || '', + apiUrl: resolveProviderChatEndpointUrl(selectedCustomProvider), + }) + setProviderDraftValidation(defaultProviderDraftValidation) + setIsProviderEditorOpen(true) + } + + const onSaveProviderEditing = (event) => { + event.preventDefault() + const providerName = providerDraft.name.trim() + const parsedEndpoint = parseChatCompletionsEndpointUrl(providerDraft.apiUrl) + const nextProviderDraftValidation = { + name: !providerName, + apiUrl: !parsedEndpoint.valid, + } + if (nextProviderDraftValidation.name || nextProviderDraftValidation.apiUrl) { + setProviderDraftValidation(nextProviderDraftValidation) + if (nextProviderDraftValidation.name) { + providerNameInputRef.current?.focus() + } else { + providerBaseUrlInputRef.current?.focus() + } + return + } + setProviderDraftValidation(defaultProviderDraftValidation) + + if (providerEditingId) { + if (pendingNewProvider && pendingNewProvider.id === providerEditingId) { + setPendingNewProvider( + buildEditedProvider( + pendingNewProvider, + providerEditingId, + providerName, + parsedEndpoint, + providerDraft.apiUrl, + ), + ) + } else { + const existingProvider = selectedCustomProvider || {} + setPendingEditedProvidersById((currentProviders) => ({ + ...currentProviders, + [providerEditingId]: buildEditedProvider( + existingProvider, + providerEditingId, + providerName, + parsedEndpoint, + providerDraft.apiUrl, + ), + })) + } + closeProviderEditor() + return + } + + const providerId = createProviderId( + providerName, + effectiveProviders, + Object.values(OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID), + ) + const createdProvider = { + id: providerId, + name: providerName, + baseUrl: '', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + chatCompletionsUrl: parsedEndpoint.chatCompletionsUrl, + completionsUrl: parsedEndpoint.completionsUrl, + enabled: true, + allowLegacyResponseField: true, + } + setPendingNewProvider(createdProvider) + setProviderSelector(providerId) + setEditingApiMode({ ...editingApiMode, providerId }) + closeProviderEditor() + } + + const onSaveEditing = (event) => { + event.preventDefault() + let nextApiMode = { ...editingApiMode } + const previousProviderId = + editingIndex === -1 ? '' : apiModes[editingIndex]?.providerId || LEGACY_CUSTOM_PROVIDER_ID + + if (shouldEditProvider) { + const selectedProviderId = resolveSelectableProviderId( + providerSelector, + effectiveProviders, + LEGACY_CUSTOM_PROVIDER_ID, + ) + const shouldClearApiKey = editingIndex !== -1 && selectedProviderId !== previousProviderId + nextApiMode = { + ...nextApiMode, + providerId: selectedProviderId, + customUrl: '', + apiKey: shouldClearApiKey ? '' : nextApiMode.apiKey, + } + } + + persistApiMode(sanitizeApiModeForSave(nextApiMode)) + setEditing(false) + closeProviderEditor() + } + const editingComponent = (
@@ -68,32 +272,15 @@ export function ApiModes({ config, updateConfig }) { onClick={(e) => { e.preventDefault() setEditing(false) + clearPendingProviderChanges() + closeProviderEditor() }} > {t('Cancel')} - +
-
+
{t('Type')}
-
+
{t('Mode')} { + const value = e.target.value + setProviderSelector(value) + setEditingApiMode({ ...editingApiMode, providerId: value }) + if (isProviderEditorOpen) { + closeProviderEditor() + } + setProviderDraftValidation(defaultProviderDraftValidation) + }} + > + + {effectiveProviders.map((provider) => ( + + ))} + + + +
+ )} + {shouldEditProvider && isProviderEditorOpen && ( + <> setEditingApiMode({ ...editingApiMode, customUrl: e.target.value })} + ref={providerNameInputRef} + value={providerDraft.name} + placeholder={t('Provider')} + onChange={(e) => { + setProviderDraft({ ...providerDraft, name: e.target.value }) + if (providerDraftValidation.name) { + setProviderDraftValidation({ + ...providerDraftValidation, + name: false, + }) + } + }} + aria-invalid={providerDraftValidation.name} + style={providerDraftValidation.name ? { borderColor: 'red' } : undefined} /> - )} - {CustomApiKeyGroups.includes(editingApiMode.groupName) && - (editingApiMode.isCustom || AlwaysCustomGroups.includes(editingApiMode.groupName)) && ( setEditingApiMode({ ...editingApiMode, apiKey: e.target.value })} + type="text" + ref={providerBaseUrlInputRef} + value={providerDraft.apiUrl} + placeholder="https://api.example.com/v1/chat/completions" + title={t('API Url')} + onChange={(e) => { + setProviderDraft({ ...providerDraft, apiUrl: e.target.value }) + if (providerDraftValidation.apiUrl) { + setProviderDraftValidation({ + ...providerDraftValidation, + apiUrl: false, + }) + } + }} + aria-invalid={providerDraftValidation.apiUrl} + style={providerDraftValidation.apiUrl ? { borderColor: 'red' } : undefined} /> - )} + {providerDraftValidation.apiUrl && ( +
{t('Please enter a full Chat Completions URL')}
+ )} +
+ + +
+ + )}
) @@ -190,7 +452,25 @@ export function ApiModes({ config, updateConfig }) { onClick={(e) => { e.preventDefault() setEditing(true) - setEditingApiMode(apiMode) + const isCustomApiMode = apiMode.groupName === 'customApiModelKeys' + const providerId = isCustomApiMode + ? resolveSelectableProviderId( + apiMode.providerId, + effectiveProviders, + LEGACY_CUSTOM_PROVIDER_ID, + ) + : '' + setEditingApiMode({ + ...defaultApiMode, + ...apiMode, + providerId, + }) + setProviderSelector(providerId || LEGACY_CUSTOM_PROVIDER_ID) + setProviderDraft(defaultProviderDraft) + setProviderDraftValidation(defaultProviderDraftValidation) + setIsProviderEditorOpen(false) + setProviderEditingId('') + clearPendingProviderChanges() setEditingIndex(index) }} > @@ -223,6 +503,12 @@ export function ApiModes({ config, updateConfig }) { e.preventDefault() setEditing(true) setEditingApiMode(defaultApiMode) + setProviderSelector(LEGACY_CUSTOM_PROVIDER_ID) + setProviderDraft(defaultProviderDraft) + setProviderDraftValidation(defaultProviderDraftValidation) + setIsProviderEditorOpen(false) + setProviderEditingId('') + clearPendingProviderChanges() setEditingIndex(-1) }} > diff --git a/src/popup/sections/GeneralPart.jsx b/src/popup/sections/GeneralPart.jsx index 9af6e5427..0076b644b 100644 --- a/src/popup/sections/GeneralPart.jsx +++ b/src/popup/sections/GeneralPart.jsx @@ -9,9 +9,7 @@ import { apiModeToModelName, } from '../../utils/index.mjs' import { - isUsingOpenAiApiModel, isUsingAzureOpenAiApiModel, - isUsingChatGLMApiModel, isUsingClaudeApiModel, isUsingCustomModel, isUsingOllamaApiModel, @@ -20,17 +18,23 @@ import { ModelMode, ThemeMode, TriggerMode, - isUsingMoonshotApiModel, Models, - isUsingOpenRouterApiModel, - isUsingAimlApiModel, - isUsingDeepSeekApiModel, } from '../../config/index.mjs' import Browser from 'webextension-polyfill' import { languageList } from '../../config/language.mjs' import PropTypes from 'prop-types' import { config as menuConfig } from '../../content-script/menu-tools' import { PencilIcon } from '@primer/octicons-react' +import { + getProviderById, + resolveOpenAICompatibleRequest, +} from '../../services/apis/provider-registry.mjs' +import { + formatFiniteBalance, + normalizeBillingApiBaseUrl, + shouldOpenOpenAIUsagePage, +} from './general-balance-utils.mjs' +import { buildProviderSecretUpdate } from './provider-secret-utils.mjs' GeneralPart.propTypes = { config: PropTypes.object.isRequired, @@ -109,18 +113,54 @@ export function GeneralPart({ config, updateConfig, setTabIndex }) { config.ollamaModelName, ]) + const selectedProviderSession = + config.apiMode && typeof config.apiMode === 'object' + ? { apiMode: config.apiMode } + : { modelName: config.modelName } + const selectedProviderRequest = resolveOpenAICompatibleRequest(config, selectedProviderSession) + const selectedProviderId = selectedProviderRequest?.providerId || '' + const selectedProvider = selectedProviderRequest + ? getProviderById(config, selectedProviderRequest.providerId) + : null + const selectedProviderApiKey = selectedProviderRequest?.apiKey || '' + const isUsingOpenAICompatibleProvider = Boolean(selectedProviderRequest) + const getBalance = async () => { - const response = await fetch(`${config.customOpenAiApiUrl}/dashboard/billing/credit_grants`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${config.apiKey}`, - }, - }) - if (response.ok) setBalance((await response.json()).total_available.toFixed(2)) - else { - const billing = await checkBilling(config.apiKey, config.customOpenAiApiUrl) - if (billing && billing.length > 2 && billing[2]) setBalance(`${billing[2].toFixed(2)}`) - else openUrl('https://platform.openai.com/account/usage') + const isOpenAIProvider = shouldOpenOpenAIUsagePage(selectedProvider?.id) + if (!isOpenAIProvider) { + setBalance(null) + return + } + + const openAiApiUrl = + selectedProvider?.baseUrl || config.customOpenAiApiUrl || 'https://api.openai.com' + const billingApiBaseUrl = normalizeBillingApiBaseUrl(openAiApiUrl) + try { + const response = await fetch(`${billingApiBaseUrl}/dashboard/billing/credit_grants`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${selectedProviderApiKey}`, + }, + }) + if (response.ok) { + const primaryBalance = formatFiniteBalance((await response.json())?.total_available) + if (primaryBalance !== null) { + setBalance(primaryBalance) + return + } + } + + const billing = await checkBilling(selectedProviderApiKey, billingApiBaseUrl) + const fallbackBalance = formatFiniteBalance(billing?.[2]) + if (fallbackBalance !== null) { + setBalance(fallbackBalance) + return + } + } catch (error) { + console.error(error) + } + if (isOpenAIProvider) { + openUrl('https://platform.openai.com/account/usage') } } @@ -178,12 +218,11 @@ export function GeneralPart({ config, updateConfig, setTabIndex }) { { const apiKey = e.target.value - updateConfig({ apiKey: apiKey }) + updateConfig(buildProviderSecretUpdate(config, selectedProviderId, apiKey)) }} /> - {config.apiKey.length === 0 ? ( - - + + ) : balance ? ( + - - ) : balance ? ( - - ) : ( - - )} + ) : ( + + ))} )} {isUsingSpecialCustomModel(config) && ( @@ -298,42 +338,20 @@ export function GeneralPart({ config, updateConfig, setTabIndex }) { }} /> )} - {isUsingChatGLMApiModel(config) && ( + + {isUsingSpecialCustomModel(config) && isUsingOpenAICompatibleProvider && ( + { const apiKey = e.target.value - updateConfig({ chatglmApiKey: apiKey }) + updateConfig(buildProviderSecretUpdate(config, selectedProviderId, apiKey)) }} /> - )} - {isUsingMoonshotApiModel(config) && ( - - { - const apiKey = e.target.value - updateConfig({ moonshotApiKey: apiKey }) - }} - /> - {config.moonshotApiKey.length === 0 && ( - - - - )} - - )} - + + )} {isUsingSpecialCustomModel(config) && ( )} - {isUsingSpecialCustomModel(config) && ( - { - const apiKey = e.target.value - updateConfig({ customApiKey: apiKey }) - }} - /> - )} {isUsingOllamaApiModel(config) && (
{t('Keep-Alive Time') + ':'} @@ -408,50 +415,6 @@ export function GeneralPart({ config, updateConfig, setTabIndex }) { }} /> )} - {isUsingDeepSeekApiModel(config) && ( - { - const apiKey = e.target.value - updateConfig({ deepSeekApiKey: apiKey }) - }} - /> - )} - {isUsingOllamaApiModel(config) && ( - { - const apiKey = e.target.value - updateConfig({ ollamaApiKey: apiKey }) - }} - /> - )} - {isUsingOpenRouterApiModel(config) && ( - { - const apiKey = e.target.value - updateConfig({ openRouterApiKey: apiKey }) - }} - /> - )} - {isUsingAimlApiModel(config) && ( - { - const apiKey = e.target.value - updateConfig({ aimlApiKey: apiKey }) - }} - /> - )} {isUsingAzureOpenAiApiModel(config) && ( normalizeProviderId(providerId)), + ...existingProviders.map((provider) => normalizeProviderId(provider.id)), + ]) + + const baseId = + normalizeProviderId(providerName) || `custom-provider-${existingProviders.length + 1}` + let nextId = baseId + let suffix = 2 + while (usedIds.has(nextId)) { + nextId = `${baseId}-${suffix}` + suffix += 1 + } + return nextId +} + +export function resolveSelectableProviderId(providerId, providers, fallbackProviderId = '') { + const normalizedProviderId = normalizeText(providerId) + if (!normalizedProviderId) return fallbackProviderId + const hasMatchedProvider = + Array.isArray(providers) && + providers.some((provider) => normalizeText(provider?.id) === normalizedProviderId) + return hasMatchedProvider ? normalizedProviderId : fallbackProviderId +} + +export function applyPendingProviderChanges( + providers, + pendingEditedProvidersById = {}, + pendingNewProvider = null, +) { + const baseProviders = Array.isArray(providers) ? providers : [] + const editedProviders = + pendingEditedProvidersById && typeof pendingEditedProvidersById === 'object' + ? pendingEditedProvidersById + : {} + + const effectiveProviders = baseProviders.map((provider) => { + const providerId = normalizeText(provider?.id) + return providerId && editedProviders[providerId] ? editedProviders[providerId] : provider + }) + + if (!pendingNewProvider || typeof pendingNewProvider !== 'object') { + return effectiveProviders + } + + const pendingNewProviderId = normalizeText(pendingNewProvider.id) + if (!pendingNewProviderId) return effectiveProviders + + const existingProviderIndex = effectiveProviders.findIndex( + (provider) => normalizeText(provider?.id) === pendingNewProviderId, + ) + if (existingProviderIndex !== -1) { + effectiveProviders[existingProviderIndex] = pendingNewProvider + return effectiveProviders + } + + return [...effectiveProviders, pendingNewProvider] +} + +export function shouldPersistPendingProviderChanges(apiMode, hasPendingProviderChanges) { + if (!hasPendingProviderChanges) return false + return normalizeText(apiMode?.groupName) === 'customApiModelKeys' +} + +export function resolveEditingProviderIdForGroupChange( + groupName, + currentProviderId, + fallbackProviderId = '', +) { + const normalizedProviderId = normalizeText(currentProviderId) + if (normalizeText(groupName) === 'customApiModelKeys') { + return normalizedProviderId || fallbackProviderId + } + return normalizedProviderId +} + +export function parseChatCompletionsEndpointUrl(value) { + const normalizedUrl = normalizeProviderEndpointUrl(value) + if (!normalizedUrl) return { valid: false, chatCompletionsUrl: '', completionsUrl: '' } + + let parsedUrl + try { + parsedUrl = new URL(normalizedUrl) + } catch { + return { valid: false, chatCompletionsUrl: '', completionsUrl: '' } + } + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return { valid: false, chatCompletionsUrl: '', completionsUrl: '' } + } + + if (parsedUrl.hash) { + return { valid: false, chatCompletionsUrl: '', completionsUrl: '' } + } + + const normalizedPathname = parsedUrl.pathname.replace(/\/+$/, '') + if (!/\/chat\/completions$/i.test(normalizedPathname)) { + return { valid: false, chatCompletionsUrl: '', completionsUrl: '' } + } + + parsedUrl.pathname = normalizedPathname + const chatCompletionsUrl = parsedUrl.toString().replace(/\/+$/, '') + const parsedCompletionUrl = new URL(chatCompletionsUrl) + parsedCompletionUrl.pathname = parsedCompletionUrl.pathname.replace( + /\/chat\/completions$/i, + '/completions', + ) + const completionsUrl = parsedCompletionUrl.toString().replace(/\/+$/, '') + return { valid: true, chatCompletionsUrl, completionsUrl } +} + +export function resolveProviderChatEndpointUrl(provider) { + if (!provider || typeof provider !== 'object') return '' + const explicitUrl = normalizeProviderEndpointUrl(provider.chatCompletionsUrl) + if (explicitUrl) return explicitUrl + + const baseUrl = normalizeProviderEndpointUrl(provider.baseUrl) + if (!baseUrl) return '' + const chatPath = ensureLeadingSlash(provider.chatCompletionsPath, '/v1/chat/completions') + return `${baseUrl}${chatPath}` +} + +export function buildEditedProvider( + existingProvider, + providerId, + providerName, + parsedEndpoint, + nextApiUrl, +) { + const normalizedNextApiUrl = normalizeProviderEndpointUrl(nextApiUrl) + const existingApiUrl = resolveProviderChatEndpointUrl(existingProvider) + const urlChanged = normalizedNextApiUrl !== existingApiUrl + + const updatedProvider = { + ...(existingProvider || {}), + id: providerId, + name: providerName, + } + + if (!urlChanged) return updatedProvider + + updatedProvider.baseUrl = '' + updatedProvider.chatCompletionsUrl = parsedEndpoint.chatCompletionsUrl + updatedProvider.completionsUrl = parsedEndpoint.completionsUrl + return updatedProvider +} diff --git a/src/popup/sections/general-balance-utils.mjs b/src/popup/sections/general-balance-utils.mjs new file mode 100644 index 000000000..135b5f6f4 --- /dev/null +++ b/src/popup/sections/general-balance-utils.mjs @@ -0,0 +1,24 @@ +export function formatFiniteBalance(value) { + if (value === null || value === undefined) { + return null + } + if (typeof value === 'string' && value.trim() === '') { + return null + } + + const numericValue = Number(value) + if (!Number.isFinite(numericValue)) { + return null + } + + return numericValue.toFixed(2) +} + +export function normalizeBillingApiBaseUrl(apiUrl) { + const normalizedUrl = String(apiUrl || '').trim() + return normalizedUrl.replace(/\/v1\/?$/i, '') +} + +export function shouldOpenOpenAIUsagePage(providerId) { + return providerId === 'openai' +} diff --git a/src/popup/sections/provider-secret-utils.mjs b/src/popup/sections/provider-secret-utils.mjs new file mode 100644 index 000000000..2d38ccb95 --- /dev/null +++ b/src/popup/sections/provider-secret-utils.mjs @@ -0,0 +1,79 @@ +import { LEGACY_API_KEY_FIELD_BY_PROVIDER_ID } from '../../config/openai-provider-mappings.mjs' +import { isApiModeSelected } from '../../utils/model-name-convert.mjs' + +export function buildProviderSecretUpdate(config, providerId, apiKey) { + if (!providerId) return {} + const normalizedProviderId = String(providerId).trim() + if (!normalizedProviderId) return {} + const normalizedNextApiKey = String(apiKey || '').trim() + const previousProviderSecret = + (config.providerSecrets && typeof config.providerSecrets === 'object' + ? String(config.providerSecrets[normalizedProviderId] || '').trim() + : '') || '' + const payload = { + providerSecrets: { + ...(config.providerSecrets || {}), + [normalizedProviderId]: normalizedNextApiKey, + }, + } + const legacyKeyField = LEGACY_API_KEY_FIELD_BY_PROVIDER_ID[normalizedProviderId] + if (legacyKeyField) payload[legacyKeyField] = normalizedNextApiKey + const legacyProviderSecret = legacyKeyField ? String(config[legacyKeyField] || '').trim() : '' + const inheritedSecretBaselines = Array.from( + new Set([previousProviderSecret, legacyProviderSecret].filter(Boolean)), + ) + + if (Array.isArray(config.customApiModes)) { + let customApiModesDirty = false + const nextCustomApiModes = config.customApiModes.map((apiMode) => { + if (!apiMode || typeof apiMode !== 'object') return apiMode + const modeApiKey = String(apiMode.apiKey || '').trim() + const isMatchedCustomProviderMode = + apiMode.groupName === 'customApiModelKeys' && + String(apiMode.providerId || '').trim() === normalizedProviderId + const shouldClearInheritedModeKey = inheritedSecretBaselines.includes(modeApiKey) + const shouldSyncSelectedModeKey = + isApiModeSelected(apiMode, config) && + modeApiKey && + !shouldClearInheritedModeKey && + modeApiKey !== normalizedNextApiKey + if ( + !isMatchedCustomProviderMode || + !modeApiKey || + (!shouldClearInheritedModeKey && !shouldSyncSelectedModeKey) + ) + return apiMode + customApiModesDirty = true + return { + ...apiMode, + apiKey: shouldClearInheritedModeKey ? '' : normalizedNextApiKey, + } + }) + if (customApiModesDirty) payload.customApiModes = nextCustomApiModes + } + + if (config.apiMode && typeof config.apiMode === 'object') { + const selectedApiMode = config.apiMode + const selectedModeApiKey = String(selectedApiMode.apiKey || '').trim() + const isMatchedSelectedCustomProviderMode = + selectedApiMode.groupName === 'customApiModelKeys' && + String(selectedApiMode.providerId || '').trim() === normalizedProviderId + const shouldClearSelectedInheritedModeKey = + inheritedSecretBaselines.includes(selectedModeApiKey) + const shouldSyncSelectedModeKey = + selectedModeApiKey && + !shouldClearSelectedInheritedModeKey && + selectedModeApiKey !== normalizedNextApiKey + if ( + isMatchedSelectedCustomProviderMode && + selectedModeApiKey && + (shouldClearSelectedInheritedModeKey || shouldSyncSelectedModeKey) + ) { + payload.apiMode = { + ...selectedApiMode, + apiKey: shouldClearSelectedInheritedModeKey ? '' : normalizedNextApiKey, + } + } + } + return payload +} diff --git a/src/services/apis/aiml-api.mjs b/src/services/apis/aiml-api.mjs deleted file mode 100644 index b1699052b..000000000 --- a/src/services/apis/aiml-api.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { generateAnswersWithChatgptApiCompat } from './openai-api.mjs' - -/** - * @param {Browser.Runtime.Port} port - * @param {string} question - * @param {Session} session - * @param {string} apiKey - */ -export async function generateAnswersWithAimlApi(port, question, session, apiKey) { - const baseUrl = 'https://api.aimlapi.com/v1' - return generateAnswersWithChatgptApiCompat(baseUrl, port, question, session, apiKey) -} diff --git a/src/services/apis/chatglm-api.mjs b/src/services/apis/chatglm-api.mjs deleted file mode 100644 index 8307c3c51..000000000 --- a/src/services/apis/chatglm-api.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { getUserConfig } from '../../config/index.mjs' -// import { getToken } from '../../utils/jwt-token-generator.mjs' -import { generateAnswersWithChatgptApiCompat } from './openai-api.mjs' - -/** - * @param {Runtime.Port} port - * @param {string} question - * @param {Session} session - */ -export async function generateAnswersWithChatGLMApi(port, question, session) { - const baseUrl = 'https://open.bigmodel.cn/api/paas/v4' - const config = await getUserConfig() - return generateAnswersWithChatgptApiCompat(baseUrl, port, question, session, config.chatglmApiKey) -} diff --git a/src/services/apis/custom-api.mjs b/src/services/apis/custom-api.mjs index 62150d151..f0cd9095a 100644 --- a/src/services/apis/custom-api.mjs +++ b/src/services/apis/custom-api.mjs @@ -1,16 +1,4 @@ -// custom api version - -// There is a lot of duplicated code here, but it is very easy to refactor. -// The current state is mainly convenient for making targeted changes at any time, -// and it has not yet had a negative impact on maintenance. -// If necessary, I will refactor. - -import { getUserConfig } from '../../config/index.mjs' -import { fetchSSE } from '../../utils/fetch-sse.mjs' -import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' -import { isEmpty } from 'lodash-es' -import { pushRecord, setAbortController } from './shared.mjs' -import { getChatCompletionsTokenParams } from './openai-token-params.mjs' +import { generateAnswersWithOpenAICompatible } from './openai-compatible-core.mjs' /** * @param {Browser.Runtime.Port} port @@ -28,84 +16,15 @@ export async function generateAnswersWithCustomApi( apiKey, modelName, ) { - const { controller, messageListener, disconnectListener } = setAbortController(port) - - const config = await getUserConfig() - const prompt = getConversationPairs( - session.conversationRecords.slice(-config.maxConversationContextLength), - false, - ) - prompt.push({ role: 'user', content: question }) - - let answer = '' - let finished = false - const finish = () => { - finished = true - pushRecord(session, question, answer) - console.debug('conversation history', { content: session.conversationRecords }) - port.postMessage({ answer: null, done: true, session: session }) - } - await fetchSSE(apiUrl, { - method: 'POST', - signal: controller.signal, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - messages: prompt, - model: modelName, - stream: true, - ...getChatCompletionsTokenParams('custom', modelName, config.maxResponseTokenLength), - temperature: config.temperature, - }), - onMessage(message) { - console.debug('sse message', message) - if (finished) return - if (message.trim() === '[DONE]') { - finish() - return - } - let data - try { - data = JSON.parse(message) - } catch (error) { - console.debug('json error', error) - return - } - - if (data.response) answer = data.response - else { - const delta = data.choices?.[0]?.delta?.content - const content = data.choices?.[0]?.message?.content - const text = data.choices?.[0]?.text - if (delta !== undefined) { - answer += delta - } else if (typeof content === 'string') { - answer = content - } else if (text) { - answer += text - } - } - port.postMessage({ answer: answer, done: false, session: null }) - - if (data.choices?.[0]?.finish_reason) { - finish() - return - } - }, - async onStart() {}, - async onEnd() { - port.postMessage({ done: true }) - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) - }, - async onError(resp) { - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) - if (resp instanceof Error) throw resp - const error = await resp.json().catch(() => ({})) - throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) - }, + await generateAnswersWithOpenAICompatible({ + port, + question, + session, + endpointType: 'chat', + requestUrl: apiUrl, + model: modelName, + apiKey, + provider: 'custom', + allowLegacyResponseField: true, }) } diff --git a/src/services/apis/deepseek-api.mjs b/src/services/apis/deepseek-api.mjs deleted file mode 100644 index d0538ea15..000000000 --- a/src/services/apis/deepseek-api.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { generateAnswersWithChatgptApiCompat } from './openai-api.mjs' - -/** - * @param {Browser.Runtime.Port} port - * @param {string} question - * @param {Session} session - * @param {string} apiKey - */ -export async function generateAnswersWithDeepSeekApi(port, question, session, apiKey) { - const baseUrl = 'https://api.deepseek.com' - return generateAnswersWithChatgptApiCompat(baseUrl, port, question, session, apiKey) -} diff --git a/src/services/apis/moonshot-api.mjs b/src/services/apis/moonshot-api.mjs deleted file mode 100644 index c3cc187b3..000000000 --- a/src/services/apis/moonshot-api.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { generateAnswersWithChatgptApiCompat } from './openai-api.mjs' - -/** - * @param {Browser.Runtime.Port} port - * @param {string} question - * @param {Session} session - * @param {string} apiKey - */ -export async function generateAnswersWithMoonshotCompletionApi(port, question, session, apiKey) { - const baseUrl = 'https://api.moonshot.cn/v1' - return generateAnswersWithChatgptApiCompat(baseUrl, port, question, session, apiKey) -} diff --git a/src/services/apis/ollama-api.mjs b/src/services/apis/ollama-api.mjs deleted file mode 100644 index 2bf5753e6..000000000 --- a/src/services/apis/ollama-api.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import { getUserConfig } from '../../config/index.mjs' -import { generateAnswersWithChatgptApiCompat } from './openai-api.mjs' -import { getModelValue } from '../../utils/model-name-convert.mjs' - -/** - * @param {Browser.Runtime.Port} port - * @param {string} question - * @param {Session} session - */ -export async function generateAnswersWithOllamaApi(port, question, session) { - const config = await getUserConfig() - const model = getModelValue(session) - return generateAnswersWithChatgptApiCompat( - config.ollamaEndpoint + '/v1', - port, - question, - session, - config.ollamaApiKey, - ).then(() => - fetch(config.ollamaEndpoint + '/api/generate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${config.ollamaApiKey}`, - }, - body: JSON.stringify({ - model, - prompt: 't', - options: { - num_predict: 1, - }, - keep_alive: config.ollamaKeepAliveTime === '-1' ? -1 : config.ollamaKeepAliveTime, - }), - }), - ) -} diff --git a/src/services/apis/openai-api.mjs b/src/services/apis/openai-api.mjs index 752a2a21c..104582e50 100644 --- a/src/services/apis/openai-api.mjs +++ b/src/services/apis/openai-api.mjs @@ -1,12 +1,64 @@ -// api version - import { getUserConfig } from '../../config/index.mjs' -import { fetchSSE } from '../../utils/fetch-sse.mjs' -import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' -import { isEmpty } from 'lodash-es' -import { getCompletionPromptBase, pushRecord, setAbortController } from './shared.mjs' import { getModelValue } from '../../utils/model-name-convert.mjs' -import { getChatCompletionsTokenParams } from './openai-token-params.mjs' +import { generateAnswersWithOpenAICompatible } from './openai-compatible-core.mjs' +import { resolveOpenAICompatibleRequest } from './provider-registry.mjs' + +function normalizeBaseUrl(baseUrl) { + return String(baseUrl || '') + .trim() + .replace(/\/+$/, '') +} + +function normalizeBaseUrlWithoutVersionSuffix(baseUrl, fallback) { + return normalizeBaseUrl(baseUrl || fallback).replace(/\/v1$/i, '') +} + +function resolveModelName(session, config) { + if (session.modelName === 'customModel' && !session.apiMode) { + return config.customModelName + } + if ( + session.apiMode?.groupName === 'customApiModelKeys' && + session.apiMode?.customName && + session.apiMode.customName.trim() + ) { + return session.apiMode.customName.trim() + } + return getModelValue(session) +} + +async function touchOllamaKeepAlive(config, model, apiKey) { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 5000) + + try { + const ollamaBaseUrl = normalizeBaseUrlWithoutVersionSuffix( + config.ollamaEndpoint, + 'http://127.0.0.1:11434', + ) + return await fetch(`${ollamaBaseUrl}/api/generate`, { + method: 'POST', + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify({ + model, + prompt: 't', + options: { + num_predict: 1, + }, + keep_alive: config.ollamaKeepAliveTime === '-1' ? -1 : config.ollamaKeepAliveTime, + }), + }) + } catch (error) { + if (error?.name === 'AbortError') return null + throw error + } finally { + clearTimeout(timeoutId) + } +} /** * @param {Browser.Runtime.Port} port @@ -15,78 +67,19 @@ import { getChatCompletionsTokenParams } from './openai-token-params.mjs' * @param {string} apiKey */ export async function generateAnswersWithGptCompletionApi(port, question, session, apiKey) { - const { controller, messageListener, disconnectListener } = setAbortController(port) - const model = getModelValue(session) - const config = await getUserConfig() - const prompt = - (await getCompletionPromptBase()) + - getConversationPairs( - session.conversationRecords.slice(-config.maxConversationContextLength), - true, - ) + - `Human: ${question}\nAI: ` - const apiUrl = config.customOpenAiApiUrl - - let answer = '' - let finished = false - const finish = () => { - finished = true - pushRecord(session, question, answer) - console.debug('conversation history', { content: session.conversationRecords }) - port.postMessage({ answer: null, done: true, session: session }) - } - await fetchSSE(`${apiUrl}/v1/completions`, { - method: 'POST', - signal: controller.signal, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - prompt: prompt, - model, - stream: true, - max_tokens: config.maxResponseTokenLength, - temperature: config.temperature, - stop: '\nHuman', - }), - onMessage(message) { - console.debug('sse message', message) - if (finished) return - if (message.trim() === '[DONE]') { - finish() - return - } - let data - try { - data = JSON.parse(message) - } catch (error) { - console.debug('json error', error) - return - } - - answer += data.choices[0].text - port.postMessage({ answer: answer, done: false, session: null }) - - if (data.choices[0]?.finish_reason) { - finish() - return - } - }, - async onStart() {}, - async onEnd() { - port.postMessage({ done: true }) - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) - }, - async onError(resp) { - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) - if (resp instanceof Error) throw resp - const error = await resp.json().catch(() => ({})) - throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) - }, + const openAiBaseUrl = normalizeBaseUrlWithoutVersionSuffix( + config.customOpenAiApiUrl, + 'https://api.openai.com', + ) + await generateAnswersWithOpenAICompatible({ + port, + question, + session, + endpointType: 'completion', + requestUrl: `${openAiBaseUrl}/v1/completions`, + model: getModelValue(session), + apiKey, }) } @@ -98,8 +91,12 @@ export async function generateAnswersWithGptCompletionApi(port, question, sessio */ export async function generateAnswersWithChatgptApi(port, question, session, apiKey) { const config = await getUserConfig() + const openAiBaseUrl = normalizeBaseUrlWithoutVersionSuffix( + config.customOpenAiApiUrl, + 'https://api.openai.com', + ) return generateAnswersWithChatgptApiCompat( - config.customOpenAiApiUrl + '/v1', + `${openAiBaseUrl}/v1`, port, question, session, @@ -118,89 +115,48 @@ export async function generateAnswersWithChatgptApiCompat( extraBody = {}, provider = 'compat', ) { - const { controller, messageListener, disconnectListener } = setAbortController(port) - const model = getModelValue(session) - - const config = await getUserConfig() - const prompt = getConversationPairs( - session.conversationRecords.slice(-config.maxConversationContextLength), - false, - ) - prompt.push({ role: 'user', content: question }) - const tokenParams = getChatCompletionsTokenParams(provider, model, config.maxResponseTokenLength) - const conflictingTokenParamKey = - 'max_completion_tokens' in tokenParams ? 'max_tokens' : 'max_completion_tokens' - // Avoid sending both token-limit fields when caller passes extraBody. - const safeExtraBody = { ...extraBody } - delete safeExtraBody[conflictingTokenParamKey] + await generateAnswersWithOpenAICompatible({ + port, + question, + session, + endpointType: 'chat', + requestUrl: `${normalizeBaseUrl(baseUrl)}/chat/completions`, + model: getModelValue(session), + apiKey, + extraBody, + provider, + }) +} - let answer = '' - let finished = false - const finish = () => { - finished = true - pushRecord(session, question, answer) - console.debug('conversation history', { content: session.conversationRecords }) - port.postMessage({ answer: null, done: true, session: session }) +/** + * Unified entry point for OpenAI-compatible providers. + * @param {Browser.Runtime.Port} port + * @param {string} question + * @param {Session} session + * @param {UserConfig} config + */ +export async function generateAnswersWithOpenAICompatibleApi(port, question, session, config) { + const request = resolveOpenAICompatibleRequest(config, session) + if (!request) { + throw new Error('Unknown OpenAI-compatible provider configuration') } - await fetchSSE(`${baseUrl}/chat/completions`, { - method: 'POST', - signal: controller.signal, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - messages: prompt, - model, - stream: true, - ...tokenParams, - temperature: config.temperature, - ...safeExtraBody, - }), - onMessage(message) { - console.debug('sse message', message) - if (finished) return - if (message.trim() === '[DONE]') { - finish() - return - } - let data - try { - data = JSON.parse(message) - } catch (error) { - console.debug('json error', error) - return - } - - const delta = data.choices[0]?.delta?.content - const content = data.choices[0]?.message?.content - const text = data.choices[0]?.text - if (delta !== undefined) { - answer += delta - } else if (content) { - answer = content - } else if (text) { - answer += text - } - port.postMessage({ answer: answer, done: false, session: null }) - if (data.choices[0]?.finish_reason) { - finish() - return - } - }, - async onStart() {}, - async onEnd() { - port.postMessage({ done: true }) - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) - }, - async onError(resp) { - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) - if (resp instanceof Error) throw resp - const error = await resp.json().catch(() => ({})) - throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) - }, + const model = resolveModelName(session, config) + await generateAnswersWithOpenAICompatible({ + port, + question, + session, + endpointType: request.endpointType, + requestUrl: request.requestUrl, + model, + apiKey: request.apiKey, + provider: request.providerId, + allowLegacyResponseField: request.provider.allowLegacyResponseField, }) + + if (request.providerId === 'ollama') { + await touchOllamaKeepAlive(config, model, request.apiKey).catch((error) => { + console.warn('Ollama keep_alive request failed:', error) + }) + } } diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs new file mode 100644 index 000000000..af3c62c8b --- /dev/null +++ b/src/services/apis/openai-compatible-core.mjs @@ -0,0 +1,161 @@ +import { getUserConfig } from '../../config/index.mjs' +import { fetchSSE } from '../../utils/fetch-sse.mjs' +import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' +import { isEmpty } from 'lodash-es' +import { getCompletionPromptBase, pushRecord, setAbortController } from './shared.mjs' +import { getChatCompletionsTokenParams } from './openai-token-params.mjs' + +function buildHeaders(apiKey, extraHeaders = {}) { + const headers = { + 'Content-Type': 'application/json', + ...extraHeaders, + } + if (apiKey) headers.Authorization = `Bearer ${apiKey}` + return headers +} + +function buildMessageAnswer(answer, data, allowLegacyResponseField) { + if (allowLegacyResponseField && typeof data?.response === 'string' && data.response) { + return data.response + } + + const delta = data?.choices?.[0]?.delta?.content + const content = data?.choices?.[0]?.message?.content + const text = data?.choices?.[0]?.text + if (delta !== undefined) return answer + delta + if (content) return content + if (text) return answer + text + return answer +} + +function hasFinished(data) { + return Boolean(data?.choices?.[0]?.finish_reason) +} + +/** + * @param {object} params + * @param {Browser.Runtime.Port} params.port + * @param {string} params.question + * @param {Session} params.session + * @param {'chat'|'completion'} params.endpointType + * @param {string} params.requestUrl + * @param {string} params.model + * @param {string} params.apiKey + * @param {string} [params.provider] + * @param {Record} [params.extraBody] + * @param {Record} [params.extraHeaders] + * @param {boolean} [params.allowLegacyResponseField] + */ +export async function generateAnswersWithOpenAICompatible({ + port, + question, + session, + endpointType, + requestUrl, + model, + apiKey, + provider = 'compat', + extraBody = {}, + extraHeaders = {}, + allowLegacyResponseField = false, +}) { + const { controller, messageListener, disconnectListener } = setAbortController(port) + const config = await getUserConfig() + + let requestBody + if (endpointType === 'completion') { + const prompt = + (await getCompletionPromptBase()) + + getConversationPairs( + session.conversationRecords.slice(-config.maxConversationContextLength), + true, + ) + + `Human: ${question}\nAI: ` + requestBody = { + prompt, + model, + stream: true, + max_tokens: config.maxResponseTokenLength, + temperature: config.temperature, + stop: '\nHuman', + ...extraBody, + } + } else { + const messages = getConversationPairs( + session.conversationRecords.slice(-config.maxConversationContextLength), + false, + ) + messages.push({ role: 'user', content: question }) + const tokenParams = getChatCompletionsTokenParams( + provider, + model, + config.maxResponseTokenLength, + ) + const conflictingTokenParamKey = + 'max_completion_tokens' in tokenParams ? 'max_tokens' : 'max_completion_tokens' + const safeExtraBody = { ...extraBody } + delete safeExtraBody[conflictingTokenParamKey] + requestBody = { + messages, + model, + stream: true, + ...tokenParams, + temperature: config.temperature, + ...safeExtraBody, + } + } + + let answer = '' + let finished = false + const finish = () => { + if (finished) return + finished = true + pushRecord(session, question, answer) + console.debug('conversation history', { content: session.conversationRecords }) + port.postMessage({ answer: null, done: true, session: session }) + } + + await fetchSSE(requestUrl, { + method: 'POST', + signal: controller.signal, + headers: buildHeaders(apiKey, extraHeaders), + body: JSON.stringify(requestBody), + onMessage(message) { + console.debug('sse message', message) + if (finished) return + if (message.trim() === '[DONE]') { + finish() + return + } + let data + try { + data = JSON.parse(message) + } catch (error) { + console.debug('json error', error) + return + } + + answer = buildMessageAnswer(answer, data, allowLegacyResponseField) + port.postMessage({ answer: answer, done: false, session: null }) + + if (hasFinished(data)) { + finish() + } + }, + async onStart() {}, + async onEnd() { + if (!finished) { + port.postMessage({ answer: null, done: true, session: session }) + } + port.onMessage.removeListener(messageListener) + port.onDisconnect.removeListener(disconnectListener) + }, + async onError(resp) { + port.onMessage.removeListener(messageListener) + port.onDisconnect.removeListener(disconnectListener) + if (resp instanceof Error) throw resp + const error = await resp.json().catch(() => ({})) + throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) + }, + }) +} diff --git a/src/services/apis/openrouter-api.mjs b/src/services/apis/openrouter-api.mjs deleted file mode 100644 index 1fe9c8ad7..000000000 --- a/src/services/apis/openrouter-api.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { generateAnswersWithChatgptApiCompat } from './openai-api.mjs' - -/** - * @param {Browser.Runtime.Port} port - * @param {string} question - * @param {Session} session - * @param {string} apiKey - */ -export async function generateAnswersWithOpenRouterApi(port, question, session, apiKey) { - const baseUrl = 'https://openrouter.ai/api/v1' - return generateAnswersWithChatgptApiCompat(baseUrl, port, question, session, apiKey) -} diff --git a/src/services/apis/provider-registry.mjs b/src/services/apis/provider-registry.mjs new file mode 100644 index 000000000..61037a0f3 --- /dev/null +++ b/src/services/apis/provider-registry.mjs @@ -0,0 +1,588 @@ +import { + LEGACY_API_KEY_FIELD_BY_PROVIDER_ID, + OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID, +} from '../../config/openai-provider-mappings.mjs' + +export { OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID } + +const DEFAULT_CHAT_PATH = '/v1/chat/completions' +const DEFAULT_COMPLETION_PATH = '/v1/completions' + +const BUILTIN_PROVIDER_TEMPLATE = [ + { + id: 'openai', + name: 'OpenAI', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + builtin: true, + enabled: true, + }, + { + id: 'deepseek', + name: 'DeepSeek', + baseUrl: 'https://api.deepseek.com', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + builtin: true, + enabled: true, + }, + { + id: 'moonshot', + name: 'Kimi.Moonshot', + baseUrl: 'https://api.moonshot.cn/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + builtin: true, + enabled: true, + }, + { + id: 'openrouter', + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + builtin: true, + enabled: true, + }, + { + id: 'aiml', + name: 'AI/ML', + baseUrl: 'https://api.aimlapi.com/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + builtin: true, + enabled: true, + }, + { + id: 'chatglm', + name: 'ChatGLM', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + builtin: true, + enabled: true, + }, + { + id: 'ollama', + name: 'Ollama', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + builtin: true, + enabled: true, + }, + { + id: 'legacy-custom-default', + name: 'Custom Model (Legacy)', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + builtin: true, + enabled: true, + allowLegacyResponseField: true, + }, +] + +function getModelNamePresetPart(modelName) { + const value = toStringOrEmpty(modelName) + const separatorIndex = value.indexOf('-') + return separatorIndex === -1 ? value : value.substring(0, separatorIndex) +} + +function resolveProviderIdFromLegacyModelName(modelName) { + const rawModelName = toStringOrEmpty(modelName) + if (!rawModelName) return null + if (rawModelName === 'customModel') return 'legacy-custom-default' + + const preset = getModelNamePresetPart(rawModelName) + + if ( + preset === 'gptApiInstruct' || + preset.startsWith('chatgptApi') || + preset === 'gptApiModelKeys' + ) { + return 'openai' + } + if (preset.startsWith('deepseek_') || preset === 'deepSeekApiModelKeys') return 'deepseek' + if (preset.startsWith('moonshot_') || preset === 'moonshotApiModelKeys') return 'moonshot' + if (preset.startsWith('openRouter_') || preset === 'openRouterApiModelKeys') return 'openrouter' + if (preset.startsWith('aiml_') || preset === 'aimlModelKeys' || preset === 'aimlApiModelKeys') { + return 'aiml' + } + if (preset === 'ollama' || preset === 'ollamaModel' || preset === 'ollamaApiModelKeys') { + return 'ollama' + } + if (preset.startsWith('chatglm') || preset === 'chatglmApiModelKeys') return 'chatglm' + if (preset === 'customApiModelKeys') return 'legacy-custom-default' + + return null +} + +function isLegacyCompletionModelName(modelName) { + const preset = getModelNamePresetPart(modelName) + return preset === 'gptApiInstruct' || preset === 'gptApiModelKeys' +} + +function toStringOrEmpty(value) { + return typeof value === 'string' ? value : '' +} + +function normalizeProviderId(value) { + return toStringOrEmpty(value) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function normalizeEndpointUrlForCompare(value) { + return toStringOrEmpty(value).trim().replace(/\/+$/, '') +} + +function normalizeStableCustomApiModeIdentity(apiMode, providerId = apiMode?.providerId) { + if (!apiMode || typeof apiMode !== 'object') return null + return { + groupName: toStringOrEmpty(apiMode.groupName).trim(), + itemName: toStringOrEmpty(apiMode.itemName).trim(), + isCustom: Boolean(apiMode.isCustom), + customName: toStringOrEmpty(apiMode.customName).trim(), + providerId: normalizeProviderId(providerId), + } +} + +function getConfiguredCustomApiModes(config) { + const customApiModes = Array.isArray(config?.customApiModes) ? config.customApiModes : [] + const selectedApiMode = + config?.apiMode && typeof config.apiMode === 'object' ? [config.apiMode] : [] + const seen = new Set() + return [...customApiModes, ...selectedApiMode] + .filter((apiMode) => apiMode?.groupName === 'customApiModelKeys') + .filter((apiMode) => { + const signature = JSON.stringify({ + groupName: toStringOrEmpty(apiMode.groupName).trim(), + itemName: toStringOrEmpty(apiMode.itemName).trim(), + isCustom: Boolean(apiMode.isCustom), + customName: toStringOrEmpty(apiMode.customName).trim(), + providerId: normalizeProviderId(apiMode.providerId), + }) + if (seen.has(signature)) return false + seen.add(signature) + return true + }) +} + +function getConfiguredCustomApiModesForProvider(config, providerId) { + const normalizedProviderId = normalizeProviderId(providerId) + if (!normalizedProviderId) return [] + return getConfiguredCustomApiModes(config).filter( + (apiMode) => normalizeProviderId(apiMode?.providerId) === normalizedProviderId, + ) +} + +function findConfiguredCustomApiMode(config, sessionApiMode, providerId) { + const normalizedProviderId = normalizeProviderId(providerId) + const normalizedSessionApiMode = normalizeStableCustomApiModeIdentity(sessionApiMode, providerId) + if (!normalizedSessionApiMode || normalizedSessionApiMode.groupName !== 'customApiModelKeys') { + return null + } + + const providerCandidates = getConfiguredCustomApiModesForProvider(config, normalizedProviderId) + const exactCandidates = providerCandidates.filter((apiMode) => { + const normalizedCandidate = normalizeStableCustomApiModeIdentity(apiMode) + return ( + normalizedCandidate && + JSON.stringify(normalizedCandidate) === JSON.stringify(normalizedSessionApiMode) + ) + }) + + if (exactCandidates.length === 1) return exactCandidates[0] + return null +} + +function findConfiguredCustomApiModeBySessionLabel(config, sessionApiMode) { + if (!sessionApiMode || typeof sessionApiMode !== 'object') return null + if (toStringOrEmpty(sessionApiMode.groupName).trim() !== 'customApiModelKeys') return null + + const normalizedSessionLabel = { + groupName: toStringOrEmpty(sessionApiMode.groupName).trim(), + itemName: toStringOrEmpty(sessionApiMode.itemName).trim(), + isCustom: Boolean(sessionApiMode.isCustom), + customName: toStringOrEmpty(sessionApiMode.customName).trim(), + } + const allCandidates = getConfiguredCustomApiModes(config).filter((apiMode) => { + if (!apiMode || typeof apiMode !== 'object') return false + return ( + toStringOrEmpty(apiMode.groupName).trim() === normalizedSessionLabel.groupName && + toStringOrEmpty(apiMode.customName).trim() === normalizedSessionLabel.customName + ) + }) + + const exactCandidates = allCandidates.filter( + (apiMode) => + toStringOrEmpty(apiMode.itemName).trim() === normalizedSessionLabel.itemName && + Boolean(apiMode.isCustom) === normalizedSessionLabel.isCustom, + ) + if (exactCandidates.length === 1) return exactCandidates[0] + + const isLegacyCustomShape = !normalizedSessionLabel.itemName + if (isLegacyCustomShape && allCandidates.length === 1) return allCandidates[0] + + return null +} + +function trimSlashes(value) { + return toStringOrEmpty(value).trim().replace(/\/+$/, '') +} + +function normalizeBaseUrlWithoutVersionSuffix(value, fallback) { + return trimSlashes(value || fallback).replace(/\/v1$/i, '') +} + +function ensureLeadingSlash(value, fallback) { + const raw = toStringOrEmpty(value).trim() + if (!raw) return fallback + return raw.startsWith('/') ? raw : `/${raw}` +} + +function joinUrl(baseUrl, path) { + if (!baseUrl) return '' + return `${trimSlashes(baseUrl)}${ensureLeadingSlash(path, '')}` +} + +function buildBuiltinProviders(config) { + return BUILTIN_PROVIDER_TEMPLATE.map((provider) => { + if (provider.id === 'openai') { + const baseUrl = normalizeBaseUrlWithoutVersionSuffix( + config.customOpenAiApiUrl, + 'https://api.openai.com', + ) + return { + ...provider, + baseUrl, + } + } + if (provider.id === 'ollama') { + const baseUrl = normalizeBaseUrlWithoutVersionSuffix( + config.ollamaEndpoint, + 'http://127.0.0.1:11434', + ) + return { + ...provider, + baseUrl: `${baseUrl}/v1`, + } + } + if (provider.id === 'legacy-custom-default') { + return { + ...provider, + chatCompletionsUrl: + toStringOrEmpty(config.customModelApiUrl).trim() || + 'http://localhost:8000/v1/chat/completions', + } + } + return provider + }) +} + +function normalizeCustomProvider(provider, index) { + if (!provider || typeof provider !== 'object') return null + const id = toStringOrEmpty(provider.id).trim() || `custom-provider-${index + 1}` + const chatCompletionsPath = ensureLeadingSlash(provider.chatCompletionsPath, DEFAULT_CHAT_PATH) + const completionsPath = ensureLeadingSlash(provider.completionsPath, DEFAULT_COMPLETION_PATH) + const chatCompletionsUrl = toStringOrEmpty(provider.chatCompletionsUrl).trim() + const completionsUrl = toStringOrEmpty(provider.completionsUrl).trim() + let baseUrl = trimSlashes(provider.baseUrl) + + if (!chatCompletionsUrl && !completionsUrl) { + const usesDefaultV1Paths = + chatCompletionsPath === DEFAULT_CHAT_PATH && completionsPath === DEFAULT_COMPLETION_PATH + if (usesDefaultV1Paths) { + baseUrl = normalizeBaseUrlWithoutVersionSuffix(baseUrl, '') + } + } + + return { + id, + name: toStringOrEmpty(provider.name).trim() || `Custom Provider ${index + 1}`, + baseUrl, + chatCompletionsPath, + completionsPath, + chatCompletionsUrl, + completionsUrl, + builtin: false, + enabled: provider.enabled !== false, + allowLegacyResponseField: Boolean(provider.allowLegacyResponseField), + } +} + +export function getCustomOpenAIProviders(config) { + const providers = Array.isArray(config.customOpenAIProviders) ? config.customOpenAIProviders : [] + return providers + .map((provider, index) => normalizeCustomProvider(provider, index)) + .filter((provider) => provider) +} + +export function getAllOpenAIProviders(config) { + const customProviders = getCustomOpenAIProviders(config) + return [...buildBuiltinProviders(config), ...customProviders] +} + +export function resolveProviderIdForSession(session) { + const apiMode = session?.apiMode + if (apiMode && typeof apiMode === 'object') { + const apiModeProviderId = toStringOrEmpty(apiMode.providerId).trim() + if (apiMode.groupName === 'customApiModelKeys' && apiModeProviderId) return apiModeProviderId + if (apiMode.groupName) { + const mappedProviderId = OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID[apiMode.groupName] + if (mappedProviderId) return mappedProviderId + } + if (apiModeProviderId) return apiModeProviderId + } + if (session?.modelName === 'customModel') return 'legacy-custom-default' + const fromLegacyModelName = resolveProviderIdFromLegacyModelName(session?.modelName) + if (fromLegacyModelName) return fromLegacyModelName + return null +} + +export function resolveEndpointTypeForSession(session) { + const apiMode = session?.apiMode + if (apiMode && typeof apiMode === 'object') { + return apiMode.groupName === 'gptApiModelKeys' ? 'completion' : 'chat' + } + return isLegacyCompletionModelName(session?.modelName) ? 'completion' : 'chat' +} + +export function getProviderById(config, providerId) { + if (!providerId) return null + const provider = getAllOpenAIProviders(config).find((item) => item.id === providerId) + if (!provider) return null + if (provider.enabled === false) return null + return provider +} + +function getConfiguredProviderSecret(config, providerId) { + if (!providerId) return '' + const hasProviderSecretsMap = + config?.providerSecrets && typeof config.providerSecrets === 'object' + if (hasProviderSecretsMap && Object.hasOwn(config.providerSecrets, providerId)) { + return toStringOrEmpty(config.providerSecrets[providerId]).trim() + } + const legacyKey = LEGACY_API_KEY_FIELD_BY_PROVIDER_ID[providerId] + return legacyKey ? toStringOrEmpty(config?.[legacyKey]).trim() : '' +} + +function hasConfiguredProviderSecretEntry(config, providerId) { + return Boolean( + providerId && + config?.providerSecrets && + typeof config.providerSecrets === 'object' && + Object.hasOwn(config.providerSecrets, providerId), + ) +} + +export function getProviderSecret(config, providerId, session) { + if (!providerId) return '' + const normalizedProviderId = normalizeProviderId(providerId) + const apiModeApiKey = + session?.apiMode && typeof session.apiMode === 'object' + ? toStringOrEmpty(session.apiMode.apiKey).trim() + : '' + const hasConfiguredSecretEntry = hasConfiguredProviderSecretEntry(config, normalizedProviderId) + const configuredSecret = getConfiguredProviderSecret(config, normalizedProviderId) + if (session?.apiMode?.groupName === 'customApiModelKeys') { + const configuredCustomApiMode = findConfiguredCustomApiMode( + config, + session.apiMode, + normalizedProviderId, + ) + if (configuredCustomApiMode) { + const configuredModeApiKey = toStringOrEmpty(configuredCustomApiMode.apiKey).trim() + if (configuredModeApiKey) return configuredModeApiKey + if (configuredSecret || hasConfiguredSecretEntry) return configuredSecret + return apiModeApiKey + } + const providerCandidates = getConfiguredCustomApiModesForProvider(config, normalizedProviderId) + if (providerCandidates.length > 0) { + const hasAnyModeSpecificKey = providerCandidates.some((apiMode) => + toStringOrEmpty(apiMode.apiKey).trim(), + ) + if (!hasAnyModeSpecificKey && (configuredSecret || hasConfiguredSecretEntry)) { + return configuredSecret + } + } + if (apiModeApiKey) return apiModeApiKey + return configuredSecret + } + if (configuredSecret || hasConfiguredSecretEntry) return configuredSecret + + return apiModeApiKey +} + +function resolveCustomProviderByLegacySessionUrl(customProviders, config, session) { + const customUrl = normalizeEndpointUrlForCompare(session?.apiMode?.customUrl) + if (!customUrl) return null + + const matchedProviders = customProviders.filter((item) => { + if (item.enabled === false) return false + + const directChatCompletionsUrl = normalizeEndpointUrlForCompare(item.chatCompletionsUrl) + if (directChatCompletionsUrl && directChatCompletionsUrl === customUrl) return true + if (directChatCompletionsUrl) return false + + const derivedChatCompletionsUrl = + item.baseUrl && item.chatCompletionsPath + ? normalizeEndpointUrlForCompare(joinUrl(item.baseUrl, item.chatCompletionsPath)) + : '' + + return derivedChatCompletionsUrl && derivedChatCompletionsUrl === customUrl + }) + if (matchedProviders.length <= 1) { + return matchedProviders[0] || null + } + + const sessionApiKey = toStringOrEmpty(session?.apiMode?.apiKey).trim() + if (sessionApiKey) { + const matchedBySessionKey = matchedProviders.filter( + (item) => getConfiguredProviderSecret(config, item.id) === sessionApiKey, + ) + if (matchedBySessionKey.length === 1) { + return matchedBySessionKey[0] + } + } + + return matchedProviders[0] +} +function resolveUrlFromProvider( + provider, + endpointType, + config, + session, + useLegacyCustomUrlFallback = false, +) { + if (!provider) return '' + + const apiModeCustomUrl = + endpointType === 'chat' && + session?.apiMode && + typeof session.apiMode === 'object' && + session.apiMode.groupName === 'customApiModelKeys' && + !toStringOrEmpty(session.apiMode.providerId).trim() && + useLegacyCustomUrlFallback + ? toStringOrEmpty(session.apiMode.customUrl).trim() + : '' + if (apiModeCustomUrl) return apiModeCustomUrl + + if (endpointType === 'completion') { + if (provider.completionsUrl) return provider.completionsUrl + if (provider.baseUrl && provider.completionsPath) { + return joinUrl(provider.baseUrl, provider.completionsPath) + } + } else { + if (provider.chatCompletionsUrl) return provider.chatCompletionsUrl + if (provider.baseUrl && provider.chatCompletionsPath) { + return joinUrl(provider.baseUrl, provider.chatCompletionsPath) + } + } + + if (provider.id === 'legacy-custom-default') { + if (endpointType === 'completion') { + const baseUrl = normalizeBaseUrlWithoutVersionSuffix( + config.customOpenAiApiUrl, + 'https://api.openai.com', + ) + return `${baseUrl}/v1/completions` + } + return ( + toStringOrEmpty(config.customModelApiUrl).trim() || + 'http://localhost:8000/v1/chat/completions' + ) + } + + return '' +} + +export function resolveOpenAICompatibleRequest(config, session) { + const providerId = resolveProviderIdForSession(session) + if (!providerId) return null + let resolvedProviderId = providerId + let provider = null + let useLegacyCustomUrlFallback = false + if (session?.apiMode?.groupName === 'customApiModelKeys') { + const customProviders = getCustomOpenAIProviders(config) + const matchedByProviderId = customProviders.find( + (item) => item.enabled !== false && item.id === providerId, + ) + if (matchedByProviderId) { + provider = matchedByProviderId + resolvedProviderId = matchedByProviderId.id + } + const normalizedProviderId = normalizeProviderId(providerId) + if (!provider && normalizedProviderId) { + const matchedByNormalizedProviderId = customProviders.find( + (item) => item.enabled !== false && item.id === normalizedProviderId, + ) + if (matchedByNormalizedProviderId) { + provider = matchedByNormalizedProviderId + resolvedProviderId = matchedByNormalizedProviderId.id + } + } + if (!provider) { + const matchedByCustomUrl = resolveCustomProviderByLegacySessionUrl( + customProviders, + config, + session, + ) + if (matchedByCustomUrl) { + provider = matchedByCustomUrl + resolvedProviderId = matchedByCustomUrl.id + } + } + if (!provider) { + const matchedConfiguredApiMode = findConfiguredCustomApiModeBySessionLabel( + config, + session?.apiMode, + ) + const matchedConfiguredProviderId = normalizeProviderId(matchedConfiguredApiMode?.providerId) + if (matchedConfiguredProviderId) { + const matchedConfiguredProvider = customProviders.find( + (item) => item.enabled !== false && item.id === matchedConfiguredProviderId, + ) + if (matchedConfiguredProvider) { + provider = matchedConfiguredProvider + resolvedProviderId = matchedConfiguredProvider.id + } else if (matchedConfiguredProviderId === 'legacy-custom-default') { + provider = getProviderById(config, matchedConfiguredProviderId) + if (provider) { + resolvedProviderId = matchedConfiguredProviderId + useLegacyCustomUrlFallback = true + } + } + } + } + if (!provider) { + if (normalizeProviderId(providerId) === 'legacy-custom-default') { + provider = getProviderById(config, providerId) + useLegacyCustomUrlFallback = !!provider + } else { + return null + } + } + } + if (!provider) { + provider = getProviderById(config, providerId) + } + if (!provider) return null + const endpointType = resolveEndpointTypeForSession(session) + const requestUrl = resolveUrlFromProvider( + provider, + endpointType, + config, + session, + useLegacyCustomUrlFallback, + ) + if (!requestUrl) return null + return { + providerId: resolvedProviderId, + provider, + endpointType, + requestUrl, + apiKey: getProviderSecret(config, resolvedProviderId, session), + } +} diff --git a/src/services/init-session.mjs b/src/services/init-session.mjs index 999d3165a..fac630a3f 100644 --- a/src/services/init-session.mjs +++ b/src/services/init-session.mjs @@ -1,5 +1,9 @@ import { v4 as uuidv4 } from 'uuid' -import { apiModeToModelName, modelNameToDesc } from '../utils/model-name-convert.mjs' +import { + apiModeToModelName, + modelNameToDesc, + normalizeApiMode, +} from '../utils/model-name-convert.mjs' import { t } from 'i18next' /** @@ -68,7 +72,7 @@ export function initSession({ ) : null, modelName, - apiMode, + apiMode: normalizeApiMode(apiMode), autoClean, isRetry: false, diff --git a/src/services/wrappers.mjs b/src/services/wrappers.mjs index c828f9038..aff0f2a5e 100644 --- a/src/services/wrappers.mjs +++ b/src/services/wrappers.mjs @@ -7,7 +7,11 @@ import { } from '../config/index.mjs' import Browser from 'webextension-polyfill' import { t } from 'i18next' -import { apiModeToModelName, modelNameToDesc } from '../utils/model-name-convert.mjs' +import { + apiModeToModelName, + modelNameToDesc, + normalizeApiMode, +} from '../utils/model-name-convert.mjs' export async function getChatGptAccessToken() { await clearOldAccessToken() @@ -103,6 +107,7 @@ export function registerPortListener(executor) { const config = await getUserConfig() if (!session.modelName) session.modelName = config.modelName if (!session.apiMode && session.modelName !== 'customModel') session.apiMode = config.apiMode + if (session.apiMode) session.apiMode = normalizeApiMode(session.apiMode) if (!session.aiName) session.aiName = modelNameToDesc( session.apiMode ? apiModeToModelName(session.apiMode) : session.modelName, diff --git a/src/utils/model-name-convert.mjs b/src/utils/model-name-convert.mjs index 3f2062326..e32fd3f72 100644 --- a/src/utils/model-name-convert.mjs +++ b/src/utils/model-name-convert.mjs @@ -72,12 +72,30 @@ export function modelNameToApiMode(modelName) { customName, customUrl: '', apiKey: '', + providerId: '', active: true, } } } +export function normalizeApiMode(apiMode) { + if (!apiMode || typeof apiMode !== 'object') return null + return { + ...apiMode, + groupName: apiMode.groupName || '', + itemName: apiMode.itemName || '', + isCustom: Boolean(apiMode.isCustom), + customName: apiMode.customName || '', + customUrl: apiMode.customUrl || '', + apiKey: apiMode.apiKey || '', + providerId: typeof apiMode.providerId === 'string' ? apiMode.providerId.trim() : '', + active: apiMode.active !== false, + } +} + export function apiModeToModelName(apiMode) { + apiMode = normalizeApiMode(apiMode) + if (!apiMode) return '' if (AlwaysCustomGroups.includes(apiMode.groupName)) return apiMode.groupName + '-' + apiMode.customName @@ -90,7 +108,13 @@ export function apiModeToModelName(apiMode) { } export function getApiModesFromConfig(config, onlyActive) { - const stringApiModes = config.customApiModes + const normalizedCustomApiModes = ( + Array.isArray(config.customApiModes) ? config.customApiModes : [] + ) + .map((apiMode) => normalizeApiMode(apiMode)) + .filter((apiMode) => apiMode && apiMode.groupName && apiMode.itemName) + + const stringApiModes = normalizedCustomApiModes .map((apiMode) => { if (onlyActive) { if (apiMode.active) return apiModeToModelName(apiMode) @@ -105,13 +129,14 @@ export function getApiModesFromConfig(config, onlyActive) { return } if (modelName === 'azureOpenAi') modelName += '-' + config.azureDeploymentName - if (modelName === 'ollama') modelName += '-' + config.ollamaModelName + if (modelName === 'ollama' || modelName === 'ollamaModel') + modelName = 'ollamaModel-' + config.ollamaModelName return modelNameToApiMode(modelName) }) .filter((apiMode) => apiMode) return [ ...originalApiModes, - ...config.customApiModes.filter((apiMode) => (onlyActive ? apiMode.active : true)), + ...normalizedCustomApiModes.filter((apiMode) => (onlyActive ? apiMode.active : true)), ] } @@ -120,10 +145,25 @@ export function getApiModesStringArrayFromConfig(config, onlyActive) { } export function isApiModeSelected(apiMode, configOrSession) { - return configOrSession.apiMode - ? JSON.stringify(configOrSession.apiMode, Object.keys(configOrSession.apiMode).sort()) === - JSON.stringify(apiMode, Object.keys(apiMode).sort()) - : configOrSession.modelName === apiModeToModelName(apiMode) + const normalizeForCompare = (value) => { + const normalized = normalizeApiMode(value) + if (!normalized) return null + return JSON.stringify({ + groupName: normalized.groupName, + itemName: normalized.itemName, + isCustom: normalized.isCustom, + customName: normalized.customName, + providerId: normalized.providerId, + active: normalized.active, + }) + } + if (!configOrSession.apiMode) { + return configOrSession.modelName === apiModeToModelName(apiMode) + } + const selectedApiMode = normalizeForCompare(configOrSession.apiMode) + const targetApiMode = normalizeForCompare(apiMode) + if (!selectedApiMode || !targetApiMode) return false + return selectedApiMode === targetApiMode } // also match custom modelName, e.g. when modelName is bingFree4, configOrSession model is bingFree4-fast, it returns true diff --git a/tests/unit/config/migrate-user-config.test.mjs b/tests/unit/config/migrate-user-config.test.mjs new file mode 100644 index 000000000..dffa7158e --- /dev/null +++ b/tests/unit/config/migrate-user-config.test.mjs @@ -0,0 +1,607 @@ +import assert from 'node:assert/strict' +import { beforeEach, test } from 'node:test' +import { getUserConfig } from '../../../src/config/index.mjs' + +function createCustomApiMode(overrides = {}) { + return { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'custom-model', + customUrl: '', + apiKey: '', + providerId: '', + active: true, + ...overrides, + } +} + +beforeEach(() => { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() +}) + +test('getUserConfig promotes legacy customUrl into custom provider and migrates legacy custom key', async () => { + const customUrl = 'https://proxy.example.com/v1/chat/completions' + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + customApiKey: 'legacy-custom-key', + customApiModes: [ + createCustomApiMode({ + customName: 'My Proxy', + customUrl, + }), + ], + }) + + const config = await getUserConfig() + const migratedMode = config.customApiModes.find((mode) => mode.customName === 'My Proxy') + const migratedProvider = config.customOpenAIProviders.find( + (provider) => provider.id === migratedMode.providerId, + ) + + assert.equal(Boolean(migratedMode.providerId), true) + assert.equal(migratedMode.customUrl, '') + assert.equal(migratedProvider.chatCompletionsUrl, customUrl) + assert.equal(config.providerSecrets[migratedMode.providerId], 'legacy-custom-key') +}) + +test('getUserConfig keeps raw-id provider secret when custom provider id is renamed', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + OpenAI: 'custom-provider-secret', + openai: 'builtin-provider-secret', + }, + customOpenAIProviders: [ + { + id: 'OpenAI', + name: 'My OpenAI Proxy', + chatCompletionsUrl: 'https://custom.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ + customName: 'proxy-mode', + providerId: 'OpenAI', + }), + ], + }) + + const config = await getUserConfig() + const migratedProvider = config.customOpenAIProviders.find( + (provider) => provider.name === 'My OpenAI Proxy', + ) + const migratedMode = config.customApiModes.find((mode) => mode.customName === 'proxy-mode') + + assert.equal(migratedProvider.id, 'openai-2') + assert.equal(migratedMode.providerId, 'openai-2') + assert.equal(config.providerSecrets['openai-2'], 'custom-provider-secret') +}) + +test('getUserConfig does not reuse builtin provider secret for renamed colliding custom provider', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + openai: 'builtin-provider-secret', + }, + customOpenAIProviders: [ + { + id: 'OpenAI', + name: 'My OpenAI Proxy', + chatCompletionsUrl: 'https://custom.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ + customName: 'proxy-mode', + providerId: 'OpenAI', + }), + ], + }) + + const config = await getUserConfig() + const migratedProvider = config.customOpenAIProviders.find( + (provider) => provider.name === 'My OpenAI Proxy', + ) + const migratedMode = config.customApiModes.find((mode) => mode.customName === 'proxy-mode') + + assert.equal(migratedProvider.id, 'openai-2') + assert.equal(migratedMode.providerId, 'openai-2') + assert.equal(config.providerSecrets.openai, 'builtin-provider-secret') + assert.equal(config.providerSecrets['openai-2'], undefined) +}) + +test('getUserConfig keeps empty providerSecrets entry instead of restoring legacy key', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + openai: '', + }, + apiKey: 'legacy-openai-key', + }) + + const config = await getUserConfig() + + assert.equal(config.providerSecrets.openai, '') + assert.equal(config.apiKey, '') +}) + +test('getUserConfig migrates raw-id provider secret when provider id is normalized only', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + MyProxy: 'raw-provider-secret', + }, + customOpenAIProviders: [ + { + id: 'MyProxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ + customName: 'proxy-mode', + providerId: 'MyProxy', + }), + ], + }) + + const config = await getUserConfig() + const migratedProvider = config.customOpenAIProviders.find( + (provider) => provider.name === 'My Proxy', + ) + const migratedMode = config.customApiModes.find((mode) => mode.customName === 'proxy-mode') + + assert.equal(migratedProvider.id, 'myproxy') + assert.equal(migratedMode.providerId, 'myproxy') + assert.equal(config.providerSecrets.myproxy, 'raw-provider-secret') +}) + +test('getUserConfig trims whitespace when normalizing custom provider ids in modes', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + MyProxy: 'raw-provider-secret', + }, + customOpenAIProviders: [ + { + id: 'MyProxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ + customName: 'proxy-mode', + providerId: ' myproxy ', + }), + ], + apiMode: createCustomApiMode({ + customName: 'selected-proxy-mode', + providerId: ' MyProxy ', + }), + }) + + const config = await getUserConfig() + const migratedMode = config.customApiModes.find((mode) => mode.customName === 'proxy-mode') + + assert.equal(migratedMode.providerId, 'myproxy') + assert.equal(config.apiMode.providerId, 'myproxy') + assert.equal(config.providerSecrets.myproxy, 'raw-provider-secret') +}) + +test('getUserConfig reuses existing custom provider when legacy customUrl only differs by trailing slash', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ + customName: 'mode-with-slash', + customUrl: 'https://proxy.example.com/v1/chat/completions/', + }), + ], + }) + + const config = await getUserConfig() + const migratedMode = config.customApiModes.find((mode) => mode.customName === 'mode-with-slash') + + assert.equal(config.customOpenAIProviders.length, 1) + assert.equal(migratedMode.providerId, 'myproxy') + assert.equal(migratedMode.customUrl, '') +}) + +test('getUserConfig reuses existing custom provider for selected mode when legacy customUrl only differs by trailing slash', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + apiMode: createCustomApiMode({ + customName: 'selected-mode', + customUrl: 'https://proxy.example.com/v1/chat/completions/', + }), + }) + + const config = await getUserConfig() + + assert.equal(config.customOpenAIProviders.length, 1) + assert.equal(config.apiMode.providerId, 'myproxy') + assert.equal(config.apiMode.customUrl, '') +}) + +test('getUserConfig preserves distinct selected and listed custom mode apiKeys', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + myproxy: 'provider-level-key', + }, + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ + customName: 'mode-key-override', + providerId: 'myproxy', + apiKey: 'mode-level-key', + }), + ], + apiMode: createCustomApiMode({ + customName: 'selected-mode-key-override', + providerId: 'myproxy', + apiKey: 'selected-mode-level-key', + }), + }) + + const config = await getUserConfig() + const migratedMode = config.customApiModes.find((mode) => mode.customName === 'mode-key-override') + const selectedProviderId = config.apiMode.providerId + + assert.equal(config.providerSecrets.myproxy, 'provider-level-key') + assert.notEqual(migratedMode.providerId, 'myproxy') + assert.notEqual(selectedProviderId, 'myproxy') + assert.notEqual(migratedMode.providerId, selectedProviderId) + assert.equal(config.providerSecrets[migratedMode.providerId], 'mode-level-key') + assert.equal(config.providerSecrets[selectedProviderId], 'selected-mode-level-key') + assert.equal(migratedMode.apiKey, '') + assert.equal(config.apiMode.apiKey, '') +}) + +test('getUserConfig splits conflicting custom mode apiKeys into separate providers', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ + customName: 'mode-a', + providerId: 'myproxy', + apiKey: 'key-a', + }), + createCustomApiMode({ + customName: 'mode-b', + providerId: 'myproxy', + apiKey: 'key-b', + }), + ], + apiMode: createCustomApiMode({ + customName: 'mode-b', + providerId: 'myproxy', + apiKey: 'key-b', + }), + }) + + const config = await getUserConfig() + const modeA = config.customApiModes.find((mode) => mode.customName === 'mode-a') + const modeB = config.customApiModes.find((mode) => mode.customName === 'mode-b') + + assert.equal(modeA.providerId, 'myproxy') + assert.notEqual(modeB.providerId, 'myproxy') + assert.equal(config.apiMode.providerId, modeB.providerId) + assert.equal(config.providerSecrets.myproxy, 'key-a') + assert.equal(config.providerSecrets[modeB.providerId], 'key-b') + assert.equal(modeA.apiKey, '') + assert.equal(modeB.apiKey, '') + assert.equal(config.apiMode.apiKey, '') +}) + +test('getUserConfig materializes distinct providers for legacy custom default key conflicts', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + customModelApiUrl: 'https://legacy.example.com/v1/chat/completions', + customApiModes: [ + createCustomApiMode({ + customName: 'legacy-a', + apiKey: 'key-a', + }), + createCustomApiMode({ + customName: 'legacy-b', + apiKey: 'key-b', + }), + ], + apiMode: createCustomApiMode({ + customName: 'legacy-b', + apiKey: 'key-b', + }), + }) + + const config = await getUserConfig() + const modeA = config.customApiModes.find((mode) => mode.customName === 'legacy-a') + const modeB = config.customApiModes.find((mode) => mode.customName === 'legacy-b') + const materializedProvider = config.customOpenAIProviders.find( + (provider) => provider.id === modeB.providerId, + ) + + assert.equal(modeA.providerId, 'legacy-custom-default') + assert.notEqual(modeB.providerId, 'legacy-custom-default') + assert.equal(config.apiMode.providerId, modeB.providerId) + assert.equal(config.providerSecrets['legacy-custom-default'], 'key-a') + assert.equal(config.providerSecrets[modeB.providerId], 'key-b') + assert.equal( + materializedProvider.chatCompletionsUrl, + 'https://legacy.example.com/v1/chat/completions', + ) + assert.equal(modeA.apiKey, '') + assert.equal(modeB.apiKey, '') + assert.equal(config.apiMode.apiKey, '') +}) + +test('getUserConfig migrates custom mode apiKey into provider secret when provider secret is empty', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + }, + ], + customApiModes: [ + createCustomApiMode({ + customName: 'mode-key-source', + providerId: 'myproxy', + apiKey: 'mode-level-key', + }), + ], + }) + + const config = await getUserConfig() + const migratedMode = config.customApiModes.find((mode) => mode.customName === 'mode-key-source') + + assert.equal(config.providerSecrets.myproxy, 'mode-level-key') + assert.equal(migratedMode.apiKey, '') +}) + +test('getUserConfig keeps existing provider secret when imported legacy key differs', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + openai: 'existing-secret', + }, + apiKey: 'imported-legacy-secret', + }) + + const config = await getUserConfig() + + assert.equal(config.providerSecrets.openai, 'existing-secret') +}) + +test('getUserConfig does not overwrite provider secret when imported legacy key is empty', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + providerSecrets: { + openai: 'existing-secret', + }, + apiKey: '', + }) + + const config = await getUserConfig() + + assert.equal(config.providerSecrets.openai, 'existing-secret') +}) + +test('getUserConfig clears non-custom mode providerId and migrates mode key to providerSecrets', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + customApiModes: [ + { + groupName: 'chatgptApiModelKeys', + itemName: 'chatgptApi35', + isCustom: false, + customName: '', + customUrl: '', + apiKey: 'sk-from-mode', + providerId: 'openai', + active: true, + }, + ], + }) + + const config = await getUserConfig() + const migratedMode = config.customApiModes.find( + (mode) => mode.groupName === 'chatgptApiModelKeys' && mode.itemName === 'chatgptApi35', + ) + + assert.equal(migratedMode.providerId, '') + assert.equal(migratedMode.apiKey, '') + assert.equal(config.providerSecrets.openai, 'sk-from-mode') +}) + +test('getUserConfig writes current config schema version during migration', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + }) + + const config = await getUserConfig() + const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.equal(config.configSchemaVersion, 1) + assert.equal(storage.configSchemaVersion, 1) +}) + +test('getUserConfig creates separate providers when same URL has different API keys', async () => { + const customUrl = 'https://proxy.example.com/v1/chat/completions' + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + customApiModes: [ + createCustomApiMode({ + customName: 'mode-a', + customUrl, + apiKey: 'key-a', + }), + createCustomApiMode({ + customName: 'mode-b', + customUrl, + apiKey: 'key-b', + }), + ], + }) + + const config = await getUserConfig() + const modeA = config.customApiModes.find((mode) => mode.customName === 'mode-a') + const modeB = config.customApiModes.find((mode) => mode.customName === 'mode-b') + + assert.notEqual( + modeA.providerId, + modeB.providerId, + 'modes with different keys should get separate providers', + ) + assert.equal(config.providerSecrets[modeA.providerId], 'key-a') + assert.equal(config.providerSecrets[modeB.providerId], 'key-b') + assert.equal(config.customOpenAIProviders.length, 2) +}) + +test('getUserConfig does not merge keyless mode into keyed provider for same URL', async () => { + const customUrl = 'https://proxy.example.com/v1/chat/completions' + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + customApiModes: [ + createCustomApiMode({ + customName: 'mode-keyed', + customUrl, + apiKey: 'key-a', + }), + createCustomApiMode({ + customName: 'mode-keyless', + customUrl, + apiKey: '', + }), + ], + }) + + const config = await getUserConfig() + const keyedMode = config.customApiModes.find((mode) => mode.customName === 'mode-keyed') + const keylessMode = config.customApiModes.find((mode) => mode.customName === 'mode-keyless') + + assert.notEqual( + keyedMode.providerId, + keylessMode.providerId, + 'keyless mode should not be merged into a keyed provider', + ) + assert.equal(config.providerSecrets[keyedMode.providerId], 'key-a') + assert.equal(config.providerSecrets[keylessMode.providerId] || '', '') +}) + +test('getUserConfig keeps selected keyless mode separate from keyed provider for same URL', async () => { + const customUrl = 'https://proxy.example.com/v1/chat/completions' + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + customApiModes: [ + createCustomApiMode({ + customName: 'mode-keyed', + customUrl, + apiKey: 'key-a', + }), + ], + apiMode: createCustomApiMode({ + customName: 'selected-keyless', + customUrl, + apiKey: '', + }), + }) + + const config = await getUserConfig() + const keyedMode = config.customApiModes.find((mode) => mode.customName === 'mode-keyed') + + assert.notEqual( + keyedMode.providerId, + config.apiMode.providerId, + 'selected keyless mode should not reuse keyed provider', + ) + assert.equal(config.providerSecrets[keyedMode.providerId], 'key-a') + assert.equal(config.providerSecrets[config.apiMode.providerId] || '', '') +}) + +test('getUserConfig reverse-syncs providerSecrets to legacy fields for backward compatibility', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 0, + customApiModes: [ + { + groupName: 'chatgptApiModelKeys', + itemName: 'chatgptApi35', + isCustom: false, + customName: '', + customUrl: '', + apiKey: 'sk-from-mode', + providerId: '', + active: true, + }, + ], + }) + + const config = await getUserConfig() + const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.equal(config.providerSecrets.openai, 'sk-from-mode') + assert.equal(storage.apiKey, 'sk-from-mode', 'legacy apiKey field should be reverse-synced') +}) + +test('getUserConfig converges missing provider migration keys when schema version is current', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + }) + + await getUserConfig() + const storageAfterFirst = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(storageAfterFirst.providerSecrets, {}) + assert.deepEqual(storageAfterFirst.customApiModes, []) + assert.deepEqual(storageAfterFirst.customOpenAIProviders, []) + + const snapshot = JSON.stringify(storageAfterFirst) + await getUserConfig() + const storageAfterSecond = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.equal(JSON.stringify(storageAfterSecond), snapshot) +}) + +test('getUserConfig normalizes providerSecrets when legacy data is not a plain object', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + providerSecrets: ['invalid-shape'], + }) + + await getUserConfig() + const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(storage.providerSecrets, {}) +}) diff --git a/tests/unit/popup/api-modes-provider-utils.test.mjs b/tests/unit/popup/api-modes-provider-utils.test.mjs new file mode 100644 index 000000000..1a3e9a380 --- /dev/null +++ b/tests/unit/popup/api-modes-provider-utils.test.mjs @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + applyPendingProviderChanges, + buildEditedProvider, + createProviderId, + parseChatCompletionsEndpointUrl, + resolveEditingProviderIdForGroupChange, + resolveSelectableProviderId, + resolveProviderChatEndpointUrl, + shouldPersistPendingProviderChanges, +} from '../../../src/popup/sections/api-modes-provider-utils.mjs' + +test('createProviderId avoids reserved and existing ids', () => { + const existingProviders = [{ id: 'foo' }, { id: 'foo-2' }] + const reservedProviderIds = ['openai', 'deepseek'] + + assert.equal(createProviderId('OpenAI', existingProviders, reservedProviderIds), 'openai-2') + assert.equal(createProviderId('Foo', existingProviders, reservedProviderIds), 'foo-3') +}) + +test('parseChatCompletionsEndpointUrl accepts full chat endpoint url', () => { + const parsed = parseChatCompletionsEndpointUrl('https://api.example.com/v1/chat/completions/') + + assert.equal(parsed.valid, true) + assert.equal(parsed.chatCompletionsUrl, 'https://api.example.com/v1/chat/completions') + assert.equal(parsed.completionsUrl, 'https://api.example.com/v1/completions') +}) + +test('parseChatCompletionsEndpointUrl rejects non-chat endpoint url', () => { + const parsed = parseChatCompletionsEndpointUrl('https://api.example.com/v1') + assert.equal(parsed.valid, false) +}) + +test('parseChatCompletionsEndpointUrl rejects non-http(s) schemes', () => { + const ftpParsed = parseChatCompletionsEndpointUrl('ftp://api.example.com/v1/chat/completions') + const fileParsed = parseChatCompletionsEndpointUrl('file:///v1/chat/completions') + assert.equal(ftpParsed.valid, false) + assert.equal(fileParsed.valid, false) +}) + +test('parseChatCompletionsEndpointUrl keeps query string when deriving completions endpoint', () => { + const parsed = parseChatCompletionsEndpointUrl( + 'https://api.example.com/v1/chat/completions?api-version=1', + ) + assert.equal(parsed.valid, true) + assert.equal( + parsed.chatCompletionsUrl, + 'https://api.example.com/v1/chat/completions?api-version=1', + ) + assert.equal(parsed.completionsUrl, 'https://api.example.com/v1/completions?api-version=1') +}) + +test('resolveProviderChatEndpointUrl prefers explicit chatCompletionsUrl', () => { + const endpoint = resolveProviderChatEndpointUrl({ + baseUrl: 'https://api.example.com/v1', + chatCompletionsPath: '/chat/completions', + chatCompletionsUrl: 'https://proxy.example.com/chat/completions', + }) + + assert.equal(endpoint, 'https://proxy.example.com/chat/completions') +}) + +test('resolveProviderChatEndpointUrl builds endpoint from baseUrl and path', () => { + const endpoint = resolveProviderChatEndpointUrl({ + baseUrl: 'https://api.example.com/v1/', + chatCompletionsPath: 'chat/completions', + chatCompletionsUrl: '', + }) + + assert.equal(endpoint, 'https://api.example.com/v1/chat/completions') +}) + +test('buildEditedProvider preserves existing provider endpoint shape when api url is unchanged', () => { + const existingProvider = { + id: 'myproxy', + name: 'My Proxy', + baseUrl: 'https://api.example.com/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/custom-completions', + completionsUrl: 'https://api.example.com/v1/custom-completions', + } + const parsedEndpoint = parseChatCompletionsEndpointUrl( + 'https://api.example.com/v1/chat/completions', + ) + + const updatedProvider = buildEditedProvider( + existingProvider, + 'myproxy', + 'My Proxy Updated', + parsedEndpoint, + 'https://api.example.com/v1/chat/completions', + ) + + assert.deepEqual(updatedProvider, { + id: 'myproxy', + name: 'My Proxy Updated', + baseUrl: 'https://api.example.com/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/custom-completions', + completionsUrl: 'https://api.example.com/v1/custom-completions', + }) +}) + +test('buildEditedProvider rewrites endpoint fields when api url changes', () => { + const existingProvider = { + id: 'myproxy', + name: 'My Proxy', + baseUrl: 'https://api.example.com/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/custom-completions', + completionsUrl: 'https://api.example.com/v1/custom-completions', + } + const parsedEndpoint = parseChatCompletionsEndpointUrl( + 'https://proxy.example.com/v2/chat/completions', + ) + + const updatedProvider = buildEditedProvider( + existingProvider, + 'myproxy', + 'My Proxy Updated', + parsedEndpoint, + 'https://proxy.example.com/v2/chat/completions', + ) + + assert.equal(updatedProvider.id, 'myproxy') + assert.equal(updatedProvider.name, 'My Proxy Updated') + assert.equal(updatedProvider.baseUrl, '') + assert.equal(updatedProvider.chatCompletionsUrl, 'https://proxy.example.com/v2/chat/completions') + assert.equal(updatedProvider.completionsUrl, 'https://proxy.example.com/v2/completions') +}) + +test('resolveSelectableProviderId falls back when provider is missing or invalid', () => { + const fallbackId = 'legacy-custom-default' + const providers = [{ id: 'myproxy' }, { id: 'another-provider' }] + + assert.equal(resolveSelectableProviderId(' myproxy ', providers, fallbackId), 'myproxy') + assert.equal(resolveSelectableProviderId('unknown-provider', providers, fallbackId), fallbackId) + assert.equal(resolveSelectableProviderId(' ', providers, fallbackId), fallbackId) +}) + +test('applyPendingProviderChanges overlays edited providers and preserves order', () => { + const providers = [ + { id: 'provider-a', name: 'Provider A' }, + { id: 'provider-b', name: 'Provider B' }, + ] + + const result = applyPendingProviderChanges(providers, { + 'provider-b': { id: 'provider-b', name: 'Provider B Updated' }, + }) + + assert.deepEqual(result, [ + { id: 'provider-a', name: 'Provider A' }, + { id: 'provider-b', name: 'Provider B Updated' }, + ]) +}) + +test('applyPendingProviderChanges appends a pending new provider', () => { + const providers = [{ id: 'provider-a', name: 'Provider A' }] + + const result = applyPendingProviderChanges( + providers, + {}, + { id: 'provider-b', name: 'Provider B' }, + ) + + assert.deepEqual(result, [ + { id: 'provider-a', name: 'Provider A' }, + { id: 'provider-b', name: 'Provider B' }, + ]) +}) + +test('applyPendingProviderChanges prefers pending new provider when id already exists', () => { + const providers = [{ id: 'provider-a', name: 'Provider A' }] + + const result = applyPendingProviderChanges( + providers, + {}, + { id: 'provider-a', name: 'Provider A Draft' }, + ) + + assert.deepEqual(result, [{ id: 'provider-a', name: 'Provider A Draft' }]) +}) + +test('shouldPersistPendingProviderChanges only persists provider changes for custom api modes', () => { + assert.equal(shouldPersistPendingProviderChanges({ groupName: 'customApiModelKeys' }, true), true) + assert.equal(shouldPersistPendingProviderChanges({ groupName: 'gptApiModelKeys' }, true), false) + assert.equal( + shouldPersistPendingProviderChanges({ groupName: 'customApiModelKeys' }, false), + false, + ) +}) + +test('resolveEditingProviderIdForGroupChange preserves custom provider draft across type switches', () => { + assert.equal( + resolveEditingProviderIdForGroupChange('gptApiModelKeys', 'myproxy', 'legacy-custom-default'), + 'myproxy', + ) + assert.equal( + resolveEditingProviderIdForGroupChange( + 'customApiModelKeys', + 'myproxy', + 'legacy-custom-default', + ), + 'myproxy', + ) + assert.equal( + resolveEditingProviderIdForGroupChange('customApiModelKeys', '', 'legacy-custom-default'), + 'legacy-custom-default', + ) +}) diff --git a/tests/unit/popup/general-balance-utils.test.mjs b/tests/unit/popup/general-balance-utils.test.mjs new file mode 100644 index 000000000..f406f2ad8 --- /dev/null +++ b/tests/unit/popup/general-balance-utils.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + formatFiniteBalance, + normalizeBillingApiBaseUrl, + shouldOpenOpenAIUsagePage, +} from '../../../src/popup/sections/general-balance-utils.mjs' + +test('formatFiniteBalance formats finite numbers', () => { + assert.equal(formatFiniteBalance(12.345), '12.35') + assert.equal(formatFiniteBalance(0), '0.00') + assert.equal(formatFiniteBalance('7.1'), '7.10') +}) + +test('formatFiniteBalance returns null for non-finite values', () => { + assert.equal(formatFiniteBalance(undefined), null) + assert.equal(formatFiniteBalance(null), null) + assert.equal(formatFiniteBalance(''), null) + assert.equal(formatFiniteBalance(NaN), null) + assert.equal(formatFiniteBalance(Number.POSITIVE_INFINITY), null) +}) + +test('normalizeBillingApiBaseUrl strips a trailing v1 suffix once', () => { + assert.equal(normalizeBillingApiBaseUrl('https://api.openai.com'), 'https://api.openai.com') + assert.equal( + normalizeBillingApiBaseUrl('https://proxy.example.com/v1'), + 'https://proxy.example.com', + ) + assert.equal( + normalizeBillingApiBaseUrl('https://proxy.example.com/v1/'), + 'https://proxy.example.com', + ) + assert.equal( + normalizeBillingApiBaseUrl('https://proxy.example.com/V1'), + 'https://proxy.example.com', + ) +}) + +test('shouldOpenOpenAIUsagePage only returns true for OpenAI provider', () => { + assert.equal(shouldOpenOpenAIUsagePage('openai'), true) + assert.equal(shouldOpenOpenAIUsagePage('deepseek'), false) + assert.equal(shouldOpenOpenAIUsagePage('legacy-custom-default'), false) + assert.equal(shouldOpenOpenAIUsagePage(''), false) +}) diff --git a/tests/unit/popup/provider-secret-utils.test.mjs b/tests/unit/popup/provider-secret-utils.test.mjs new file mode 100644 index 000000000..5ace0f032 --- /dev/null +++ b/tests/unit/popup/provider-secret-utils.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildProviderSecretUpdate } from '../../../src/popup/sections/provider-secret-utils.mjs' + +function createCustomApiMode(overrides = {}) { + return { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'custom-model', + customUrl: '', + apiKey: '', + providerId: '', + active: true, + ...overrides, + } +} + +test('buildProviderSecretUpdate returns empty object for empty providerId', () => { + assert.deepEqual(buildProviderSecretUpdate({}, '', 'key'), {}) +}) + +test('buildProviderSecretUpdate returns empty object for whitespace providerId', () => { + assert.deepEqual(buildProviderSecretUpdate({}, ' ', 'key'), {}) +}) + +test('buildProviderSecretUpdate sets providerSecrets and legacy field for builtin provider', () => { + const config = { providerSecrets: {} } + const result = buildProviderSecretUpdate(config, 'openai', 'sk-new') + + assert.equal(result.providerSecrets.openai, 'sk-new') + assert.equal(result.apiKey, 'sk-new') +}) + +test('buildProviderSecretUpdate sets only providerSecrets for custom provider without legacy field', () => { + const config = { providerSecrets: {} } + const result = buildProviderSecretUpdate(config, 'my-custom-provider', 'sk-custom') + + assert.equal(result.providerSecrets['my-custom-provider'], 'sk-custom') + assert.equal(result.apiKey, undefined) +}) + +test('buildProviderSecretUpdate clears inherited mode-level keys matching old provider secret', () => { + const config = { + providerSecrets: { myproxy: 'old-key' }, + modelName: 'chatgptApi4oMini', + customApiModes: [ + createCustomApiMode({ providerId: 'myproxy', apiKey: 'old-key', customName: 'mode-a' }), + createCustomApiMode({ providerId: 'myproxy', apiKey: 'unique-key', customName: 'mode-b' }), + ], + } + const result = buildProviderSecretUpdate(config, 'myproxy', 'new-key') + + const modeA = result.customApiModes.find((m) => m.customName === 'mode-a') + assert.equal(modeA.apiKey, '', 'inherited key should be cleared') + const modeB = result.customApiModes.find((m) => m.customName === 'mode-b') + assert.equal( + modeB.apiKey, + 'unique-key', + 'non-inherited non-selected mode key should be unchanged', + ) +}) + +test('buildProviderSecretUpdate clears selected mode inherited key in config.apiMode', () => { + const selectedMode = createCustomApiMode({ + providerId: 'myproxy', + apiKey: 'old-key', + customName: 'selected', + }) + const config = { + providerSecrets: { myproxy: 'old-key' }, + apiMode: selectedMode, + modelName: 'chatgptApi4oMini', + customApiModes: [], + } + const result = buildProviderSecretUpdate(config, 'myproxy', 'new-key') + + assert.equal(result.apiMode.apiKey, '', 'selected mode inherited key should be cleared') +}) + +test('buildProviderSecretUpdate syncs selected mode custom key to new value', () => { + const selectedMode = createCustomApiMode({ + providerId: 'myproxy', + apiKey: 'custom-mode-key', + customName: 'selected', + }) + const config = { + providerSecrets: { myproxy: 'different-old-key' }, + apiMode: selectedMode, + modelName: 'chatgptApi4oMini', + customApiModes: [selectedMode], + } + const result = buildProviderSecretUpdate(config, 'myproxy', 'new-key') + + assert.equal(result.apiMode.apiKey, 'new-key') + const syncedMode = result.customApiModes.find((m) => m.customName === 'selected') + assert.equal(syncedMode.apiKey, 'new-key') +}) + +test('buildProviderSecretUpdate does not modify modes for unrelated providers', () => { + const config = { + providerSecrets: {}, + customApiModes: [ + createCustomApiMode({ + providerId: 'other-provider', + apiKey: 'other-key', + customName: 'unrelated', + }), + ], + } + const result = buildProviderSecretUpdate(config, 'myproxy', 'new-key') + + assert.equal( + result.customApiModes, + undefined, + 'customApiModes should not be in payload when unchanged', + ) +}) diff --git a/tests/unit/services/apis/custom-api.test.mjs b/tests/unit/services/apis/custom-api.test.mjs index 8f8dea842..3717b94a9 100644 --- a/tests/unit/services/apis/custom-api.test.mjs +++ b/tests/unit/services/apis/custom-api.test.mjs @@ -72,7 +72,7 @@ test('aggregates delta.content SSE chunks and finishes on finish_reason', async port.postedMessages.some((m) => m.done === true && m.session === session), true, ) - assert.deepEqual(port.postedMessages.at(-1), { done: true }) + assert.deepEqual(port.postedMessages.at(-1), { answer: null, done: true, session }) assert.deepEqual(session.conversationRecords.at(-1), { question: 'CurrentQ', answer: 'Hello', diff --git a/tests/unit/services/apis/openai-api-compat.test.mjs b/tests/unit/services/apis/openai-api-compat.test.mjs index 87834819a..8997cb969 100644 --- a/tests/unit/services/apis/openai-api-compat.test.mjs +++ b/tests/unit/services/apis/openai-api-compat.test.mjs @@ -4,6 +4,7 @@ import { generateAnswersWithChatgptApi, generateAnswersWithChatgptApiCompat, generateAnswersWithGptCompletionApi, + generateAnswersWithOpenAICompatibleApi, } from '../../../../src/services/apis/openai-api.mjs' import { createFakePort } from '../../helpers/port.mjs' import { createMockSseResponse } from '../../helpers/sse-response.mjs' @@ -89,11 +90,49 @@ test('generateAnswersWithChatgptApiCompat sends expected request and aggregates port.postedMessages.some((message) => message.done === true && message.session === session), true, ) - assert.deepEqual(port.postedMessages.at(-1), { done: true }) + assert.deepEqual(port.postedMessages.at(-1), { answer: null, done: true, session }) assert.deepEqual(session.conversationRecords.at(-1), { question: 'CurrentQ', answer: 'Hello' }) }) -test('generateAnswersWithChatgptApiCompat uses max_completion_tokens for OpenAI latest gpt-5 compat models', async (t) => { +test('generateAnswersWithChatgptApiCompat emits fallback done message when stream ends without finish reason', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + maxConversationContextLength: 3, + maxResponseTokenLength: 256, + temperature: 0.25, + }) + + const session = { + modelName: 'chatgptApi4oMini', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + + t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse(['data: {"choices":[{"delta":{"content":"Partial"}}]}\n\n']), + ) + + await generateAnswersWithChatgptApiCompat( + 'https://api.example.com/v1', + port, + 'CurrentQ', + session, + 'sk-test', + ) + + assert.equal( + port.postedMessages.some((message) => message.done === false && message.answer === 'Partial'), + true, + ) + assert.equal( + port.postedMessages.some((message) => message.done === true && message.session === session), + true, + ) + assert.deepEqual(port.postedMessages.at(-1), { answer: null, done: true, session }) +}) + +test('generateAnswersWithChatgptApiCompat uses max_completion_tokens for OpenAI gpt-5 models', async (t) => { t.mock.method(console, 'debug', () => {}) setStorage({ maxConversationContextLength: 3, @@ -619,3 +658,172 @@ test('generateAnswersWithGptCompletionApi builds completion prompt and appends a ) assert.deepEqual(session.conversationRecords.at(-1), { question: 'NowQ', answer: 'AB' }) }) + +test('generateAnswersWithGptCompletionApi avoids duplicate /v1 when customOpenAiApiUrl already has /v1', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + customOpenAiApiUrl: 'https://api.example.com/v1/', + maxConversationContextLength: 5, + maxResponseTokenLength: 300, + temperature: 0.5, + }) + + const session = { + modelName: 'gptApiInstruct', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + + let capturedInput + t.mock.method(globalThis, 'fetch', async (input) => { + capturedInput = input + return createMockSseResponse(['data: {"choices":[{"text":"Done","finish_reason":"stop"}]}\n\n']) + }) + + await generateAnswersWithGptCompletionApi(port, 'NowQ', session, 'sk-completion') + + assert.equal(capturedInput, 'https://api.example.com/v1/completions') +}) + +test('generateAnswersWithChatgptApi avoids duplicate /v1 when customOpenAiApiUrl already has /v1', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + customOpenAiApiUrl: 'https://api.example.com/v1/', + maxConversationContextLength: 2, + maxResponseTokenLength: 128, + temperature: 0.2, + }) + + const session = { + modelName: 'chatgptApi4oMini', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + + let capturedInput + t.mock.method(globalThis, 'fetch', async (input) => { + capturedInput = input + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + await generateAnswersWithChatgptApi(port, 'NowQ', session, 'sk-chat') + + assert.equal(capturedInput, 'https://api.example.com/v1/chat/completions') +}) + +test('generateAnswersWithOpenAICompatibleApi uses default Ollama endpoint for keepAlive when empty', async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'warn', () => {}) + setStorage({ + maxConversationContextLength: 2, + maxResponseTokenLength: 64, + temperature: 0.2, + }) + + const config = { + ollamaEndpoint: '', + providerSecrets: {}, + customOpenAIProviders: [], + } + const session = { + modelName: 'ollama', + apiMode: { + groupName: 'ollamaApiModelKeys', + itemName: 'ollama', + isCustom: false, + customName: '', + customUrl: '', + apiKey: '', + providerId: '', + active: true, + }, + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + const requestedUrls = [] + + t.mock.method(globalThis, 'fetch', async (input) => { + requestedUrls.push(String(input)) + if (String(input).endsWith('/chat/completions')) { + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', + ]) + } + return { ok: true } + }) + + await generateAnswersWithOpenAICompatibleApi(port, 'NowQ', session, config) + + assert.equal(requestedUrls.includes('http://127.0.0.1:11434/v1/chat/completions'), true) + assert.equal(requestedUrls.includes('http://127.0.0.1:11434/api/generate'), true) +}) + +test('generateAnswersWithOpenAICompatibleApi ignores non-string legacy response chunks', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + maxConversationContextLength: 2, + maxResponseTokenLength: 64, + temperature: 0.2, + }) + + const config = { + providerSecrets: { + 'my-provider': 'sk-custom', + }, + customOpenAIProviders: [ + { + id: 'my-provider', + name: 'My Provider', + baseUrl: 'https://api.example.com', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + enabled: true, + allowLegacyResponseField: true, + }, + ], + } + const session = { + modelName: 'customModel', + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'my-model', + customUrl: '', + apiKey: '', + providerId: 'my-provider', + active: true, + }, + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + + t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([ + 'data: {"response":false}\n\n', + 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', + ]), + ) + + await generateAnswersWithOpenAICompatibleApi(port, 'NowQ', session, config) + + assert.equal( + port.postedMessages.some((message) => message.done === false && message.answer === 'false'), + false, + ) + assert.equal( + port.postedMessages.some((message) => message.done === false && message.answer === 'falseOK'), + false, + ) + assert.equal( + port.postedMessages.some((message) => message.done === false && message.answer === 'OK'), + true, + ) + assert.deepEqual(session.conversationRecords.at(-1), { question: 'NowQ', answer: 'OK' }) +}) diff --git a/tests/unit/services/apis/provider-registry.test.mjs b/tests/unit/services/apis/provider-registry.test.mjs new file mode 100644 index 000000000..045e92f01 --- /dev/null +++ b/tests/unit/services/apis/provider-registry.test.mjs @@ -0,0 +1,1556 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + resolveEndpointTypeForSession, + resolveOpenAICompatibleRequest, +} from '../../../../src/services/apis/provider-registry.mjs' + +test('resolveEndpointTypeForSession prefers apiMode when present', () => { + const session = { + apiMode: { + groupName: 'chatgptApiModelKeys', + itemName: 'gpt-4o-mini', + }, + modelName: 'gptApiInstruct', + } + + assert.equal(resolveEndpointTypeForSession(session), 'chat') +}) + +test('resolveEndpointTypeForSession returns completion for gptApiModelKeys apiMode', () => { + const session = { + apiMode: { + groupName: 'gptApiModelKeys', + itemName: 'text-davinci-003', + }, + modelName: 'chatgptApi4oMini', + } + + assert.equal(resolveEndpointTypeForSession(session), 'completion') +}) + +test('resolveEndpointTypeForSession falls back to legacy modelName when apiMode is missing', () => { + const session = { + modelName: 'gptApiInstruct-text-davinci-003', + } + + assert.equal(resolveEndpointTypeForSession(session), 'completion') +}) + +test('resolveOpenAICompatibleRequest resolves custom provider from normalized session provider id', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'proxy-key', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: ' MyProxy ', + customName: 'proxy-model', + customUrl: '', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'myproxy') + assert.equal(resolved.requestUrl, 'https://proxy.example.com/v1/chat/completions') + assert.equal(resolved.apiKey, 'proxy-key') +}) + +test('resolveOpenAICompatibleRequest resolves provider secret when session providerId is not canonical', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'proxy-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: '', + apiKey: '', + providerId: 'myproxy', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'MyProxy', + customName: 'proxy-model', + customUrl: '', + apiKey: '', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'proxy-key') +}) + +test('resolveOpenAICompatibleRequest treats empty providerSecrets entries as authoritative', () => { + const config = { + providerSecrets: { + openai: '', + }, + apiKey: 'legacy-openai-key', + } + const session = { + apiMode: { + groupName: 'chatgptApiModelKeys', + itemName: 'gpt-4o-mini', + apiKey: 'stale-session-key', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'openai') + assert.equal(resolved.apiKey, '') +}) + +test('resolveOpenAICompatibleRequest preserves orphan custom session key override when mode is not in config', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'new-provider-key', + }, + customApiModes: [], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'myproxy', + customName: 'proxy-model', + customUrl: '', + apiKey: 'stale-session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'stale-session-key') +}) + +test('resolveOpenAICompatibleRequest prefers configured provider secret over stale custom session key', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'new-provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: '', + apiKey: '', + providerId: 'myproxy', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'myproxy', + customName: 'proxy-model', + customUrl: '', + apiKey: 'stale-session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'new-provider-key') +}) + +test('resolveOpenAICompatibleRequest deduplicates selected custom mode when config copies differ only by apiKey', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'updated-mode-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: '', + apiKey: '', + providerId: 'myproxy', + active: true, + }, + ], + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: '', + apiKey: 'updated-mode-key', + providerId: 'myproxy', + active: true, + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'myproxy', + customName: 'proxy-model', + customUrl: '', + apiKey: 'stale-session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'updated-mode-key') +}) + +test('resolveOpenAICompatibleRequest matches configured custom mode when session providerId needs normalization', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'new-provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: '', + apiKey: '', + providerId: 'myproxy', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: ' MyProxy ', + customName: 'proxy-model', + customUrl: '', + apiKey: 'stale-session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'new-provider-key') +}) + +test('resolveOpenAICompatibleRequest recovers custom provider from legacy customUrl when provider uses baseUrl and path', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + baseUrl: 'https://proxy.example.com', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'proxy-key', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'OpenAI', + customName: 'proxy-model', + customUrl: 'https://proxy.example.com/v1/chat/completions/', + apiKey: '', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'myproxy') + assert.equal(resolved.requestUrl, 'https://proxy.example.com/v1/chat/completions') + assert.equal(resolved.apiKey, 'proxy-key') +}) + +test('resolveOpenAICompatibleRequest ignores derived customUrl match when provider has a different direct chat url', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://direct.example.com/v1/chat/completions', + baseUrl: 'https://derived.example.com', + chatCompletionsPath: '/v1/chat/completions', + completionsUrl: 'https://direct.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'proxy-key', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'OpenAI', + customName: 'proxy-model', + customUrl: 'https://derived.example.com/v1/chat/completions', + apiKey: '', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved, null) +}) + +test('resolveOpenAICompatibleRequest uses recovered provider url instead of stale legacy customUrl', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://new.example.com/v1/chat/completions', + completionsUrl: 'https://new.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: '', + apiKey: '', + providerId: 'myproxy', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: '', + isCustom: false, + providerId: '', + customName: 'proxy-model', + customUrl: 'https://old.example.com/v1/chat/completions', + apiKey: 'stale-session-key', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'myproxy') + assert.equal(resolved.requestUrl, 'https://new.example.com/v1/chat/completions') + assert.equal(resolved.apiKey, 'provider-key') +}) + +test('resolveOpenAICompatibleRequest preserves stored customUrl for unrecovered legacy custom sessions', () => { + const config = { + customModelApiUrl: 'https://global-default.example.com/v1/chat/completions', + providerSecrets: { + 'legacy-custom-default': 'legacy-key', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: '', + customName: 'orphaned-self-hosted', + customUrl: 'https://self-hosted.example.com/v1/chat/completions', + apiKey: 'session-key', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'legacy-custom-default') + assert.equal(resolved.requestUrl, 'https://self-hosted.example.com/v1/chat/completions') + assert.equal(resolved.apiKey, 'session-key') +}) + +test('resolveOpenAICompatibleRequest uses recovered provider url when configured provider reuses legacy-custom-default id', () => { + const config = { + customOpenAIProviders: [ + { + id: 'legacy-custom-default', + name: 'Recovered Legacy Provider', + chatCompletionsUrl: 'https://new.example.com/v1/chat/completions', + completionsUrl: 'https://new.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + 'legacy-custom-default': 'provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: '', + isCustom: false, + customName: 'proxy-model', + customUrl: '', + apiKey: '', + providerId: 'legacy-custom-default', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: '', + isCustom: false, + providerId: '', + customName: 'proxy-model', + customUrl: 'https://old.example.com/v1/chat/completions', + apiKey: 'stale-session-key', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'legacy-custom-default') + assert.equal(resolved.requestUrl, 'https://new.example.com/v1/chat/completions') + assert.equal(resolved.apiKey, 'provider-key') +}) + +test('resolveOpenAICompatibleRequest preserves saved customUrl when label recovery lands on legacy-custom-default', () => { + const config = { + customModelApiUrl: 'https://global-default.example.com/v1/chat/completions', + providerSecrets: { + 'legacy-custom-default': 'legacy-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: '', + isCustom: false, + customName: 'legacy-proxy', + customUrl: '', + apiKey: '', + providerId: 'legacy-custom-default', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: '', + isCustom: false, + providerId: '', + customName: 'legacy-proxy', + customUrl: 'https://saved-session.example.com/v1/chat/completions', + apiKey: 'session-key', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'legacy-custom-default') + assert.equal(resolved.requestUrl, 'https://saved-session.example.com/v1/chat/completions') + assert.equal(resolved.apiKey, 'legacy-key') +}) + +test('resolveOpenAICompatibleRequest falls back to provider secret when custom mode label changes', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'updated-provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'renamed-model', + customUrl: '', + apiKey: '', + providerId: 'myproxy', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'myproxy', + customName: 'old-model-name', + customUrl: '', + apiKey: 'stale-session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'updated-provider-key') +}) + +test('resolveOpenAICompatibleRequest does not treat the only provider mode as a renamed session mode', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'replacement-mode', + customUrl: '', + apiKey: 'replacement-mode-key', + providerId: 'myproxy', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'myproxy', + customName: 'deleted-mode', + customUrl: '', + apiKey: 'session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'session-key') +}) + +test('resolveOpenAICompatibleRequest preserves session key when multiple custom modes share one provider', () => { + const config = { + customOpenAIProviders: [ + { + id: 'shared-provider', + name: 'Shared Provider', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + 'shared-provider': 'provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-a', + customUrl: '', + apiKey: '', + providerId: 'shared-provider', + active: true, + }, + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-b', + customUrl: '', + apiKey: 'mode-b-key', + providerId: 'shared-provider', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'shared-provider', + customName: 'old-session-name', + customUrl: '', + apiKey: 'session-key', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'session-key') +}) + +test('resolveOpenAICompatibleRequest matches the correct custom mode by customName when multiple modes share one provider', () => { + const config = { + customOpenAIProviders: [ + { + id: 'shared-provider', + name: 'Shared Provider', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + 'shared-provider': 'provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-a', + customUrl: '', + apiKey: '', + providerId: 'shared-provider', + active: true, + }, + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-b', + customUrl: '', + apiKey: 'mode-b-key', + providerId: 'shared-provider', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'shared-provider', + customName: 'mode-b', + customUrl: '', + apiKey: 'stale-session-key', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'mode-b-key') +}) + +test('resolveOpenAICompatibleRequest uses provider secret when multiple custom modes share one provider but none has a mode-specific key', () => { + const config = { + customOpenAIProviders: [ + { + id: 'shared-provider', + name: 'Shared Provider', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + 'shared-provider': 'provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-a', + customUrl: '', + apiKey: '', + providerId: 'shared-provider', + active: true, + }, + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-b', + customUrl: '', + apiKey: '', + providerId: 'shared-provider', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'shared-provider', + customName: 'old-session-name', + customUrl: '', + apiKey: 'stale-session-key', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'provider-key') +}) + +test('resolveOpenAICompatibleRequest preserves session key when multiple custom modes share one provider without configured keys', () => { + const config = { + customOpenAIProviders: [ + { + id: 'shared-provider', + name: 'Shared Provider', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: {}, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-a', + customUrl: '', + apiKey: '', + providerId: 'shared-provider', + active: true, + }, + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-b', + customUrl: '', + apiKey: '', + providerId: 'shared-provider', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'shared-provider', + customName: 'old-session-name', + customUrl: '', + apiKey: 'session-key', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'session-key') +}) + +test('resolveOpenAICompatibleRequest falls back to provider secret for custom provider when mode-level key is empty', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: '', + apiKey: '', + providerId: 'myproxy', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'myproxy', + customName: 'proxy-model', + customUrl: '', + apiKey: '', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'provider-key') +}) + +test('resolveOpenAICompatibleRequest prefers configured custom mode key over provider secret', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: '', + apiKey: 'mode-key', + providerId: 'myproxy', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'myproxy', + customName: 'proxy-model', + customUrl: '', + apiKey: 'stale-session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'mode-key') +}) + +test('resolveOpenAICompatibleRequest preserves session key when matched custom mode has no saved key', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: {}, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: '', + apiKey: '', + providerId: 'myproxy', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'myproxy', + customName: 'proxy-model', + customUrl: '', + apiKey: 'session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'session-key') +}) + +test('resolveOpenAICompatibleRequest ignores active-state differences when matching configured custom mode', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'updated-provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: '', + apiKey: '', + providerId: 'myproxy', + active: false, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'myproxy', + customName: 'proxy-model', + customUrl: '', + apiKey: 'stale-session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.apiKey, 'updated-provider-key') +}) + +test('resolveOpenAICompatibleRequest falls back to provider secret when providerId was migrated but provider still resolves', () => { + const config = { + customOpenAIProviders: [ + { + id: 'openai-2', + name: 'Legacy OpenAI Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + 'openai-2': 'updated-provider-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'renamed-model', + customUrl: '', + apiKey: '', + providerId: 'openai-2', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'openai', + customName: 'old-model-name', + customUrl: 'https://proxy.example.com/v1/chat/completions/', + apiKey: 'stale-session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'openai-2') + assert.equal(resolved.apiKey, 'updated-provider-key') +}) + +test('resolveOpenAICompatibleRequest resolves custom provider by legacy customUrl when session provider id collides with builtin id', () => { + const config = { + customOpenAIProviders: [ + { + id: 'openai-2', + name: 'Legacy OpenAI Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + 'openai-2': 'proxy-key', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'openai', + customName: 'proxy-model', + customUrl: 'https://proxy.example.com/v1/chat/completions/', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'openai-2') + assert.equal(resolved.requestUrl, 'https://proxy.example.com/v1/chat/completions') + assert.equal(resolved.apiKey, 'proxy-key') +}) + +test('resolveOpenAICompatibleRequest matches legacy customUrl session by mode-level apiKey', () => { + const config = { + customOpenAIProviders: [ + { + id: 'proxy-a', + name: 'Proxy A', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + { + id: 'proxy-b', + name: 'Proxy B', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + 'proxy-a': 'key-a', + 'proxy-b': 'key-b', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'openai', + customName: 'proxy-model', + customUrl: 'https://proxy.example.com/v1/chat/completions', + apiKey: 'key-b', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'proxy-b') + assert.equal(resolved.apiKey, 'key-b') +}) + +test('resolveOpenAICompatibleRequest resolves renamed custom provider before falling back to builtin provider', () => { + const config = { + customOpenAIProviders: [ + { + id: 'openai-2', + name: 'Legacy OpenAI Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + 'openai-2': 'proxy-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: '', + apiKey: '', + providerId: 'openai-2', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'openai', + customName: 'proxy-model', + customUrl: '', + apiKey: 'stale-session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'openai-2') + assert.equal(resolved.requestUrl, 'https://proxy.example.com/v1/chat/completions') + assert.equal(resolved.apiKey, 'proxy-key') +}) + +test('resolveOpenAICompatibleRequest does not fall back to builtin provider when custom provider cannot be safely recovered', () => { + const config = { + providerSecrets: { + openai: 'builtin-openai-key', + }, + customOpenAIProviders: [ + { + id: 'openai-2', + name: 'Legacy OpenAI Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'renamed-proxy-model', + customUrl: '', + apiKey: '', + providerId: 'openai-2', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'openai', + customName: 'missing-session-label', + customUrl: '', + apiKey: 'stale-session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved, null) +}) + +test('resolveOpenAICompatibleRequest recovers legacy custom default provider from label-matched configured mode', () => { + const config = { + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'legacy-proxy', + customUrl: '', + apiKey: '', + providerId: 'legacy-custom-default', + active: true, + }, + ], + customModelApiUrl: 'https://legacy-proxy.example.com/v1/chat/completions', + providerSecrets: { + 'legacy-custom-default': 'legacy-key', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'openai', + customName: 'legacy-proxy', + customUrl: '', + apiKey: 'stale-session-key', + active: true, + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'legacy-custom-default') + assert.equal(resolved.requestUrl, 'https://legacy-proxy.example.com/v1/chat/completions') + assert.equal(resolved.apiKey, 'legacy-key') +}) + +test('resolveOpenAICompatibleRequest recovers renamed custom provider for legacy session without itemName and isCustom', () => { + const config = { + customOpenAIProviders: [ + { + id: 'openai-2', + name: 'Legacy OpenAI Proxy', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + 'openai-2': 'proxy-key', + }, + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'proxy-model', + customUrl: '', + apiKey: '', + providerId: 'openai-2', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'openai', + customName: 'proxy-model', + customUrl: '', + apiKey: 'stale-session-key', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'openai-2') + assert.equal(resolved.requestUrl, 'https://proxy.example.com/v1/chat/completions') + assert.equal(resolved.apiKey, 'proxy-key') +}) + +test('resolveOpenAICompatibleRequest keeps fail-closed behavior when legacy label recovery is ambiguous', () => { + const config = { + customOpenAIProviders: [ + { + id: 'proxy-a', + name: 'Proxy A', + chatCompletionsUrl: 'https://proxy-a.example.com/v1/chat/completions', + completionsUrl: 'https://proxy-a.example.com/v1/completions', + enabled: true, + }, + { + id: 'proxy-b', + name: 'Proxy B', + chatCompletionsUrl: 'https://proxy-b.example.com/v1/chat/completions', + completionsUrl: 'https://proxy-b.example.com/v1/completions', + enabled: true, + }, + ], + customApiModes: [ + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'shared-label', + customUrl: '', + apiKey: '', + providerId: 'proxy-a', + active: true, + }, + { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'shared-label', + customUrl: '', + apiKey: '', + providerId: 'proxy-b', + active: true, + }, + ], + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'openai', + customName: 'shared-label', + customUrl: '', + apiKey: 'stale-session-key', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved, null) +}) + +test('resolveOpenAICompatibleRequest keeps URL-first fallback when only legacy custom provider secret is available', () => { + const config = { + customOpenAIProviders: [ + { + id: 'proxy-a', + name: 'Proxy A', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + { + id: 'proxy-b', + name: 'Proxy B', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + 'legacy-custom-default': 'key-b', + 'proxy-a': 'key-a', + 'proxy-b': 'key-b', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'openai', + customName: 'proxy-model', + customUrl: 'https://proxy.example.com/v1/chat/completions', + apiKey: '', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'proxy-a') + assert.equal(resolved.apiKey, 'key-a') +}) + +test('resolveOpenAICompatibleRequest keeps URL-first fallback when legacy customUrl session has no key signal', () => { + const config = { + customOpenAIProviders: [ + { + id: 'proxy-a', + name: 'Proxy A', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + { + id: 'proxy-b', + name: 'Proxy B', + chatCompletionsUrl: 'https://proxy.example.com/v1/chat/completions', + completionsUrl: 'https://proxy.example.com/v1/completions', + enabled: true, + }, + ], + providerSecrets: { + 'proxy-a': 'key-a', + 'proxy-b': 'key-b', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + providerId: 'openai', + customName: 'proxy-model', + customUrl: 'https://proxy.example.com/v1/chat/completions', + apiKey: '', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'proxy-a') + assert.equal(resolved.apiKey, 'key-a') +}) + +test('resolveOpenAICompatibleRequest avoids duplicate /v1 for OpenAI base URL with /v1 suffix', () => { + const config = { + customOpenAiApiUrl: 'https://api.openai.com/v1/', + providerSecrets: { + openai: 'openai-key', + }, + } + const session = { + apiMode: { + groupName: 'chatgptApiModelKeys', + itemName: 'chatgptApi4oMini', + providerId: '', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'openai') + assert.equal(resolved.requestUrl, 'https://api.openai.com/v1/chat/completions') +}) + +test('resolveOpenAICompatibleRequest avoids duplicate /v1 for OpenAI completion URL with /v1 suffix', () => { + const config = { + customOpenAiApiUrl: 'https://api.openai.com/v1/', + providerSecrets: { + openai: 'openai-key', + }, + } + const session = { + apiMode: { + groupName: 'gptApiModelKeys', + itemName: 'gptApiInstruct', + providerId: '', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'openai') + assert.equal(resolved.endpointType, 'completion') + assert.equal(resolved.requestUrl, 'https://api.openai.com/v1/completions') +}) + +test('resolveOpenAICompatibleRequest avoids duplicate /v1 for Ollama endpoint with /v1 suffix', () => { + const config = { + ollamaEndpoint: 'http://127.0.0.1:11434/v1/', + } + const session = { + apiMode: { + groupName: 'ollamaApiModelKeys', + itemName: 'ollama', + providerId: '', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.providerId, 'ollama') + assert.equal(resolved.requestUrl, 'http://127.0.0.1:11434/v1/chat/completions') +}) + +test('resolveOpenAICompatibleRequest avoids duplicate /v1 for custom provider baseUrl with default paths', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + baseUrl: 'https://proxy.example.com/v1/', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'proxy-key', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + providerId: 'myproxy', + customName: 'proxy-model', + customUrl: '', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.requestUrl, 'https://proxy.example.com/v1/chat/completions') +}) + +test('resolveOpenAICompatibleRequest preserves /v1 for custom provider baseUrl with explicit non-default paths', () => { + const config = { + customOpenAIProviders: [ + { + id: 'myproxy', + name: 'My Proxy', + baseUrl: 'https://proxy.example.com/v1/', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + enabled: true, + }, + ], + providerSecrets: { + myproxy: 'proxy-key', + }, + } + const session = { + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + providerId: 'myproxy', + customName: 'proxy-model', + customUrl: '', + }, + } + + const resolved = resolveOpenAICompatibleRequest(config, session) + + assert.equal(resolved.requestUrl, 'https://proxy.example.com/v1/chat/completions') +}) diff --git a/tests/unit/services/apis/thin-adapters.test.mjs b/tests/unit/services/apis/thin-adapters.test.mjs index 1b3318d34..385fd25f7 100644 --- a/tests/unit/services/apis/thin-adapters.test.mjs +++ b/tests/unit/services/apis/thin-adapters.test.mjs @@ -3,11 +3,7 @@ import { beforeEach, test } from 'node:test' import { createFakePort } from '../../helpers/port.mjs' import { createMockSseResponse } from '../../helpers/sse-response.mjs' -import { generateAnswersWithAimlApi } from '../../../../src/services/apis/aiml-api.mjs' -import { generateAnswersWithDeepSeekApi } from '../../../../src/services/apis/deepseek-api.mjs' -import { generateAnswersWithMoonshotCompletionApi } from '../../../../src/services/apis/moonshot-api.mjs' -import { generateAnswersWithOpenRouterApi } from '../../../../src/services/apis/openrouter-api.mjs' -import { generateAnswersWithChatGLMApi } from '../../../../src/services/apis/chatglm-api.mjs' +import { generateAnswersWithOpenAICompatibleApi } from '../../../../src/services/apis/openai-api.mjs' const setStorage = (values) => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage(values) @@ -23,8 +19,8 @@ const commonStorage = { temperature: 0.5, } -const makeSession = () => ({ - modelName: 'chatgptApi4oMini', +const makeSession = (apiMode) => ({ + apiMode, conversationRecords: [], isRetry: false, }) @@ -34,47 +30,54 @@ const sseChunks = ['data: {"choices":[{"delta":{"content":"OK"},"finish_reason": const adapters = [ { name: 'aiml-api', - fn: (port, q, session) => generateAnswersWithAimlApi(port, q, session, 'aiml-key'), + apiMode: { groupName: 'aimlModelKeys', itemName: 'aiml_openai_o3_2025_04_16' }, + providerId: 'aiml', expectedBaseUrl: 'https://api.aimlapi.com/v1', expectedApiKey: 'aiml-key', - storage: commonStorage, }, { name: 'deepseek-api', - fn: (port, q, session) => generateAnswersWithDeepSeekApi(port, q, session, 'ds-key'), + apiMode: { groupName: 'deepSeekApiModelKeys', itemName: 'deepseek_chat' }, + providerId: 'deepseek', expectedBaseUrl: 'https://api.deepseek.com', expectedApiKey: 'ds-key', - storage: commonStorage, }, { name: 'moonshot-api', - fn: (port, q, session) => generateAnswersWithMoonshotCompletionApi(port, q, session, 'ms-key'), + apiMode: { groupName: 'moonshotApiModelKeys', itemName: 'moonshot_kimi_latest' }, + providerId: 'moonshot', expectedBaseUrl: 'https://api.moonshot.cn/v1', expectedApiKey: 'ms-key', - storage: commonStorage, }, { name: 'openrouter-api', - fn: (port, q, session) => generateAnswersWithOpenRouterApi(port, q, session, 'or-key'), + apiMode: { groupName: 'openRouterApiModelKeys', itemName: 'openRouter_openai_o3' }, + providerId: 'openrouter', expectedBaseUrl: 'https://openrouter.ai/api/v1', expectedApiKey: 'or-key', - storage: commonStorage, }, { name: 'chatglm-api', - fn: (port, q, session) => generateAnswersWithChatGLMApi(port, q, session), + apiMode: { groupName: 'chatglmApiModelKeys', itemName: 'chatglmTurbo' }, + providerId: 'chatglm', expectedBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', expectedApiKey: 'glm-key', - storage: { ...commonStorage, chatglmApiKey: 'glm-key' }, }, ] for (const adapter of adapters) { test(`${adapter.name}: passes correct base URL and API key`, async (t) => { t.mock.method(console, 'debug', () => {}) - setStorage(adapter.storage) - const session = makeSession() + const config = { + ...commonStorage, + providerSecrets: { + [adapter.providerId]: adapter.expectedApiKey, + }, + } + setStorage(config) + + const session = makeSession(adapter.apiMode) const port = createFakePort() let capturedInput, capturedInit @@ -84,7 +87,7 @@ for (const adapter of adapters) { return createMockSseResponse(sseChunks) }) - await adapter.fn(port, 'Q', session) + await generateAnswersWithOpenAICompatibleApi(port, 'Q', session, config) assert.equal(capturedInput, `${adapter.expectedBaseUrl}/chat/completions`) // Verify API key reaches the Authorization header @@ -93,14 +96,21 @@ for (const adapter of adapters) { test(`${adapter.name}: delegates to compat layer and produces output`, async (t) => { t.mock.method(console, 'debug', () => {}) - setStorage(adapter.storage) - const session = makeSession() + const config = { + ...commonStorage, + providerSecrets: { + [adapter.providerId]: adapter.expectedApiKey, + }, + } + setStorage(config) + + const session = makeSession(adapter.apiMode) const port = createFakePort() t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(sseChunks)) - await adapter.fn(port, 'Q', session) + await generateAnswersWithOpenAICompatibleApi(port, 'Q', session, config) assert.equal( port.postedMessages.some((m) => m.done === true && m.session === session), @@ -115,9 +125,13 @@ for (const adapter of adapters) { test('chatglm-api: reads chatglmApiKey from config', async (t) => { t.mock.method(console, 'debug', () => {}) - setStorage({ ...commonStorage, chatglmApiKey: 'glm-secret' }) + const config = { ...commonStorage, chatglmApiKey: 'glm-secret' } + setStorage(config) - const session = makeSession() + const session = makeSession({ + groupName: 'chatglmApiModelKeys', + itemName: 'chatglmTurbo', + }) const port = createFakePort() let capturedInit @@ -126,7 +140,7 @@ test('chatglm-api: reads chatglmApiKey from config', async (t) => { return createMockSseResponse(sseChunks) }) - await generateAnswersWithChatGLMApi(port, 'Q', session) + await generateAnswersWithOpenAICompatibleApi(port, 'Q', session, config) assert.equal(capturedInit.headers.Authorization, 'Bearer glm-secret') }) diff --git a/tests/unit/services/wrappers-register.test.mjs b/tests/unit/services/wrappers-register.test.mjs index c1786e783..66ab7171e 100644 --- a/tests/unit/services/wrappers-register.test.mjs +++ b/tests/unit/services/wrappers-register.test.mjs @@ -43,6 +43,7 @@ import { getBardCookies, getClaudeSessionKey, } from '../../../src/services/wrappers.mjs' +import { normalizeApiMode } from '../../../src/utils/model-name-convert.mjs' const setStorage = (values) => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage(values) @@ -176,7 +177,7 @@ test('registerPortListener defaults apiMode from config for non-custom models', port.emitMessage({ session: { conversationRecords: [] } }) const session = await execDone - assert.deepEqual(session.apiMode, apiMode) + assert.deepEqual(session.apiMode, normalizeApiMode(apiMode)) }) test('registerPortListener sets aiName when not provided', async (t) => { diff --git a/tests/unit/utils/model-name-convert.test.mjs b/tests/unit/utils/model-name-convert.test.mjs index 8601418a3..068dc5ba9 100644 --- a/tests/unit/utils/model-name-convert.test.mjs +++ b/tests/unit/utils/model-name-convert.test.mjs @@ -13,6 +13,7 @@ import { modelNameToPresetPart, modelNameToValue, getModelValue, + normalizeApiMode, } from '../../../src/utils/model-name-convert.mjs' import { ModelGroups } from '../../../src/config/index.mjs' @@ -305,3 +306,99 @@ test('isUsingModelName returns true for exact apiMode match', () => { test('isUsingModelName resolves ModelGroups presetPart to first value', () => { assert.equal(isUsingModelName('bingFree4', { modelName: 'bingWebModelKeys-custom' }), true) }) + +test('normalizeApiMode trims providerId', () => { + const normalized = normalizeApiMode({ + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-a', + providerId: ' myproxy ', + }) + + assert.equal(normalized.providerId, 'myproxy') +}) + +test('isApiModeSelected matches apiMode when providerId differs only by whitespace', () => { + const apiMode = { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-a', + providerId: 'myproxy', + } + const session = { + apiMode: { + ...apiMode, + providerId: ' myproxy ', + }, + } + + assert.equal(isApiModeSelected(apiMode, session), true) +}) + +test('isApiModeSelected returns false when either side apiMode is invalid', () => { + const validApiMode = { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-a', + providerId: 'myproxy', + } + + assert.equal( + isApiModeSelected(validApiMode, { + apiMode: 'customApiModelKeys-customModel', + }), + false, + ) + assert.equal( + isApiModeSelected('customApiModelKeys-customModel', { + apiMode: validApiMode, + }), + false, + ) + assert.equal( + isApiModeSelected('customApiModelKeys-customModel', { + apiMode: 'customApiModelKeys-customModel', + }), + false, + ) +}) + +test('isApiModeSelected returns false when apiMode differs only by active state', () => { + const apiMode = { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-a', + providerId: 'myproxy', + active: false, + } + const session = { + apiMode: { + ...apiMode, + active: true, + }, + } + + assert.equal(isApiModeSelected(apiMode, session), false) +}) + +test('isApiModeSelected returns true when apiMode active state is equal', () => { + const apiMode = { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + customName: 'mode-a', + providerId: 'myproxy', + active: true, + } + const session = { + apiMode: { + ...apiMode, + }, + } + + assert.equal(isApiModeSelected(apiMode, session), true) +})