From a399f9495846b5881b696de79299af8f0ae07972 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:30:26 +0000 Subject: [PATCH 1/2] Initial plan From 6a89b594c4b4fdcae1cf4e66b6d4ecb0debaa71e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:42:33 +0000 Subject: [PATCH 2/2] Implement provider-level management for custom models with UI Co-authored-by: PeterDaveHello <3691490+PeterDaveHello@users.noreply.github.com> --- src/_locales/en/main.json | 12 ++ src/background/index.mjs | 23 ++- src/config/index.mjs | 115 ++++++++++- src/popup/Popup.jsx | 5 + src/popup/sections/CustomProviders.jsx | 259 +++++++++++++++++++++++++ src/popup/sections/GeneralPart.jsx | 1 + src/services/apis/custom-api.mjs | 36 +++- src/services/init-session.mjs | 8 + src/utils/model-name-convert.mjs | 34 +++- 9 files changed, 484 insertions(+), 9 deletions(-) create mode 100644 src/popup/sections/CustomProviders.jsx diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index 174f99af..5b65c11f 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -160,5 +160,17 @@ "Type": "Type", "Mode": "Mode", "Custom": "Custom", + "Custom Providers": "Custom Providers", + "Manage OpenAI-compatible API providers. Each provider can have multiple models sharing the same API key and base URL.": "Manage OpenAI-compatible API providers. Each provider can have multiple models sharing the same API key and base URL.", + "Add New Provider": "Add New Provider", + "Add Provider": "Add Provider", + "Save Provider": "Save Provider", + "Provider Name (e.g., \"OpenRouter\", \"LocalAI\")": "Provider Name (e.g., \"OpenRouter\", \"LocalAI\")", + "Base URL (e.g., \"https://openrouter.ai/api/v1/chat/completions\")": "Base URL (e.g., \"https://openrouter.ai/api/v1/chat/completions\")", + "Models": "Models", + "Add Model": "Add Model", + "Model Name (e.g., \"gpt-4\", \"claude-3\")": "Model Name (e.g., \"gpt-4\", \"claude-3\")", + "Display Name (e.g., \"GPT-4\", \"Claude 3\")": "Display Name (e.g., \"GPT-4\", \"Claude 3\")", + "Active": "Active", "Crop Text to ensure the input tokens do not exceed the model's limit": "Crop Text to ensure the input tokens do not exceed the model's limit" } diff --git a/src/background/index.mjs b/src/background/index.mjs index 9d759b9a..cd58b64c 100644 --- a/src/background/index.mjs +++ b/src/background/index.mjs @@ -9,7 +9,10 @@ import { generateAnswersWithChatgptApi, generateAnswersWithGptCompletionApi, } from '../services/apis/openai-api' -import { generateAnswersWithCustomApi } from '../services/apis/custom-api.mjs' +import { + generateAnswersWithCustomApi, + generateAnswersWithCustomProviderApi, +} from '../services/apis/custom-api.mjs' import { generateAnswersWithOllamaApi } from '../services/apis/ollama-api.mjs' import { generateAnswersWithAzureOpenaiApi } from '../services/apis/azure-openai-api.mjs' import { generateAnswersWithClaudeApi } from '../services/apis/claude-api.mjs' @@ -84,7 +87,19 @@ async function executeApi(session, port, config) { console.debug('modelName', session.modelName) console.debug('apiMode', session.apiMode) if (isUsingCustomModel(session)) { - if (!session.apiMode) + if (session.providerId && session.providerModelName) { + // Use provider-based configuration from session + await generateAnswersWithCustomProviderApi(port, session.question, session) + } else if (session.apiMode && session.apiMode.providerId) { + // Use provider-based configuration from API mode + const providerSession = { + ...session, + providerId: session.apiMode.providerId, + providerModelName: session.apiMode.providerModelName, + } + await generateAnswersWithCustomProviderApi(port, session.question, providerSession) + } else if (!session.apiMode) { + // Legacy single custom model await generateAnswersWithCustomApi( port, session.question, @@ -93,7 +108,8 @@ async function executeApi(session, port, config) { config.customApiKey, config.customModelName, ) - else + } else { + // Custom API mode await generateAnswersWithCustomApi( port, session.question, @@ -104,6 +120,7 @@ async function executeApi(session, port, config) { session.apiMode.apiKey.trim() || config.customApiKey, session.apiMode.customName, ) + } } else if (isUsingChatgptWebModel(session)) { let tabId if ( diff --git a/src/config/index.mjs b/src/config/index.mjs index fb504aee..f0f3d281 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -553,6 +553,23 @@ export const defaultConfig = { active: false, }, ], + // Provider-level management for OpenAI-compatible APIs + customProviders: [ + { + id: '', + name: '', + baseUrl: '', + apiKey: '', + active: true, + models: [ + { + name: '', + displayName: '', + active: true, + }, + ], + }, + ], activeSelectionTools: ['translate', 'translateToEn', 'summary', 'polish', 'code', 'ask'], customSelectionTools: [ { @@ -712,6 +729,53 @@ export function isUsingCustomModel(configOrSession) { return isInApiModeGroup(customApiModelKeys, configOrSession) } +/** + * Check if a session is using a custom provider model + * @param {object} configOrSession + * @returns {boolean} + */ +export function isUsingCustomProviderModel(configOrSession) { + return configOrSession.providerId && configOrSession.providerModelName +} + +/** + * Get provider configuration by ID + * @param {UserConfig} config + * @param {string} providerId + * @returns {object|null} + */ +export function getCustomProvider(config, providerId) { + return config.customProviders?.find((provider) => provider.id === providerId) || null +} + +/** + * Get all active custom providers + * @param {UserConfig} config + * @returns {array} + */ +export function getActiveCustomProviders(config) { + return config.customProviders?.filter((provider) => provider.active) || [] +} + +/** + * Get all active models from all providers + * @param {UserConfig} config + * @returns {array} Array of {provider, model} objects + */ +export function getAllActiveProviderModels(config) { + const result = [] + const activeProviders = getActiveCustomProviders(config) + + for (const provider of activeProviders) { + const activeModels = provider.models?.filter((model) => model.active) || [] + for (const model of activeModels) { + result.push({ provider, model }) + } + } + + return result +} + /** * @deprecated */ @@ -729,11 +793,60 @@ export async function getPreferredLanguageKey() { * get user config from local storage * @returns {Promise} */ +/** + * Migrate legacy custom model configuration to providers if needed + * @param {UserConfig} config + * @returns {UserConfig} + */ +function migrateCustomModelToProvider(config) { + // Skip migration if no legacy custom model or already has providers + if (!config.customApiKey || !config.customModelApiUrl || !config.customModelName) { + return config + } + + // Skip if already has custom providers (user has already migrated or set up providers) + if (config.customProviders && config.customProviders.length > 0 && config.customProviders[0].id) { + return config + } + + // Create a provider from the legacy configuration + const legacyProvider = { + id: 'legacy_custom_' + Date.now(), + name: 'Legacy Custom Model', + baseUrl: config.customModelApiUrl, + apiKey: config.customApiKey, + active: true, + models: [ + { + name: config.customModelName, + displayName: config.customModelName, + active: true, + }, + ], + } + + return { + ...config, + customProviders: [legacyProvider], + } +} + 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 config = defaults(options, defaultConfig) + + // Run migration for legacy custom models + const migratedConfig = migrateCustomModelToProvider(config) + + // Save migrated config if changes were made + if (JSON.stringify(migratedConfig.customProviders) !== JSON.stringify(config.customProviders)) { + await setUserConfig({ customProviders: migratedConfig.customProviders }) + } + + return migratedConfig } /** diff --git a/src/popup/Popup.jsx b/src/popup/Popup.jsx index d88e93dc..36bbe4f5 100644 --- a/src/popup/Popup.jsx +++ b/src/popup/Popup.jsx @@ -18,6 +18,7 @@ import { GeneralPart } from './sections/GeneralPart' import { FeaturePages } from './sections/FeaturePages' import { AdvancedPart } from './sections/AdvancedPart' import { ModulesPart } from './sections/ModulesPart' +import { CustomProviders } from './sections/CustomProviders' // eslint-disable-next-line react/prop-types function Footer({ currentVersion, latestVersion }) { @@ -106,6 +107,7 @@ function Popup() { {t('General')} {t('Feature Pages')} {t('Modules')} + {t('Custom Providers')} {t('Advanced')} @@ -118,6 +120,9 @@ function Popup() { + + + diff --git a/src/popup/sections/CustomProviders.jsx b/src/popup/sections/CustomProviders.jsx new file mode 100644 index 00000000..b210859d --- /dev/null +++ b/src/popup/sections/CustomProviders.jsx @@ -0,0 +1,259 @@ +import { useTranslation } from 'react-i18next' +import PropTypes from 'prop-types' +import { useLayoutEffect, useState } from 'react' +import { PencilIcon, TrashIcon, PlusIcon } from '@primer/octicons-react' + +CustomProviders.propTypes = { + config: PropTypes.object.isRequired, + updateConfig: PropTypes.func.isRequired, +} + +const defaultProvider = { + id: '', + name: '', + baseUrl: 'http://localhost:8000/v1/chat/completions', + apiKey: '', + active: true, + models: [ + { + name: 'gpt-4', + displayName: 'GPT-4', + active: true, + }, + ], +} + +const defaultModel = { + name: '', + displayName: '', + active: true, +} + +export function CustomProviders({ config, updateConfig }) { + const { t } = useTranslation() + const [editing, setEditing] = useState(false) + const [editingProvider, setEditingProvider] = useState(defaultProvider) + const [editingIndex, setEditingIndex] = useState(-1) + const [providers, setProviders] = useState([]) + + useLayoutEffect(() => { + const customProviders = config.customProviders || [] + setProviders(customProviders) + }, [config.customProviders]) + + const generateProviderId = (name) => { + return name.toLowerCase().replace(/[^a-z0-9]/g, '_') + '_' + Date.now() + } + + const saveProvider = () => { + const updatedProviders = [...providers] + + if (editingIndex === -1) { + // Adding new provider + const newProvider = { + ...editingProvider, + id: editingProvider.id || generateProviderId(editingProvider.name), + } + updatedProviders.push(newProvider) + } else { + // Editing existing provider + updatedProviders[editingIndex] = editingProvider + } + + updateConfig({ customProviders: updatedProviders }) + setEditing(false) + setEditingProvider(defaultProvider) + setEditingIndex(-1) + } + + const deleteProvider = (index) => { + const updatedProviders = [...providers] + updatedProviders.splice(index, 1) + updateConfig({ customProviders: updatedProviders }) + } + + const toggleProviderActive = (index) => { + const updatedProviders = [...providers] + updatedProviders[index] = { + ...updatedProviders[index], + active: !updatedProviders[index].active, + } + updateConfig({ customProviders: updatedProviders }) + } + + const addModel = () => { + setEditingProvider({ + ...editingProvider, + models: [...editingProvider.models, { ...defaultModel }], + }) + } + + const updateModel = (modelIndex, field, value) => { + const updatedModels = [...editingProvider.models] + updatedModels[modelIndex] = { ...updatedModels[modelIndex], [field]: value } + setEditingProvider({ ...editingProvider, models: updatedModels }) + } + + const removeModel = (modelIndex) => { + const updatedModels = editingProvider.models.filter((_, index) => index !== modelIndex) + setEditingProvider({ ...editingProvider, models: updatedModels }) + } + + const editingComponent = ( +
+
+ + +
+ + setEditingProvider({ ...editingProvider, name: e.target.value })} + /> + + setEditingProvider({ ...editingProvider, baseUrl: e.target.value })} + /> + + setEditingProvider({ ...editingProvider, apiKey: e.target.value })} + /> + +
+
+ {t('Models')} + +
+ + {editingProvider.models.map((model, modelIndex) => ( +
+ updateModel(modelIndex, 'name', e.target.value)} + style={{ flex: 1 }} + /> + updateModel(modelIndex, 'displayName', e.target.value)} + style={{ flex: 1 }} + /> + + +
+ ))} +
+
+ ) + + return ( +
+

{t('Custom Providers')}

+

+ {t( + 'Manage OpenAI-compatible API providers. Each provider can have multiple models sharing the same API key and base URL.', + )} +

+ + {providers.map((provider, index) => ( +
+ {editing && editingIndex === index ? ( + editingComponent + ) : ( + + )} +
+ ))} + + {editing && editingIndex === -1 && editingComponent} + + {!editing && ( + + )} +
+ ) +} diff --git a/src/popup/sections/GeneralPart.jsx b/src/popup/sections/GeneralPart.jsx index 9af6e542..2b0858c1 100644 --- a/src/popup/sections/GeneralPart.jsx +++ b/src/popup/sections/GeneralPart.jsx @@ -105,6 +105,7 @@ export function GeneralPart({ config, updateConfig, setTabIndex }) { }, [ config.activeApiModes, config.customApiModes, + config.customProviders, config.azureDeploymentName, config.ollamaModelName, ]) diff --git a/src/services/apis/custom-api.mjs b/src/services/apis/custom-api.mjs index 002d1b96..9192a1b5 100644 --- a/src/services/apis/custom-api.mjs +++ b/src/services/apis/custom-api.mjs @@ -5,12 +5,46 @@ // and it has not yet had a negative impact on maintenance. // If necessary, I will refactor. -import { getUserConfig } from '../../config/index.mjs' +import { + getUserConfig, + getCustomProvider, + isUsingCustomProviderModel, +} 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' +/** + * Generate answers using custom provider or direct configuration + * @param {Browser.Runtime.Port} port + * @param {string} question + * @param {Session} session + */ +export async function generateAnswersWithCustomProviderApi(port, question, session) { + const config = await getUserConfig() + let apiUrl, apiKey, modelName + + // Check if using provider-based configuration + if (isUsingCustomProviderModel(session)) { + const provider = getCustomProvider(config, session.providerId) + if (!provider) { + throw new Error(`Custom provider ${session.providerId} not found`) + } + + apiUrl = provider.baseUrl?.trim() || 'http://localhost:8000/v1/chat/completions' + apiKey = provider.apiKey?.trim() || '' + modelName = session.providerModelName || 'gpt-4' + } else { + // Fallback to legacy custom model configuration + apiUrl = config.customModelApiUrl?.trim() || 'http://localhost:8000/v1/chat/completions' + apiKey = config.customApiKey?.trim() || '' + modelName = config.customModelName || 'gpt-4' + } + + return generateAnswersWithCustomApi(port, question, session, apiUrl, apiKey, modelName) +} + /** * @param {Browser.Runtime.Port} port * @param {string} question diff --git a/src/services/init-session.mjs b/src/services/init-session.mjs index 999d3165..ca200c66 100644 --- a/src/services/init-session.mjs +++ b/src/services/init-session.mjs @@ -38,6 +38,8 @@ import { t } from 'i18next' * @param {boolean|null} autoClean * @param {Object|null} apiMode * @param {string} extraCustomModelName + * @param {string|null} providerId + * @param {string|null} providerModelName * @returns {Session} */ export function initSession({ @@ -48,6 +50,8 @@ export function initSession({ autoClean = false, apiMode = null, extraCustomModelName = '', + providerId = null, + providerModelName = null, } = {}) { return { // common @@ -70,6 +74,10 @@ export function initSession({ modelName, apiMode, + // provider-based custom models + providerId, + providerModelName, + autoClean, isRetry: false, diff --git a/src/utils/model-name-convert.mjs b/src/utils/model-name-convert.mjs index 3f206232..e1a1cb5d 100644 --- a/src/utils/model-name-convert.mjs +++ b/src/utils/model-name-convert.mjs @@ -109,10 +109,36 @@ export function getApiModesFromConfig(config, onlyActive) { return modelNameToApiMode(modelName) }) .filter((apiMode) => apiMode) - return [ - ...originalApiModes, - ...config.customApiModes.filter((apiMode) => (onlyActive ? apiMode.active : true)), - ] + + // Add custom API modes + const customApiModes = config.customApiModes.filter((apiMode) => + onlyActive ? apiMode.active : true, + ) + + // Add provider models as API modes + const providerApiModes = [] + if (config.customProviders) { + for (const provider of config.customProviders) { + if (!onlyActive || provider.active) { + const activeModels = provider.models?.filter((model) => !onlyActive || model.active) || [] + for (const model of activeModels) { + providerApiModes.push({ + groupName: 'customProviderModel', + itemName: `${provider.id}_${model.name}`, + isCustom: true, + customName: model.displayName || model.name, + customUrl: provider.baseUrl, + apiKey: provider.apiKey, + active: provider.active && model.active, + providerId: provider.id, + providerModelName: model.name, + }) + } + } + } + } + + return [...originalApiModes, ...customApiModes, ...providerApiModes] } export function getApiModesStringArrayFromConfig(config, onlyActive) {