Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions docs/technical/ai-providers.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AI 供应商系统

> Last updated: 2026-03
> Last updated: 2026-07

## 概述

Expand Down Expand Up @@ -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 | 无 | 无 | 展示、发现新模型 |

#### 关键约束
Expand All @@ -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 规则 |
Expand Down Expand Up @@ -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` | 仅在缺失时填充 | 保留现有分类 |

Expand All @@ -193,7 +194,8 @@ Provider / Model 控制面目前有多个来源:provider definition、本地

关键决策:
- 本地模型配置优先于远程配置(保留用户自定义)
- 注册表富化**覆写** capabilities/contextWindow(更权威)
- 注册表富化默认覆写 capabilities/contextWindow/maxOutput;只有模型带有对应 override 标记时才保留显式配置
- 清空手动输入的 context/maxOutput 会移除对应 override,恢复 registry 默认值
- **仅在 provider API 成功时**才追加发现的新模型(避免在 fallback 模式下引入未经验证的模型)

### 新模型发现
Expand Down
34 changes: 32 additions & 2 deletions src/renderer/modals/ModelEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderModelInfo['type']>(props.model?.type || 'chat')
const [contextWindow, setContextWindow] = useState<number | undefined>(props.model?.contextWindow)
const [contextWindowOverride, setContextWindowOverride] = useState(props.model?.contextWindowOverride || false)
const [maxOutput, setMaxOutput] = useState<number | undefined>(props.model?.maxOutput)
const [maxOutputOverride, setMaxOutputOverride] = useState(props.model?.maxOutputOverride || false)
const [testState, setTestState] = useState<ModelTestState>({
testing: false,
})
Expand All @@ -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])

Expand All @@ -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']))
}
},
Expand All @@ -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()
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -194,7 +208,15 @@ const ModelEdit = NiceModal.create((props: { model?: ProviderModelInfo; provider
<NumberInput
placeholder={String(t('e.g. 128000'))}
value={contextWindow}
onChange={(value) => 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}
Expand All @@ -207,7 +229,15 @@ const ModelEdit = NiceModal.create((props: { model?: ProviderModelInfo; provider
<NumberInput
placeholder={String(t('e.g. 4096'))}
value={maxOutput}
onChange={(value) => 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}
Expand Down
124 changes: 124 additions & 0 deletions src/renderer/packages/model-registry/enrich.test.ts
Original file line number Diff line number Diff line change
@@ -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<ModelRegistryData>(() => ({
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'])
})
})
23 changes: 19 additions & 4 deletions src/renderer/packages/model-registry/enrich.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
Expand Down
22 changes: 12 additions & 10 deletions src/renderer/routes/settings/provider/$providerId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
}

Expand All @@ -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),
})
}

Expand Down Expand Up @@ -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
),
})
Expand Down Expand Up @@ -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) })}
/>
</AdaptiveModal>

Expand Down
Loading