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/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) && ( { - 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) }