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 48368313c..81264f07e 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', @@ -547,9 +551,13 @@ export const defaultConfig = { customName: '', customUrl: '', apiKey: '', + providerId: '', active: false, }, ], + customOpenAIProviders: [], + providerSecrets: {}, + configSchemaVersion: 1, activeSelectionTools: ['translate', 'translateToEn', 'summary', 'polish', 'code', 'ask'], customSelectionTools: [ { @@ -722,15 +730,479 @@ 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 existingProviderSecret = normalizeText(providerSecrets[providerId]) + if (legacyKeyValue && !existingProviderSecret) { + 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 || 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 legacyCustomProviderSecret = normalizeText(providerSecrets['legacy-custom-default']) + const isProviderSecretCompatibleForCustomMode = (modeApiKey, providerSecret) => { + const effectiveModeKey = normalizeText(modeApiKey) || legacyCustomProviderSecret + if (effectiveModeKey) { + return !providerSecret || providerSecret === effectiveModeKey + } + return !providerSecret + } + 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 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 existingProviderSecret = normalizeText(providerSecrets[apiMode.providerId]) + if (!existingProviderSecret) { + providerSecrets[apiMode.providerId] = apiModeKey + dirty = true + } + // 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) { + const existingProviderSecret = normalizeText(providerSecrets[apiMode.providerId]) + if (!existingProviderSecret) { + providerSecrets[apiMode.providerId] = legacyCustomProviderSecret + dirty = true + } + } + } + + if (migrated.apiMode && typeof migrated.apiMode === 'object') { + const selectedApiMode = { ...migrated.apiMode } + let selectedApiModeDirty = false + const selectedIsCustom = selectedApiMode.groupName === 'customApiModelKeys' + let selectedProviderIdAssignedFromLegacyCustomUrl = false + + 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 && !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 && + !normalizeText(providerSecrets[selectedTargetProviderId]) + ) { + providerSecrets[selectedTargetProviderId] = legacyCustomProviderSecret + dirty = true + } + if (selectedApiModeKey) { + const targetProviderId = selectedIsCustom + ? normalizeText(selectedApiMode.providerId) || 'legacy-custom-default' + : API_MODE_GROUP_TO_PROVIDER_ID[normalizeText(selectedApiMode.groupName)] || + normalizeText(selectedApiMode.providerId) + if (targetProviderId) { + const existingProviderSecret = normalizeText(providerSecrets[targetProviderId]) + if (!existingProviderSecret) { + providerSecrets[targetProviderId] = selectedApiModeKey + dirty = true + } else if (selectedIsCustom && existingProviderSecret !== selectedApiModeKey) { + // Keep the currently selected custom mode effective after migration by + // promoting its legacy mode-level key to providerSecrets. + providerSecrets[targetProviderId] = selectedApiModeKey + dirty = true + } + 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 providerSecret = normalizeText(providerSecrets[providerId]) + if (providerSecret && normalizeText(migrated[legacyKey]) !== providerSecret) { + migrated[legacyKey] = providerSecret + 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..a1df30330 100644 --- a/src/popup/sections/ApiModes.jsx +++ b/src/popup/sections/ApiModes.jsx @@ -7,19 +7,26 @@ import { modelNameToDesc, } from '../../utils/index.mjs' import { PencilIcon, TrashIcon } from '@primer/octicons-react' -import { useLayoutEffect, useState } from 'react' +import { useLayoutEffect, 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 { + createProviderId, + parseChatCompletionsEndpointUrl, + resolveSelectableProviderId, + resolveProviderChatEndpointUrl, +} 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 +34,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 +66,27 @@ 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 [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 +103,153 @@ export function ApiModes({ config, updateConfig }) { }) } + const shouldEditProvider = editingApiMode.groupName === 'customApiModelKeys' + const effectiveProviders = pendingNewProvider + ? [...customProviders, pendingNewProvider] + : customProviders + const selectedCustomProvider = effectiveProviders.find( + (provider) => provider.id === providerSelector, + ) + + const persistApiMode = (nextApiMode) => { + const payload = { + activeApiModes: [], + customApiModes: + editingIndex === -1 + ? [...apiModes, nextApiMode] + : apiModes.map((apiMode, index) => (index === editingIndex ? nextApiMode : apiMode)), + } + if (pendingNewProvider) { + payload.customOpenAIProviders = [...customProviders, pendingNewProvider] + } + if (editingIndex !== -1 && isApiModeSelected(apiModes[editingIndex], config)) { + payload.apiMode = nextApiMode + } + updateConfig(payload) + setPendingNewProvider(null) + } + + 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({ + ...pendingNewProvider, + name: providerName, + baseUrl: '', + chatCompletionsUrl: parsedEndpoint.chatCompletionsUrl, + completionsUrl: parsedEndpoint.completionsUrl, + }) + } else { + const nextCustomProviders = customProviders.map((provider) => + provider.id === providerEditingId + ? { + ...provider, + name: providerName, + baseUrl: '', + chatCompletionsUrl: parsedEndpoint.chatCompletionsUrl, + completionsUrl: parsedEndpoint.completionsUrl, + } + : provider, + ) + updateConfig({ customOpenAIProviders: nextCustomProviders }) + } + 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 +257,14 @@ export function ApiModes({ config, updateConfig }) { onClick={(e) => { e.preventDefault() setEditing(false) + setPendingNewProvider(null) }} > {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 +436,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('') + setPendingNewProvider(null) setEditingIndex(index) }} > @@ -223,6 +487,12 @@ export function ApiModes({ config, updateConfig }) { e.preventDefault() setEditing(true) setEditingApiMode(defaultApiMode) + setProviderSelector(LEGACY_CUSTOM_PROVIDER_ID) + setProviderDraft(defaultProviderDraft) + setProviderDraftValidation(defaultProviderDraftValidation) + setIsProviderEditorOpen(false) + setProviderEditingId('') + setPendingNewProvider(null) setEditingIndex(-1) }} > diff --git a/src/popup/sections/GeneralPart.jsx b/src/popup/sections/GeneralPart.jsx index 9af6e5427..b075a235e 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,19 @@ 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 } from './general-balance-utils.mjs' +import { buildProviderSecretUpdate } from './provider-secret-utils.mjs' GeneralPart.propTypes = { config: PropTypes.object.isRequired, @@ -109,19 +109,47 @@ 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 openAiApiUrl = selectedProvider?.baseUrl || config.customOpenAiApiUrl + try { + const response = await fetch(`${openAiApiUrl}/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, openAiApiUrl) + if (billing && billing.length > 2) { + const fallbackBalance = formatFiniteBalance(billing[2]) + if (fallbackBalance !== null) { + setBalance(fallbackBalance) + return + } + } + } catch (error) { + console.error(error) } + openUrl('https://platform.openai.com/account/usage') } return ( @@ -178,12 +206,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,41 +326,6 @@ export function GeneralPart({ config, updateConfig, setTabIndex }) { }} /> )} - {isUsingChatGLMApiModel(config) && ( - { - const apiKey = e.target.value - updateConfig({ chatglmApiKey: 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 +390,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 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}` +} diff --git a/src/popup/sections/general-balance-utils.mjs b/src/popup/sections/general-balance-utils.mjs new file mode 100644 index 000000000..efa931668 --- /dev/null +++ b/src/popup/sections/general-balance-utils.mjs @@ -0,0 +1,15 @@ +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) +} 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..2c8a4b836 --- /dev/null +++ b/src/services/apis/provider-registry.mjs @@ -0,0 +1,372 @@ +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 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}` + return { + id, + name: toStringOrEmpty(provider.name).trim() || `Custom Provider ${index + 1}`, + baseUrl: trimSlashes(provider.baseUrl), + chatCompletionsPath: ensureLeadingSlash(provider.chatCompletionsPath, DEFAULT_CHAT_PATH), + completionsPath: ensureLeadingSlash(provider.completionsPath, DEFAULT_COMPLETION_PATH), + chatCompletionsUrl: toStringOrEmpty(provider.chatCompletionsUrl).trim(), + completionsUrl: toStringOrEmpty(provider.completionsUrl).trim(), + 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 +} + +export function getProviderSecret(config, providerId, session) { + if (!providerId) return '' + const apiModeApiKey = + session?.apiMode && typeof session.apiMode === 'object' + ? toStringOrEmpty(session.apiMode.apiKey).trim() + : '' + if (session?.apiMode?.groupName === 'customApiModelKeys' && apiModeApiKey) { + return apiModeApiKey + } + + const fromMap = + config?.providerSecrets && typeof config.providerSecrets === 'object' + ? toStringOrEmpty(config.providerSecrets[providerId]).trim() + : '' + if (fromMap) return fromMap + const legacyKey = LEGACY_API_KEY_FIELD_BY_PROVIDER_ID[providerId] + const legacyValue = legacyKey ? toStringOrEmpty(config?.[legacyKey]).trim() : '' + if (legacyValue) return legacyValue + + return apiModeApiKey +} + +function resolveUrlFromProvider(provider, endpointType, config, session) { + if (!provider) return '' + + const apiModeCustomUrl = + endpointType === 'chat' && + session?.apiMode && + typeof session.apiMode === 'object' && + session.apiMode.groupName === 'customApiModelKeys' && + !toStringOrEmpty(session.apiMode.providerId).trim() + ? 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 + 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 customUrl = normalizeEndpointUrlForCompare(session?.apiMode?.customUrl) + if (customUrl) { + const matchedByCustomUrl = customProviders.find( + (item) => + item.enabled !== false && + normalizeEndpointUrlForCompare(item.chatCompletionsUrl) === customUrl, + ) + if (matchedByCustomUrl) { + provider = matchedByCustomUrl + resolvedProviderId = matchedByCustomUrl.id + } + } + } + } + if (!provider) { + provider = getProviderById(config, providerId) + } + if (!provider) return null + const endpointType = resolveEndpointTypeForSession(session) + const requestUrl = resolveUrlFromProvider(provider, endpointType, config, session) + 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..8df2f36be --- /dev/null +++ b/tests/unit/config/migrate-user-config.test.mjs @@ -0,0 +1,508 @@ +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 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 promotes selected custom mode apiKey and clears mode-level keys', 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') + + assert.equal(config.providerSecrets.myproxy, 'selected-mode-level-key') + assert.equal(migratedMode.apiKey, '') + assert.equal(config.apiMode.apiKey, '') +}) + +test('getUserConfig consolidates multiple custom mode apiKeys for one provider', 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(config.providerSecrets.myproxy, 'key-b') + 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..adaff1b37 --- /dev/null +++ b/tests/unit/popup/api-modes-provider-utils.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + createProviderId, + parseChatCompletionsEndpointUrl, + resolveSelectableProviderId, + resolveProviderChatEndpointUrl, +} 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('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) +}) 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..98a7fec45 --- /dev/null +++ b/tests/unit/popup/general-balance-utils.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { formatFiniteBalance } 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) +}) 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 8ca6b78c9..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', @@ -151,7 +151,10 @@ test('ignores null message.content to avoid null-prefixed answers', async (t) => ) const partialAnswers = port.postedMessages.filter((m) => m.done === false).map((m) => m.answer) - assert.equal(partialAnswers.some((a) => a === null), false) + assert.equal( + partialAnswers.some((a) => a === null), + false, + ) assert.equal( partialAnswers.some((a) => typeof a === 'string' && a.startsWith('null')), false, diff --git a/tests/unit/services/apis/openai-api-compat.test.mjs b/tests/unit/services/apis/openai-api-compat.test.mjs index 76b59edd9..5ec8bdde3 100644 --- a/tests/unit/services/apis/openai-api-compat.test.mjs +++ b/tests/unit/services/apis/openai-api-compat.test.mjs @@ -1,8 +1,10 @@ import assert from 'node:assert/strict' import { beforeEach, test } from 'node:test' 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' @@ -75,10 +77,48 @@ 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 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({ @@ -482,3 +522,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..79eb5f89a --- /dev/null +++ b/tests/unit/services/apis/provider-registry.test.mjs @@ -0,0 +1,161 @@ +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 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 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') +}) 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 019cd4558..b2380f4f9 100644 --- a/tests/unit/utils/model-name-convert.test.mjs +++ b/tests/unit/utils/model-name-convert.test.mjs @@ -12,6 +12,7 @@ import { modelNameToDesc, modelNameToValue, getModelValue, + normalizeApiMode, } from '../../../src/utils/model-name-convert.mjs' import { ModelGroups } from '../../../src/config/index.mjs' @@ -263,3 +264,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) +})