diff --git a/docs/technical/ai-providers.md b/docs/technical/ai-providers.md index 841384887a..e91031b161 100644 --- a/docs/technical/ai-providers.md +++ b/docs/technical/ai-providers.md @@ -1,6 +1,6 @@ # AI 供应商系统 -> Last updated: 2026-03 +> Last updated: 2026-07 ## 概述 @@ -79,7 +79,8 @@ Provider / Model 控制面目前有多个来源:provider definition、本地 | `apiHost` / `apiPath` | provider settings | provider defaults | 无 | 请求路由 | | effective API key | desktop OAuth token | provider settings `apiKey` | 空字符串 | 请求鉴权 | | model list | user-saved models | backend remote manifest / provider API | curated registry fallback | 设置页模型选择 | -| runtime capability / context / maxOutput | models.dev registry(覆写) | provider defaults / provider API / user-saved model info | 无 | 运行时行为与 UI feature gate | +| runtime capability | explicit model capability override | models.dev registry | provider defaults / provider API | 运行时行为与 UI feature gate | +| context / maxOutput | explicit model numeric override | models.dev registry | provider defaults / provider API / user-saved model info | 上下文与输出上限展示和运行时约束 | | release date / status / family | models.dev registry | 无 | 无 | 展示、发现新模型 | #### 关键约束 @@ -95,7 +96,7 @@ Provider / Model 控制面目前有多个来源:provider definition、本地 | `ProviderDefinition` | provider contract、默认配置、实例工厂、`modelsDevProviderId` 声明 | 不直接决定消息流或 UI 交互细节 | | provider ID mapping (`provider-mapping.ts`) | Chatbox ↔ models.dev ID 映射关系(单一数据源) | 不决定 provider 注册或 UI 路由 | | OAuth mapping / credential manager | 凭证来源、刷新、共享规则 | 不决定模型选择或 capability | -| models.dev registry | 模型元数据权威来源、capability 覆写、fallback 模型列表、新模型发现 | 不负责全局 provider 注册 | +| models.dev registry | 模型元数据权威来源、默认 capability、context/maxOutput 覆写、fallback 模型列表、新模型发现 | 不负责全局 provider 注册,不覆盖带有显式 override 标记的 capability/context/maxOutput 配置 | | registry 缓存层 (`fetch.ts`) | 多级缓存(内存→Blob→快照)、fetch 去重、订阅通知 | 不决定富化策略 | | provider API / user config | 当前 endpoint 的模型配置与运行约束 | 不负责全局 provider 注册 | | model class (`OpenAI` / `Claude` / ...) | 发请求、收响应、适配具体协议 | 不维护全局 precedence 规则 | @@ -168,9 +169,9 @@ Provider / Model 控制面目前有多个来源:provider definition、本地 | 字段 | 策略 | 原因 | |------|------|------| -| `capabilities` | registry **覆写** | 事实数据,registry 更权威 | -| `contextWindow` | registry **覆写** | 事实数据,registry 更权威 | -| `maxOutput` | registry **覆写** | 事实数据,registry 更权威 | +| `capabilities` | 默认 registry **覆写**;模型带有 `capabilitiesOverride` 时保留显式值 | 用户可能接入兼容端点、代理或私有部署,能力不一定等同于公开模型 | +| `contextWindow` | 默认 registry **覆写**;模型带有 `contextWindowOverride` 且值为正数时保留显式值 | 用户可能接入兼容端点、代理或私有部署,真实上下文不一定等同于公开模型 | +| `maxOutput` | 默认 registry **覆写**;模型带有 `maxOutputOverride` 且值为正数时保留显式值 | 用户可能接入兼容端点、代理或私有部署,真实输出上限不一定等同于公开模型 | | `nickname` | 仅在缺失时填充 | 用户可能已自定义 | | `type` | 仅在缺失时填充 | 保留现有分类 | @@ -193,7 +194,8 @@ Provider / Model 控制面目前有多个来源:provider definition、本地 关键决策: - 本地模型配置优先于远程配置(保留用户自定义) -- 注册表富化**覆写** capabilities/contextWindow(更权威) +- 注册表富化默认覆写 capabilities/contextWindow/maxOutput;只有模型带有对应 override 标记时才保留显式配置 +- 清空手动输入的 context/maxOutput 会移除对应 override,恢复 registry 默认值 - **仅在 provider API 成功时**才追加发现的新模型(避免在 fallback 模式下引入未经验证的模型) ### 新模型发现 diff --git a/src/renderer/modals/ModelEdit.tsx b/src/renderer/modals/ModelEdit.tsx index 6cf236e522..ff66aa64f8 100644 --- a/src/renderer/modals/ModelEdit.tsx +++ b/src/renderer/modals/ModelEdit.tsx @@ -19,9 +19,12 @@ const ModelEdit = NiceModal.create((props: { model?: ProviderModelInfo; provider const [modelId, setModelId] = useState(props.model?.modelId || '') const [nickname, setNickname] = useState(props.model?.nickname || '') const [capabilities, setCapabilities] = useState(props.model?.capabilities || []) + const [capabilitiesOverride, setCapabilitiesOverride] = useState(props.model?.capabilitiesOverride || false) const [type, setType] = useState(props.model?.type || 'chat') const [contextWindow, setContextWindow] = useState(props.model?.contextWindow) + const [contextWindowOverride, setContextWindowOverride] = useState(props.model?.contextWindowOverride || false) const [maxOutput, setMaxOutput] = useState(props.model?.maxOutput) + const [maxOutputOverride, setMaxOutputOverride] = useState(props.model?.maxOutputOverride || false) const [testState, setTestState] = useState({ testing: false, }) @@ -37,9 +40,12 @@ const ModelEdit = NiceModal.create((props: { model?: ProviderModelInfo; provider setModelId(props.model?.modelId || '') setNickname(props.model?.nickname || '') setCapabilities(props.model?.capabilities || []) + setCapabilitiesOverride(props.model?.capabilitiesOverride || false) setType(props.model?.type || 'chat') setContextWindow(props.model?.contextWindow) + setContextWindowOverride(props.model?.contextWindowOverride || false) setMaxOutput(props.model?.maxOutput) + setMaxOutputOverride(props.model?.maxOutputOverride || false) setTestState({ testing: false }) }, [props]) @@ -60,9 +66,11 @@ const ModelEdit = NiceModal.create((props: { model?: ProviderModelInfo; provider // Auto-enable capabilities based on test results if (state.visionTest?.status === 'success') { + setCapabilitiesOverride(true) setCapabilities((prev = []) => (prev.includes('vision') ? prev : [...prev, 'vision'])) } if (state.toolTest?.status === 'success') { + setCapabilitiesOverride(true) setCapabilities((prev = []) => (prev.includes('tool_use') ? prev : [...prev, 'tool_use'])) } }, @@ -80,8 +88,11 @@ const ModelEdit = NiceModal.create((props: { model?: ProviderModelInfo; provider type, nickname: nickname || undefined, capabilities, + capabilitiesOverride: capabilitiesOverride || undefined, contextWindow, + contextWindowOverride: (contextWindowOverride && contextWindow !== undefined) || undefined, maxOutput, + maxOutputOverride: (maxOutputOverride && maxOutput !== undefined) || undefined, }) modal.hide() } @@ -148,6 +159,7 @@ const ModelEdit = NiceModal.create((props: { model?: ProviderModelInfo; provider checked={capabilities?.includes('vision')} onChange={(e) => { const checked = e.currentTarget.checked + setCapabilitiesOverride(true) if (checked) { setCapabilities([...(capabilities || []), 'vision']) } else { @@ -161,6 +173,7 @@ const ModelEdit = NiceModal.create((props: { model?: ProviderModelInfo; provider checked={capabilities?.includes('reasoning')} onChange={(e) => { const checked = e.currentTarget.checked + setCapabilitiesOverride(true) if (checked) { setCapabilities([...(capabilities || []), 'reasoning']) } else { @@ -174,6 +187,7 @@ const ModelEdit = NiceModal.create((props: { model?: ProviderModelInfo; provider checked={capabilities?.includes('tool_use')} onChange={(e) => { const checked = e.currentTarget.checked + setCapabilitiesOverride(true) if (checked) { setCapabilities([...(capabilities || []), 'tool_use']) } else { @@ -194,7 +208,15 @@ const ModelEdit = NiceModal.create((props: { model?: ProviderModelInfo; provider setContextWindow(typeof value === 'number' ? value : undefined)} + onChange={(value) => { + if (typeof value === 'number') { + setContextWindowOverride(true) + setContextWindow(value) + } else { + setContextWindowOverride(false) + setContextWindow(undefined) + } + }} min={1} max={10_000_000} step={1000} @@ -207,7 +229,15 @@ const ModelEdit = NiceModal.create((props: { model?: ProviderModelInfo; provider setMaxOutput(typeof value === 'number' ? value : undefined)} + onChange={(value) => { + if (typeof value === 'number') { + setMaxOutputOverride(true) + setMaxOutput(value) + } else { + setMaxOutputOverride(false) + setMaxOutput(undefined) + } + }} min={1} max={1_000_000} step={100} diff --git a/src/renderer/packages/model-registry/enrich.test.ts b/src/renderer/packages/model-registry/enrich.test.ts new file mode 100644 index 0000000000..1b7d7d748b --- /dev/null +++ b/src/renderer/packages/model-registry/enrich.test.ts @@ -0,0 +1,124 @@ +import type { ModelRegistryData } from '@shared/model-registry/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { enrichModelsFromRegistry } from './enrich' + +const registry = vi.hoisted(() => ({ + test: { + 'known-model': { + modelId: 'known-model', + name: 'Known Model', + type: 'chat', + capabilities: ['vision', 'tool_use'], + contextWindow: 200_000, + maxOutput: 32_000, + }, + }, +})) + +const getRegistrySync = vi.hoisted(() => vi.fn(() => registry)) + +vi.mock('./fetch', () => ({ + getRegistrySync, +})) + +describe('enrichModelsFromRegistry', () => { + beforeEach(() => { + getRegistrySync.mockClear() + }) + + it('fills missing metadata from the registry by default', () => { + const [model] = enrichModelsFromRegistry([{ modelId: 'known-model' }], 'test') + + expect(model.capabilities).toEqual(['vision', 'tool_use']) + expect(model.contextWindow).toBe(200_000) + expect(model.maxOutput).toBe(32_000) + }) + + it('overwrites existing metadata by default', () => { + const [model] = enrichModelsFromRegistry( + [ + { + modelId: 'known-model', + capabilities: [], + contextWindow: 100, + maxOutput: 10, + }, + ], + 'test' + ) + + expect(model.capabilities).toEqual(['vision', 'tool_use']) + expect(model.contextWindow).toBe(200_000) + expect(model.maxOutput).toBe(32_000) + }) + + it('keeps explicit user capabilities when marked as an override', () => { + const [model] = enrichModelsFromRegistry( + [ + { + modelId: 'known-model', + capabilities: [], + capabilitiesOverride: true, + contextWindow: 100, + maxOutput: 10, + }, + ], + 'test' + ) + + expect(model.capabilities).toEqual([]) + expect(model.contextWindow).toBe(200_000) + expect(model.maxOutput).toBe(32_000) + }) + + it('keeps explicit context and output limits when marked as overrides', () => { + const [model] = enrichModelsFromRegistry( + [ + { + modelId: 'known-model', + contextWindow: 1_050_000, + contextWindowOverride: true, + maxOutput: 128_000, + maxOutputOverride: true, + }, + ], + 'test' + ) + + expect(model.contextWindow).toBe(1_050_000) + expect(model.maxOutput).toBe(128_000) + }) + + it('ignores non-positive numeric overrides', () => { + const [model] = enrichModelsFromRegistry( + [ + { + modelId: 'known-model', + contextWindow: 0, + contextWindowOverride: true, + maxOutput: -1, + maxOutputOverride: true, + }, + ], + 'test' + ) + + expect(model.contextWindow).toBe(200_000) + expect(model.maxOutput).toBe(32_000) + }) + + it('does not treat saved capabilities as an override without the override marker', () => { + const [model] = enrichModelsFromRegistry( + [ + { + modelId: 'known-model', + capabilities: [], + capabilitiesOverride: false, + }, + ], + 'test' + ) + + expect(model.capabilities).toEqual(['vision', 'tool_use']) + }) +}) diff --git a/src/renderer/packages/model-registry/enrich.ts b/src/renderer/packages/model-registry/enrich.ts index 64c0cfab09..9b40bc938e 100644 --- a/src/renderer/packages/model-registry/enrich.ts +++ b/src/renderer/packages/model-registry/enrich.ts @@ -8,8 +8,10 @@ import { getRegistrySync } from './fetch' * Replaces the backend API call to getProviderModelsInfo. * * Enrichment strategy: - * - capabilities, contextWindow, maxOutput: registry data OVERWRITES existing values + * - capabilities, contextWindow, maxOutput: registry data OVERWRITES existing values by default * (these are factual data, registry is more authoritative and up-to-date) + * - when a model has capabilitiesOverride, existing capabilities are treated as user overrides + * - when a model has contextWindowOverride or maxOutputOverride, those numeric limits are treated as user overrides * - nickname: only filled when missing (user may have customized it) * - type: only filled when missing (embedding/rerank may be set by provider definition) * - labels: only filled when missing @@ -25,12 +27,25 @@ export function enrichModelsFromRegistry(models: ProviderModelInfo[], chatboxPro return models.map((model) => { const meta = findModelInRegistry(model.modelId, providerRegistry) if (!meta) return model + const shouldPreserveCapabilities = model.capabilitiesOverride === true && model.capabilities !== undefined + const shouldPreserveContextWindow = + model.contextWindowOverride === true && typeof model.contextWindow === 'number' && model.contextWindow > 0 + const shouldPreserveMaxOutput = + model.maxOutputOverride === true && typeof model.maxOutput === 'number' && model.maxOutput > 0 return { ...model, - capabilities: meta.capabilities.length > 0 ? meta.capabilities : model.capabilities, - contextWindow: meta.contextWindow > 0 ? meta.contextWindow : model.contextWindow, - maxOutput: meta.maxOutput > 0 ? meta.maxOutput : model.maxOutput, + capabilities: shouldPreserveCapabilities + ? model.capabilities + : meta.capabilities.length > 0 + ? [...meta.capabilities] + : model.capabilities, + contextWindow: shouldPreserveContextWindow + ? model.contextWindow + : meta.contextWindow > 0 + ? meta.contextWindow + : model.contextWindow, + maxOutput: shouldPreserveMaxOutput ? model.maxOutput : meta.maxOutput > 0 ? meta.maxOutput : model.maxOutput, nickname: model.nickname || meta.name, type: model.type || meta.type, } diff --git a/src/renderer/routes/settings/provider/$providerId.tsx b/src/renderer/routes/settings/provider/$providerId.tsx index f4c685247f..c17845c8d5 100644 --- a/src/renderer/routes/settings/provider/$providerId.tsx +++ b/src/renderer/routes/settings/provider/$providerId.tsx @@ -326,13 +326,13 @@ function ProviderSettings({ providerId }: { providerId: string }) { return } - if (displayModels?.find((m) => m.modelId === newModel.modelId)) { + if (rawModels?.find((m) => m.modelId === newModel.modelId)) { addToast(t('already existed')) return } setProviderSettings({ - models: [...displayModels, newModel], + models: [...rawModels, newModel], }) } @@ -343,13 +343,13 @@ function ProviderSettings({ providerId }: { providerId: string }) { } setProviderSettings({ - models: displayModels.map((m) => (m.modelId === newModel.modelId ? newModel : m)), + models: rawModels.map((m) => (m.modelId === newModel.modelId ? newModel : m)), }) } const deleteModel = (modelId: string) => { setProviderSettings({ - models: displayModels.filter((m) => m.modelId !== modelId), + models: rawModels.filter((m) => m.modelId !== modelId), }) } @@ -460,9 +460,13 @@ function ProviderSettings({ providerId }: { providerId: string }) { if (visionSupported) capabilitiesToAdd.push('vision') if (toolUseSupported) capabilitiesToAdd.push('tool_use') setProviderSettings({ - models: displayModels.map((m) => + models: rawModels.map((m) => m.modelId === model.modelId - ? { ...m, capabilities: uniq([...(m.capabilities || []), ...capabilitiesToAdd]) } + ? { + ...m, + capabilities: uniq([...(model.capabilities || []), ...capabilitiesToAdd]), + capabilitiesOverride: true, + } : m ), }) @@ -992,10 +996,8 @@ function ProviderSettings({ providerId }: { providerId: string }) { showActions={true} showSearch={true} displayedModelIds={displayModels.map((m) => m.modelId)} - onAddModel={(model) => setProviderSettings({ models: [...displayModels, model] })} - onRemoveModel={(modelId) => - setProviderSettings({ models: displayModels.filter((m) => m.modelId !== modelId) }) - } + onAddModel={(model) => setProviderSettings({ models: [...rawModels, model] })} + onRemoveModel={(modelId) => setProviderSettings({ models: rawModels.filter((m) => m.modelId !== modelId) })} /> diff --git a/src/shared/model-registry/enrich.test.ts b/src/shared/model-registry/enrich.test.ts new file mode 100644 index 0000000000..ab322d8f97 --- /dev/null +++ b/src/shared/model-registry/enrich.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { enrichModelFromRegistry, setRuntimeRegistry } from './enrich' +import type { ModelRegistryData } from './types' + +const registry: ModelRegistryData = { + test: { + 'known-model': { + modelId: 'known-model', + name: 'Known Model', + type: 'chat', + capabilities: ['vision', 'tool_use'], + contextWindow: 200_000, + maxOutput: 32_000, + }, + }, +} + +describe('enrichModelFromRegistry', () => { + beforeEach(() => { + setRuntimeRegistry(registry) + }) + + it('fills missing metadata from the registry by default', () => { + const model = enrichModelFromRegistry({ modelId: 'known-model' }, 'test') + + expect(model.capabilities).toEqual(['vision', 'tool_use']) + expect(model.contextWindow).toBe(200_000) + expect(model.maxOutput).toBe(32_000) + }) + + it('overwrites existing metadata by default', () => { + const model = enrichModelFromRegistry( + { + modelId: 'known-model', + capabilities: [], + contextWindow: 100, + maxOutput: 10, + }, + 'test' + ) + + expect(model.capabilities).toEqual(['vision', 'tool_use']) + expect(model.contextWindow).toBe(200_000) + expect(model.maxOutput).toBe(32_000) + }) + + it('keeps explicit user capabilities when marked as an override', () => { + const model = enrichModelFromRegistry( + { + modelId: 'known-model', + capabilities: [], + capabilitiesOverride: true, + contextWindow: 100, + maxOutput: 10, + }, + 'test' + ) + + expect(model.capabilities).toEqual([]) + expect(model.contextWindow).toBe(200_000) + expect(model.maxOutput).toBe(32_000) + }) + + it('keeps explicit context and output limits when marked as overrides', () => { + const model = enrichModelFromRegistry( + { + modelId: 'known-model', + contextWindow: 1_050_000, + contextWindowOverride: true, + maxOutput: 128_000, + maxOutputOverride: true, + }, + 'test' + ) + + expect(model.contextWindow).toBe(1_050_000) + expect(model.maxOutput).toBe(128_000) + }) + + it('ignores non-positive numeric overrides', () => { + const model = enrichModelFromRegistry( + { + modelId: 'known-model', + contextWindow: 0, + contextWindowOverride: true, + maxOutput: -1, + maxOutputOverride: true, + }, + 'test' + ) + + expect(model.contextWindow).toBe(200_000) + expect(model.maxOutput).toBe(32_000) + }) + + it('does not treat saved capabilities as an override without the override marker', () => { + const model = enrichModelFromRegistry( + { + modelId: 'known-model', + capabilities: [], + capabilitiesOverride: false, + }, + 'test' + ) + + expect(model.capabilities).toEqual(['vision', 'tool_use']) + }) +}) diff --git a/src/shared/model-registry/enrich.ts b/src/shared/model-registry/enrich.ts index b660da9f1a..6659655f39 100644 --- a/src/shared/model-registry/enrich.ts +++ b/src/shared/model-registry/enrich.ts @@ -66,8 +66,10 @@ export function findModelInRegistry(modelId: string, registry: ProviderModelRegi * Used by getModelConfig() to ensure model instances have correct capabilities. * * Enrichment strategy: - * - capabilities, contextWindow, maxOutput: registry data OVERWRITES existing values + * - capabilities, contextWindow, maxOutput: registry data OVERWRITES existing values by default * (these are factual data, registry is more authoritative and up-to-date) + * - when a model has capabilitiesOverride, existing capabilities are treated as user overrides + * - when a model has contextWindowOverride or maxOutputOverride, those numeric limits are treated as user overrides * - nickname: only filled when missing (user may have customized it) * - type: only filled when missing */ @@ -81,12 +83,25 @@ export function enrichModelFromRegistry 0 + const shouldPreserveMaxOutput = + model.maxOutputOverride === true && typeof model.maxOutput === 'number' && model.maxOutput > 0 return { ...model, - capabilities: meta.capabilities.length > 0 ? meta.capabilities : model.capabilities, - contextWindow: meta.contextWindow > 0 ? meta.contextWindow : model.contextWindow, - maxOutput: meta.maxOutput > 0 ? meta.maxOutput : model.maxOutput, + capabilities: shouldPreserveCapabilities + ? model.capabilities + : meta.capabilities.length > 0 + ? [...meta.capabilities] + : model.capabilities, + contextWindow: shouldPreserveContextWindow + ? model.contextWindow + : meta.contextWindow > 0 + ? meta.contextWindow + : model.contextWindow, + maxOutput: shouldPreserveMaxOutput ? model.maxOutput : meta.maxOutput > 0 ? meta.maxOutput : model.maxOutput, nickname: model.nickname || meta.name, type: model.type || meta.type, } diff --git a/src/shared/providers/index.ts b/src/shared/providers/index.ts index 06742110e7..7c246f9579 100644 --- a/src/shared/providers/index.ts +++ b/src/shared/providers/index.ts @@ -1,7 +1,7 @@ -import type { ModelInterface } from '../models/types' import { enrichModelFromRegistry } from '../model-registry/enrich' +import type { ModelInterface } from '../models/types' import { mergeSharedOAuthProviderSettings, resolveEffectiveApiKey } from '../oauth' -import type { Config, ProviderModelInfo, ProviderSettings, SessionSettings, Settings } from '../types' +import type { Config, ProviderModelInfo, SessionSettings, Settings } from '../types' import type { ModelDependencies } from '../types/adapters' // ChatboxAI must be imported first to ensure it appears at the top of provider lists // Import order determines display order in UI (side-effect registration into Map) @@ -98,7 +98,8 @@ export function getProviderSettings(setting: SessionSettings, globalSettings: Se function getModelConfig(settings: SessionSettings, globalSettings: Settings, provider: string): ProviderModelInfo { const providerSetting = globalSettings.providers?.[provider] || {} - let model = providerSetting.models?.find((m) => m.modelId === settings.modelId) + const userSavedModel = providerSetting.models?.find((m) => m.modelId === settings.modelId) + let model = userSavedModel if (!model) { model = getSystemProviders() .find((p) => p.id === provider) diff --git a/src/shared/types/settings.ts b/src/shared/types/settings.ts index 5b809c9973..65a7483a6c 100644 --- a/src/shared/types/settings.ts +++ b/src/shared/types/settings.ts @@ -41,8 +41,11 @@ export const ProviderModelInfoSchema = z.object({ .array(z.enum(['vision', 'reasoning', 'tool_use', 'web_search'])) .optional() .catch([]), + capabilitiesOverride: z.boolean().optional().catch(undefined), contextWindow: z.number().optional().catch(undefined), + contextWindowOverride: z.boolean().optional().catch(undefined), maxOutput: z.number().optional().catch(undefined), + maxOutputOverride: z.boolean().optional().catch(undefined), }) export const OAuthCredentialsSchema = z.object({