From 77d85e053844c0a4a38975862c2ddf967d36454e Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Tue, 24 Feb 2026 22:27:51 +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 | 376 ++++++++++++++++++- src/services/apis/custom-api.mjs | 103 +---- src/services/apis/openai-api.mjs | 243 +++++------- src/services/apis/openai-compatible-core.mjs | 159 ++++++++ src/services/apis/provider-registry.mjs | 318 ++++++++++++++++ src/services/init-session.mjs | 8 +- src/services/wrappers.mjs | 7 +- src/utils/model-name-convert.mjs | 45 ++- 9 files changed, 1017 insertions(+), 329 deletions(-) create mode 100644 src/services/apis/openai-compatible-core.mjs create mode 100644 src/services/apis/provider-registry.mjs diff --git a/src/background/index.mjs b/src/background/index.mjs index 86a2883b..ac0d286e 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' const RECONNECT_CONFIG = { MAX_ATTEMPTS: 5, @@ -419,6 +409,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}`, @@ -434,29 +438,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 ( @@ -581,46 +563,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 48368313..bf1ec84e 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -547,9 +547,13 @@ export const defaultConfig = { customName: '', customUrl: '', apiKey: '', + providerId: '', active: false, }, ], + customOpenAIProviders: [], + providerSecrets: {}, + configSchemaVersion: 1, activeSelectionTools: ['translate', 'translateToEn', 'summary', 'polish', 'code', 'ask'], customSelectionTools: [ { @@ -722,15 +726,381 @@ export async function getPreferredLanguageKey() { return config.preferredLanguage } +const CONFIG_SCHEMA_VERSION = 1 +const LEGACY_SECRET_KEY_TO_PROVIDER_ID = { + apiKey: 'openai', + deepSeekApiKey: 'deepseek', + moonshotApiKey: 'moonshot', + openRouterApiKey: 'openrouter', + aimlApiKey: 'aiml', + chatglmApiKey: 'chatglm', + ollamaApiKey: 'ollama', + customApiKey: 'legacy-custom-default', +} +const API_MODE_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', +} + +function normalizeText(value) { + return typeof value === 'string' ? value.trim() : '' +} + +function normalizeProviderId(value) { + return normalizeText(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +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 originalId = normalizeProviderId(provider.id) + const preferredId = originalId || `custom-provider-${index + 1}` + const id = ensureUniqueProviderId(providerIdSet, preferredId) + providerIdSet.add(id) + return { + originalId, + 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 providerSecrets = + migrated.providerSecrets && typeof migrated.providerSecrets === 'object' + ? { ...migrated.providerSecrets } + : {} + if (!(migrated.providerSecrets && typeof migrated.providerSecrets === 'object')) { + dirty = true + } + for (const [legacyKey, providerId] of Object.entries(LEGACY_SECRET_KEY_TO_PROVIDER_ID)) { + const legacyKeyValue = normalizeText(migrated[legacyKey]) + if (legacyKeyValue && !normalizeText(providerSecrets[providerId])) { + 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, provider }) => { + if (originalId && originalId !== provider.id) { + providerIdRenames.push({ oldId: originalId, 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, newId: newProviderId } = providerIdRenames[index] + if (oldProviderId === newProviderId) continue + if (!legacyCustomProviderIds.has(oldProviderId)) continue + const oldSecret = normalizeText(providerSecrets[oldProviderId]) + if (oldSecret && normalizeText(providerSecrets[newProviderId]) !== oldSecret) { + providerSecrets[newProviderId] = oldSecret + 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']) + 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 existingProviderId = normalizeProviderId(apiMode.providerId) + 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) + if (customUrl) { + let provider = customOpenAIProviders.find( + (item) => normalizeText(item.chatCompletionsUrl) === customUrl, + ) + 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 + 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 + apiMode.apiKey = '' + customApiModesDirty = true + } else if (existingProviderSecret === apiModeKey) { + 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 existingSelectedProviderId = normalizeProviderId(selectedApiMode.providerId) + 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) + if (customUrl) { + let provider = customOpenAIProviders.find( + (item) => normalizeText(item.chatCompletionsUrl) === customUrl, + ) + 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 + 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 + selectedApiMode.apiKey = '' + selectedApiModeDirty = true + } else if (existingProviderSecret === selectedApiModeKey) { + selectedApiMode.apiKey = '' + selectedApiModeDirty = true + } else if (!selectedIsCustom) { + 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 + + 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 = { + customChatGptWebApiUrl: migrated.customChatGptWebApiUrl, + customApiModes: migrated.customApiModes, + customOpenAIProviders: migrated.customOpenAIProviders, + providerSecrets: migrated.providerSecrets, + configSchemaVersion: migrated.configSchemaVersion, + } + if (migrated.apiMode !== undefined) payload.apiMode = migrated.apiMode + await Browser.storage.local.set(payload) + } + return defaults(migrated, defaultConfig) } /** diff --git a/src/services/apis/custom-api.mjs b/src/services/apis/custom-api.mjs index b82e0a15..f0cd9095 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 (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}`) - }, + await generateAnswersWithOpenAICompatible({ + port, + question, + session, + endpointType: 'chat', + requestUrl: apiUrl, + model: modelName, + apiKey, + provider: 'custom', + allowLegacyResponseField: true, }) } diff --git a/src/services/apis/openai-api.mjs b/src/services/apis/openai-api.mjs index 752a2a21..f93d8d02 100644 --- a/src/services/apis/openai-api.mjs +++ b/src/services/apis/openai-api.mjs @@ -1,92 +1,60 @@ -// 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' - -/** - * @param {Browser.Runtime.Port} port - * @param {string} question - * @param {Session} session - * @param {string} apiKey - */ -export async function generateAnswersWithGptCompletionApi(port, question, session, apiKey) { - const { controller, messageListener, disconnectListener } = setAbortController(port) - const model = getModelValue(session) +import { generateAnswersWithOpenAICompatible } from './openai-compatible-core.mjs' +import { resolveOpenAICompatibleRequest } from './provider-registry.mjs' - const config = await getUserConfig() - const prompt = - (await getCompletionPromptBase()) + - getConversationPairs( - session.conversationRecords.slice(-config.maxConversationContextLength), - true, - ) + - `Human: ${question}\nAI: ` - const apiUrl = config.customOpenAiApiUrl +function normalizeBaseUrl(baseUrl) { + return String(baseUrl || '').replace(/\/+$/, '') +} - 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 }) +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() } - await fetchSSE(`${apiUrl}/v1/completions`, { + return getModelValue(session) +} + +async function touchOllamaKeepAlive(config, model, apiKey) { + return fetch(`${normalizeBaseUrl(config.ollamaEndpoint)}/api/generate`, { method: 'POST', - signal: controller.signal, headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), }, body: JSON.stringify({ - prompt: prompt, model, - stream: true, - max_tokens: config.maxResponseTokenLength, - temperature: config.temperature, - stop: '\nHuman', + prompt: 't', + options: { + num_predict: 1, + }, + keep_alive: config.ollamaKeepAliveTime === '-1' ? -1 : config.ollamaKeepAliveTime, }), - 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}`) - }, +/** + * @param {Browser.Runtime.Port} port + * @param {string} question + * @param {Session} session + * @param {string} apiKey + */ +export async function generateAnswersWithGptCompletionApi(port, question, session, apiKey) { + const config = await getUserConfig() + await generateAnswersWithOpenAICompatible({ + port, + question, + session, + endpointType: 'completion', + requestUrl: `${normalizeBaseUrl(config.customOpenAiApiUrl)}/v1/completions`, + model: getModelValue(session), + apiKey, }) } @@ -99,7 +67,7 @@ export async function generateAnswersWithGptCompletionApi(port, question, sessio export async function generateAnswersWithChatgptApi(port, question, session, apiKey) { const config = await getUserConfig() return generateAnswersWithChatgptApiCompat( - config.customOpenAiApiUrl + '/v1', + `${normalizeBaseUrl(config.customOpenAiApiUrl)}/v1`, port, question, session, @@ -118,89 +86,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 00000000..da00e0ce --- /dev/null +++ b/src/services/apis/openai-compatible-core.mjs @@ -0,0 +1,159 @@ +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 && data?.response !== undefined) { + return String(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({ 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}`) + }, + }) +} diff --git a/src/services/apis/provider-registry.mjs b/src/services/apis/provider-registry.mjs new file mode 100644 index 00000000..111f8c0c --- /dev/null +++ b/src/services/apis/provider-registry.mjs @@ -0,0 +1,318 @@ +const DEFAULT_CHAT_PATH = '/v1/chat/completions' +const DEFAULT_COMPLETION_PATH = '/v1/completions' + +const LEGACY_KEY_BY_PROVIDER_ID = { + openai: 'apiKey', + deepseek: 'deepSeekApiKey', + moonshot: 'moonshotApiKey', + openrouter: 'openRouterApiKey', + aiml: 'aimlApiKey', + chatglm: 'chatglmApiKey', + ollama: 'ollamaApiKey', + 'legacy-custom-default': 'customApiKey', +} + +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, + }, +] + +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', +} + +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 trimSlashes(value) { + return toStringOrEmpty(value).trim().replace(/\/+$/, '') +} + +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') { + return { + ...provider, + baseUrl: trimSlashes(config.customOpenAiApiUrl || 'https://api.openai.com'), + } + } + if (provider.id === 'ollama') { + return { + ...provider, + baseUrl: `${trimSlashes(config.ollamaEndpoint || 'http://127.0.0.1:11434')}/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') { + if (apiMode.groupName === 'customApiModelKeys' && apiMode.providerId) return apiMode.providerId + if (apiMode.groupName) { + return OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID[apiMode.groupName] + } + if (apiMode.providerId) return apiMode.providerId + } + if (session?.modelName === 'customModel') return 'legacy-custom-default' + const fromLegacyModelName = resolveProviderIdFromLegacyModelName(session?.modelName) + if (fromLegacyModelName) return fromLegacyModelName + return null +} + +export function resolveEndpointTypeForSession(session) { + if ( + session?.apiMode?.groupName === 'gptApiModelKeys' || + isLegacyCompletionModelName(session?.modelName) + ) { + return 'completion' + } + return '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_KEY_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.customUrl).trim() + : '' + if (apiModeCustomUrl) return apiModeCustomUrl + + if (endpointType === 'completion') { + if (provider.completionsUrl) return provider.completionsUrl + if (provider.completionsPath) return joinUrl(provider.baseUrl, provider.completionsPath) + } else { + if (provider.chatCompletionsUrl) return provider.chatCompletionsUrl + if (provider.chatCompletionsPath) return joinUrl(provider.baseUrl, provider.chatCompletionsPath) + } + + if (provider.id === 'legacy-custom-default') { + if (endpointType === 'completion') { + return `${trimSlashes(config.customOpenAiApiUrl || 'https://api.openai.com')}/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 + const 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, + provider, + endpointType, + requestUrl, + apiKey: getProviderSecret(config, providerId, session), + } +} diff --git a/src/services/init-session.mjs b/src/services/init-session.mjs index 999d3165..fac630a3 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 c828f903..aff0f2a5 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 3f206232..f815d346 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: apiMode.providerId || '', + 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,9 +145,19 @@ export function getApiModesStringArrayFromConfig(config, onlyActive) { } export function isApiModeSelected(apiMode, configOrSession) { + const normalizeForCompare = (value) => { + const normalized = normalizeApiMode(value) + if (!normalized) return '' + return JSON.stringify({ + groupName: normalized.groupName, + itemName: normalized.itemName, + isCustom: normalized.isCustom, + customName: normalized.customName, + providerId: normalized.providerId, + }) + } return configOrSession.apiMode - ? JSON.stringify(configOrSession.apiMode, Object.keys(configOrSession.apiMode).sort()) === - JSON.stringify(apiMode, Object.keys(apiMode).sort()) + ? normalizeForCompare(configOrSession.apiMode) === normalizeForCompare(apiMode) : configOrSession.modelName === apiModeToModelName(apiMode) } From 36ed81e5fae15fa42baea3f38c77c1e931aa1cf7 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Tue, 24 Feb 2026 22:36:32 +0800 Subject: [PATCH 2/2] Add custom provider workflow to API modes Extend API Modes editing so custom model entries can bind to a providerId and create a new OpenAI-compatible provider in the same flow with only provider name and base URL. Unify API key editing in General settings by resolving the currently selected OpenAI-compatible provider and writing secrets into the new providerSecrets map, while still syncing legacy key fields for backward compatibility. Preserve legacy custom URL behavior for legacy provider mode and clear apiMode.customUrl when users switch to a registered provider so provider registry URLs are applied correctly. --- src/popup/sections/ApiModes.jsx | 242 +++++++++++++++++++++++----- src/popup/sections/GeneralPart.jsx | 250 +++++++++++++++-------------- 2 files changed, 326 insertions(+), 166 deletions(-) diff --git a/src/popup/sections/ApiModes.jsx b/src/popup/sections/ApiModes.jsx index 7fdff7f3..6ad6bd14 100644 --- a/src/popup/sections/ApiModes.jsx +++ b/src/popup/sections/ApiModes.jsx @@ -8,18 +8,19 @@ import { } from '../../utils/index.mjs' import { PencilIcon, TrashIcon } from '@primer/octicons-react' import { useLayoutEffect, 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' 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 +28,58 @@ const defaultApiMode = { customName: '', customUrl: 'http://localhost:8000/v1/chat/completions', apiKey: '', + providerId: '', active: true, } +const defaultProviderDraft = { + name: '', + baseUrl: '', + chatCompletionsPath: '/v1/chat/completions', + completionsPath: '/v1/completions', +} + +function normalizeProviderId(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function createProviderId(providerName, existingProviders) { + const usedIds = new Set([ + ...Object.values(OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID), + ...existingProviders.map((provider) => 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 +} + +function normalizeBaseUrl(value) { + return String(value || '') + .trim() + .replace(/\/+$/, '') +} + +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 +87,19 @@ export function ApiModes({ config, updateConfig }) { const [editingIndex, setEditingIndex] = useState(-1) const [apiModes, setApiModes] = useState([]) const [apiModeStringArray, setApiModeStringArray] = useState([]) + const [customProviders, setCustomProviders] = useState([]) + const [providerSelector, setProviderSelector] = useState(LEGACY_CUSTOM_PROVIDER_ID) + const [providerDraft, setProviderDraft] = useState(defaultProviderDraft) 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 +116,84 @@ export function ApiModes({ config, updateConfig }) { }) } + const shouldEditProvider = editingApiMode.groupName === 'customApiModelKeys' + + const persistApiMode = (nextApiMode, nextCustomProviders) => { + const payload = { + activeApiModes: [], + customApiModes: + editingIndex === -1 + ? [...apiModes, nextApiMode] + : apiModes.map((apiMode, index) => (index === editingIndex ? nextApiMode : apiMode)), + } + if (nextCustomProviders !== null) payload.customOpenAIProviders = nextCustomProviders + if (editingIndex !== -1 && isApiModeSelected(apiModes[editingIndex], config)) { + payload.apiMode = nextApiMode + } + updateConfig(payload) + } + + const onSaveEditing = (event) => { + event.preventDefault() + let nextApiMode = { ...editingApiMode } + let nextCustomProviders = null + const previousProviderId = + editingIndex === -1 ? '' : apiModes[editingIndex]?.providerId || LEGACY_CUSTOM_PROVIDER_ID + + if (shouldEditProvider) { + if (providerSelector === '__new__') { + const providerName = providerDraft.name.trim() + const providerBaseUrl = normalizeBaseUrl(providerDraft.baseUrl) + if (!providerName || !providerBaseUrl) return + const hasChatCompletionsEndpoint = /\/chat\/completions$/i.test(providerBaseUrl) + const hasV1BasePath = /\/v1$/i.test(providerBaseUrl) + const providerChatCompletionsUrl = hasChatCompletionsEndpoint ? providerBaseUrl : '' + const providerCompletionsUrl = hasChatCompletionsEndpoint + ? providerBaseUrl.replace(/\/chat\/completions$/i, '/completions') + : '' + const providerChatCompletionsPath = hasV1BasePath + ? '/chat/completions' + : providerDraft.chatCompletionsPath + const providerCompletionsPath = hasV1BasePath + ? '/completions' + : providerDraft.completionsPath + + const providerId = createProviderId(providerName, customProviders) + const createdProvider = { + id: providerId, + name: providerName, + baseUrl: hasChatCompletionsEndpoint ? '' : providerBaseUrl, + chatCompletionsPath: providerChatCompletionsPath, + completionsPath: providerCompletionsPath, + chatCompletionsUrl: providerChatCompletionsUrl, + completionsUrl: providerCompletionsUrl, + enabled: true, + allowLegacyResponseField: true, + } + nextCustomProviders = [...customProviders, createdProvider] + const shouldClearApiKey = editingIndex !== -1 && providerId !== previousProviderId + nextApiMode = { + ...nextApiMode, + providerId, + customUrl: '', + apiKey: shouldClearApiKey ? '' : nextApiMode.apiKey, + } + } else { + const selectedProviderId = providerSelector || LEGACY_CUSTOM_PROVIDER_ID + const shouldClearApiKey = editingIndex !== -1 && selectedProviderId !== previousProviderId + nextApiMode = { + ...nextApiMode, + providerId: selectedProviderId, + customUrl: '', + apiKey: shouldClearApiKey ? '' : nextApiMode.apiKey, + } + } + } + + persistApiMode(sanitizeApiModeForSave(nextApiMode), nextCustomProviders) + setEditing(false) + } + const editingComponent = (
@@ -72,26 +205,7 @@ export function ApiModes({ config, updateConfig }) { > {t('Cancel')} - +
{t('Type')} @@ -103,7 +217,16 @@ export function ApiModes({ config, updateConfig }) { const isCustom = editingApiMode.itemName === 'custom' && !AlwaysCustomGroups.includes(groupName) if (isCustom) itemName = 'custom' - setEditingApiMode({ ...editingApiMode, groupName, itemName, isCustom }) + const providerId = + groupName === 'customApiModelKeys' + ? editingApiMode.providerId || LEGACY_CUSTOM_PROVIDER_ID + : '' + setEditingApiMode({ ...editingApiMode, groupName, itemName, isCustom, providerId }) + if (groupName === 'customApiModelKeys') { + setProviderSelector(providerId) + } else { + setProviderSelector(LEGACY_CUSTOM_PROVIDER_ID) + } }} > {Object.entries(ModelGroups).map(([groupName, { desc }]) => ( @@ -141,24 +264,45 @@ export function ApiModes({ config, updateConfig }) { /> )}
- {CustomUrlGroups.includes(editingApiMode.groupName) && - (editingApiMode.isCustom || AlwaysCustomGroups.includes(editingApiMode.groupName)) && ( + {shouldEditProvider && ( +
+ {t('Provider')} + +
+ )} + {shouldEditProvider && providerSelector === '__new__' && ( + <> setEditingApiMode({ ...editingApiMode, customUrl: e.target.value })} + value={providerDraft.name} + placeholder={t('Provider')} + onChange={(e) => setProviderDraft({ ...providerDraft, name: e.target.value })} /> - )} - {CustomApiKeyGroups.includes(editingApiMode.groupName) && - (editingApiMode.isCustom || AlwaysCustomGroups.includes(editingApiMode.groupName)) && ( setEditingApiMode({ ...editingApiMode, apiKey: e.target.value })} + type="text" + value={providerDraft.baseUrl} + placeholder={t('API Url')} + onChange={(e) => setProviderDraft({ ...providerDraft, baseUrl: e.target.value })} /> - )} + + )}
) @@ -190,7 +334,17 @@ export function ApiModes({ config, updateConfig }) { onClick={(e) => { e.preventDefault() setEditing(true) - setEditingApiMode(apiMode) + const isCustomApiMode = apiMode.groupName === 'customApiModelKeys' + const providerId = isCustomApiMode + ? apiMode.providerId || LEGACY_CUSTOM_PROVIDER_ID + : '' + setEditingApiMode({ + ...defaultApiMode, + ...apiMode, + providerId, + }) + setProviderSelector(providerId || LEGACY_CUSTOM_PROVIDER_ID) + setProviderDraft(defaultProviderDraft) setEditingIndex(index) }} > @@ -223,6 +377,8 @@ export function ApiModes({ config, updateConfig }) { e.preventDefault() setEditing(true) setEditingApiMode(defaultApiMode) + setProviderSelector(LEGACY_CUSTOM_PROVIDER_ID) + setProviderDraft(defaultProviderDraft) setEditingIndex(-1) }} > diff --git a/src/popup/sections/GeneralPart.jsx b/src/popup/sections/GeneralPart.jsx index 9af6e542..7e137fd0 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,17 @@ 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' GeneralPart.propTypes = { config: PropTypes.object.isRequired, @@ -95,6 +93,93 @@ function isUsingSpecialCustomModel(configOrSession) { return isUsingCustomModel(configOrSession) && !configOrSession.apiMode } +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', +} + +function buildProviderSecretUpdate(config, providerId, apiKey) { + if (!providerId) return {} + const normalizedProviderId = String(providerId).trim() + 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 +} + export function GeneralPart({ config, updateConfig, setTabIndex }) { const { t, i18n } = useTranslation() const [balance, setBalance] = useState(null) @@ -109,16 +194,25 @@ export function GeneralPart({ config, updateConfig, setTabIndex }) { config.ollamaModelName, ]) + const selectedProviderRequest = resolveOpenAICompatibleRequest(config, config) + 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`, { + const openAiApiUrl = selectedProvider?.baseUrl || config.customOpenAiApiUrl + const response = await fetch(`${openAiApiUrl}/dashboard/billing/credit_grants`, { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${config.apiKey}`, + Authorization: `Bearer ${selectedProviderApiKey}`, }, }) if (response.ok) setBalance((await response.json()).total_available.toFixed(2)) else { - const billing = await checkBilling(config.apiKey, config.customOpenAiApiUrl) + const billing = await checkBilling(selectedProviderApiKey, openAiApiUrl) if (billing && billing.length > 2 && billing[2]) setBalance(`${billing[2].toFixed(2)}`) else openUrl('https://platform.openai.com/account/usage') } @@ -178,12 +272,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 +392,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 +456,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) && (