From a9c729490a7007e2dba59e3cd82b9221380b4ee6 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Thu, 26 Feb 2026 23:58:23 +0800 Subject: [PATCH 1/2] Refactor OpenAI-compatible provider execution into shared core Consolidate OpenAI-compatible API request handling into a single shared module and route background dispatch through provider registry lookup. This removes duplicated streaming/parsing logic from openai-api and custom-api while keeping existing behavior. Add config migration to preserve existing API keys and custom mode entries by mapping them into providerSecrets and custom provider records. Keep legacy fallbacks for apiMode customUrl/custom apiKey to avoid user-visible regressions during rollout. Normalize apiMode objects at runtime and compare selection using stable identity fields so migrated and legacy session data continue to match correctly. --- src/background/index.mjs | 87 +-- src/config/index.mjs | 478 +++++++++++++++- src/config/openai-provider-mappings.mjs | 30 ++ src/services/apis/aiml-api.mjs | 12 - src/services/apis/chatglm-api.mjs | 14 - src/services/apis/custom-api.mjs | 103 +--- src/services/apis/deepseek-api.mjs | 12 - src/services/apis/moonshot-api.mjs | 12 - src/services/apis/ollama-api.mjs | 36 -- src/services/apis/openai-api.mjs | 276 ++++------ src/services/apis/openai-compatible-core.mjs | 161 ++++++ src/services/apis/openrouter-api.mjs | 12 - src/services/apis/provider-registry.mjs | 372 +++++++++++++ src/services/init-session.mjs | 8 +- src/services/wrappers.mjs | 7 +- src/utils/model-name-convert.mjs | 54 +- .../unit/config/migrate-user-config.test.mjs | 508 ++++++++++++++++++ tests/unit/services/apis/custom-api.test.mjs | 7 +- .../services/apis/openai-api-compat.test.mjs | 211 +++++++- .../services/apis/provider-registry.test.mjs | 161 ++++++ .../unit/services/apis/thin-adapters.test.mjs | 66 ++- .../unit/services/wrappers-register.test.mjs | 3 +- tests/unit/utils/model-name-convert.test.mjs | 97 ++++ 23 files changed, 2266 insertions(+), 461 deletions(-) create mode 100644 src/config/openai-provider-mappings.mjs delete mode 100644 src/services/apis/aiml-api.mjs delete mode 100644 src/services/apis/chatglm-api.mjs delete mode 100644 src/services/apis/deepseek-api.mjs delete mode 100644 src/services/apis/moonshot-api.mjs delete mode 100644 src/services/apis/ollama-api.mjs create mode 100644 src/services/apis/openai-compatible-core.mjs delete mode 100644 src/services/apis/openrouter-api.mjs create mode 100644 src/services/apis/provider-registry.mjs create mode 100644 tests/unit/config/migrate-user-config.test.mjs create mode 100644 tests/unit/services/apis/provider-registry.test.mjs 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/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/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) +}) From 5cab19b29e330f753b434637b610b807f73ec9d5 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Fri, 27 Feb 2026 01:46:44 +0800 Subject: [PATCH 2/2] Add custom provider editor to API modes Split provider management from API mode saving so users can add and edit custom OpenAI-compatible providers in a dedicated editor. Require a full chat-completions endpoint URL for provider setup and derive the paired completions endpoint with shared popup utilities backed by unit tests. Keep API mode provider binding explicit at save time and preserve provider-secret synchronization in General settings for backward compatibility. Add provider-related locale keys across supported locales. --- src/_locales/de/main.json | 3 + src/_locales/en/main.json | 3 + src/_locales/es/main.json | 3 + src/_locales/fr/main.json | 3 + src/_locales/in/main.json | 3 + src/_locales/it/main.json | 3 + src/_locales/ja/main.json | 3 + src/_locales/ko/main.json | 3 + src/_locales/pt/main.json | 3 + src/_locales/ru/main.json | 3 + src/_locales/tr/main.json | 3 + src/_locales/zh-hans/main.json | 3 + src/_locales/zh-hant/main.json | 3 + src/popup/sections/ApiModes.jsx | 362 +++++++++++++++--- src/popup/sections/GeneralPart.jsx | 200 ++++------ .../sections/api-modes-provider-utils.mjs | 91 +++++ src/popup/sections/general-balance-utils.mjs | 15 + src/popup/sections/provider-secret-utils.mjs | 79 ++++ .../popup/api-modes-provider-utils.test.mjs | 77 ++++ .../unit/popup/general-balance-utils.test.mjs | 17 + .../unit/popup/provider-secret-utils.test.mjs | 118 ++++++ 21 files changed, 821 insertions(+), 177 deletions(-) create mode 100644 src/popup/sections/api-modes-provider-utils.mjs create mode 100644 src/popup/sections/general-balance-utils.mjs create mode 100644 src/popup/sections/provider-secret-utils.mjs create mode 100644 tests/unit/popup/api-modes-provider-utils.test.mjs create mode 100644 tests/unit/popup/general-balance-utils.test.mjs create mode 100644 tests/unit/popup/provider-secret-utils.test.mjs 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/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/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', + ) +})