fix(desktop): support 1M provider context declarations (#814)

Add per-model 1M capability flags for custom providers and persist them through provider settings.
When a model is marked as 1M-capable, runtime env uses the local [1m] marker and the desktop form auto-fills a 1000000-token context window when needed.

Tested: bun test src/server/__tests__/providers.test.ts -t "custom providers can mark main and role models as 1M-capable"
Tested: cd desktop && bun run test -- generalSettings.test.tsx -t "normalizes blank model mappings|saves 1M model declarations"
Tested: bun run check:desktop
Tested: bun run check:server
Not-tested: bun run verify / coverage gates were not run for this scoped local handoff.
Confidence: high
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-16 13:58:53 +08:00
parent 8813dff5b1
commit 54a8f3107c
12 changed files with 398 additions and 38 deletions

View File

@ -1650,6 +1650,10 @@ describe('Settings > Providers tab', () => {
fireEvent.click(screen.getByRole('button', { name: /Add Provider|添加服务商/i }))
const dialog = screen.getByRole('dialog')
await waitFor(() => {
const settingsTextarea = dialog.querySelector('textarea')
expect(settingsTextarea?.value).toContain('"ANTHROPIC_MODEL"')
})
fireEvent.change(within(dialog).getByPlaceholderText('sk-...'), { target: { value: 'sk-test' } })
fireEvent.change(within(dialog).getByLabelText(/Main Model|主模型/i), { target: { value: 'gpt-5.5' } })
fireEvent.click(within(dialog).getByRole('button', { name: /Save|Add|保存|添加/i }))
@ -1737,6 +1741,88 @@ describe('Settings > Providers tab', () => {
}))
})
it('saves 1M model declarations for the main and role mappings', async () => {
providerStoreState.createProvider = vi.fn().mockResolvedValue({
id: 'provider-new',
presetId: 'custom',
name: 'Custom',
apiKey: 'sk-test',
baseUrl: 'https://api.example.com/anthropic',
apiFormat: 'anthropic',
models: {
main: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5',
sonnet: 'claude-sonnet-4-6',
opus: 'claude-opus-4-7',
},
model1mSupport: {
main: true,
haiku: false,
sonnet: true,
opus: false,
},
})
providerStoreState.presets = [
{
id: 'custom',
name: 'Custom',
baseUrl: 'https://api.example.com/anthropic',
apiFormat: 'anthropic',
defaultModels: {
main: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5',
sonnet: 'claude-sonnet-4-6',
opus: 'claude-opus-4-7',
},
needsApiKey: true,
websiteUrl: '',
},
]
render(<Settings />)
fireEvent.click(screen.getByRole('button', { name: /Add Provider|添加服务商/i }))
const dialog = screen.getByRole('dialog')
await waitFor(() => {
const settingsTextarea = dialog.querySelector('textarea')
expect(settingsTextarea?.value).toContain('"ANTHROPIC_MODEL"')
})
fireEvent.change(within(dialog).getByPlaceholderText('sk-...'), { target: { value: 'sk-test' } })
fireEvent.click(within(dialog).getByRole('checkbox', { name: /1M support: main/i }))
fireEvent.click(within(dialog).getByRole('checkbox', { name: /1M support: sonnet/i }))
await waitFor(() => {
const settingsTextarea = dialog.querySelector('textarea')
expect(settingsTextarea?.value).toContain('"ANTHROPIC_MODEL": "claude-sonnet-4-6[1m]"')
expect(settingsTextarea?.value).toContain('"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6[1m]"')
expect(settingsTextarea?.value).toContain('"CLAUDE_CODE_MODEL_CONTEXT_WINDOWS"')
expect(settingsTextarea?.value).toContain('\\"claude-sonnet-4-6\\":1000000')
})
fireEvent.click(within(dialog).getByRole('button', { name: /Save|Add|保存|添加/i }))
await waitFor(() => {
expect(providerStoreState.createProvider).toHaveBeenCalledWith(expect.objectContaining({
model1mSupport: {
main: true,
haiku: false,
sonnet: true,
opus: false,
},
modelContextWindows: {
'claude-sonnet-4-6': 1000000,
},
}))
})
expect(MOCK_UPDATE_SETTINGS).toHaveBeenCalledWith(expect.objectContaining({
env: expect.objectContaining({
ANTHROPIC_MODEL: 'claude-sonnet-4-6[1m]',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
CLAUDE_CODE_MODEL_CONTEXT_WINDOWS: '{"claude-sonnet-4-6":1000000}',
}),
}))
})
it('hides the API key by default and reveals it from the eye button', () => {
providerStoreState.presets = [
{

View File

@ -415,6 +415,7 @@ export const en = {
'settings.providers.haikuModel': 'Haiku Model',
'settings.providers.sonnetModel': 'Sonnet Model',
'settings.providers.opusModel': 'Opus Model',
'settings.providers.model1mSupportShort': '1M',
'settings.providers.sameAsMain': 'Same as main',
'settings.providers.contextSettingsTitle': 'Context and auto-compact',
'settings.providers.contextSettingsDesc': 'Preset limits are applied automatically. Edit only when the provider changes limits or you use a custom model ID.',

View File

@ -417,6 +417,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.providers.haikuModel': 'Haiku モデル',
'settings.providers.sonnetModel': 'Sonnet モデル',
'settings.providers.opusModel': 'Opus モデル',
'settings.providers.model1mSupportShort': '1M',
'settings.providers.sameAsMain': 'メインと同じ',
'settings.providers.contextSettingsTitle': 'コンテキストと自動コンパクト',
'settings.providers.contextSettingsDesc': 'プリセットの上限は自動的に適用されます。プロバイダーが上限を変更した場合やカスタムモデル ID を使用する場合のみ編集してください。',

View File

@ -417,6 +417,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.providers.haikuModel': 'Haiku 모델',
'settings.providers.sonnetModel': 'Sonnet 모델',
'settings.providers.opusModel': 'Opus 모델',
'settings.providers.model1mSupportShort': '1M',
'settings.providers.sameAsMain': '메인과 동일',
'settings.providers.contextSettingsTitle': '컨텍스트 및 자동 압축',
'settings.providers.contextSettingsDesc': '사전 설정 한도가 자동으로 적용됩니다. 공급자가 한도를 변경하거나 사용자 지정 모델 ID를 사용할 때만 편집하세요.',

View File

@ -417,6 +417,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.haikuModel': 'Haiku 模型',
'settings.providers.sonnetModel': 'Sonnet 模型',
'settings.providers.opusModel': 'Opus 模型',
'settings.providers.model1mSupportShort': '1M',
'settings.providers.sameAsMain': '與主模型相同',
'settings.providers.contextSettingsTitle': '上下文與自動壓縮',
'settings.providers.contextSettingsDesc': '預設會自動應用模型視窗。只有服務商調整限制,或使用自定義模型 ID 時才需要修改。',

View File

@ -417,6 +417,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.haikuModel': 'Haiku 模型',
'settings.providers.sonnetModel': 'Sonnet 模型',
'settings.providers.opusModel': 'Opus 模型',
'settings.providers.model1mSupportShort': '1M',
'settings.providers.sameAsMain': '与主模型相同',
'settings.providers.contextSettingsTitle': '上下文与自动压缩',
'settings.providers.contextSettingsDesc': '预设会自动应用模型窗口。只有服务商调整限制,或使用自定义模型 ID 时才需要修改。',

View File

@ -28,7 +28,7 @@ import { Button } from '../components/shared/Button'
import { Dropdown } from '../components/shared/Dropdown'
import type { ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior, OutputStyleSource } from '../types/settings'
import type { Locale } from '../i18n'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, Model1mSupport, ApiFormat, ProviderAuthStrategy } from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'
import { AdapterSettings } from './AdapterSettings'
import { useAgentStore } from '../stores/agentStore'
@ -705,7 +705,14 @@ const AUTO_COMPACT_WINDOW_ENV_KEY = 'CLAUDE_CODE_AUTO_COMPACT_WINDOW'
const MODEL_CONTEXT_WINDOWS_ENV_KEY = 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS'
const MODEL_CONTEXT_WINDOW_MIN = 16000
const MODEL_CONTEXT_WINDOW_MAX = 10000000
const MODEL_1M_CONTEXT_WINDOW = 1000000
const MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const
const DEFAULT_MODEL_1M_SUPPORT: Model1mSupport = {
main: false,
haiku: false,
sonnet: false,
opus: false,
}
const DEFAULT_PROVIDER_AUTH_STRATEGY: ProviderAuthStrategy = 'auth_token'
const AUTH_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'])
type ModelSlot = typeof MODEL_SLOTS[number]
@ -835,6 +842,81 @@ function buildModelContextWindows(
return windows
}
function hasModel1mMarker(model: string): boolean {
return /\[1m\]$/i.test(model.trim()) || /:1m$/i.test(model.trim())
}
function stripModel1mMarker(model: string): string {
return model.trim().replace(/\[1m\]$/i, '').replace(/:1m$/i, '').trim()
}
function stripModel1mMarkers(models: ModelMapping): ModelMapping {
return {
main: stripModel1mMarker(models.main),
haiku: stripModel1mMarker(models.haiku),
sonnet: stripModel1mMarker(models.sonnet),
opus: stripModel1mMarker(models.opus),
}
}
function getInitialModel1mSupport(
models: ModelMapping,
provider?: SavedProvider,
): Model1mSupport {
return {
main: provider?.model1mSupport?.main === true || hasModel1mMarker(models.main),
haiku: provider?.model1mSupport?.haiku === true || hasModel1mMarker(models.haiku),
sonnet: provider?.model1mSupport?.sonnet === true || hasModel1mMarker(models.sonnet),
opus: provider?.model1mSupport?.opus === true || hasModel1mMarker(models.opus),
}
}
function applyModel1mSupport(model: string, enabled: boolean): string {
const stripped = stripModel1mMarker(model)
return enabled && stripped ? `${stripped}[1m]` : stripped
}
function applyModel1mSupportMapping(
models: ModelMapping,
model1mSupport: Model1mSupport,
): ModelMapping {
return {
main: applyModel1mSupport(models.main, model1mSupport.main),
haiku: applyModel1mSupport(models.haiku, model1mSupport.haiku),
sonnet: applyModel1mSupport(models.sonnet, model1mSupport.sonnet),
opus: applyModel1mSupport(models.opus, model1mSupport.opus),
}
}
function hasAnyModel1mSupport(model1mSupport: Model1mSupport): boolean {
return MODEL_SLOTS.some((slot) => model1mSupport[slot])
}
function shouldFill1mContextWindow(value: string): boolean {
const parsed = parseModelContextWindowsInput(value)
return parsed === undefined || parsed < MODEL_1M_CONTEXT_WINDOW
}
function apply1mSupportToContextInput(
inputs: ModelContextInputs,
slot: ModelSlot,
enabled: boolean,
): ModelContextInputs {
if (!enabled || !shouldFill1mContextWindow(inputs[slot])) return inputs
return { ...inputs, [slot]: String(MODEL_1M_CONTEXT_WINDOW) }
}
function apply1mSupportToContextInputs(
inputs: ModelContextInputs,
model1mSupport: Model1mSupport,
): ModelContextInputs {
let nextInputs = inputs
for (const slot of MODEL_SLOTS) {
nextInputs = apply1mSupportToContextInput(nextInputs, slot, model1mSupport[slot])
}
return nextInputs
}
function normalizeModelMapping(models: ModelMapping): ModelMapping {
const main = models.main.trim()
return {
@ -931,18 +1013,23 @@ function updateSettingsJsonModelContextWindows(
}
}
function updateSettingsJsonModels(raw: string, models: ModelMapping): string {
function updateSettingsJsonModels(
raw: string,
models: ModelMapping,
model1mSupport: Model1mSupport = DEFAULT_MODEL_1M_SUPPORT,
): string {
try {
const parsed = JSON.parse(raw || '{}') as { env?: Record<string, unknown> }
const existingEnv = parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
? parsed.env
: {}
const runtimeModels = applyModel1mSupportMapping(models, model1mSupport)
parsed.env = {
...existingEnv,
ANTHROPIC_MODEL: models.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_MODEL: runtimeModels.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: runtimeModels.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: runtimeModels.sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: runtimeModels.opus,
}
return JSON.stringify(parsed, null, 2)
} catch {
@ -1024,6 +1111,15 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset
: availablePresets[0] ?? fallbackPreset,
)
const initialModels = stripModel1mMarkers(provider?.models ?? initialPreset.defaultModels)
const initialModel1mSupport = getInitialModel1mSupport(
provider?.models ?? initialPreset.defaultModels,
provider,
)
const initialModelContextInputs = apply1mSupportToContextInputs(
getModelContextInputs(initialModels, initialPreset, provider),
initialModel1mSupport,
)
const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
const [name, setName] = useState(provider?.name ?? initialPreset.name)
@ -1033,10 +1129,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
const [apiKey, setApiKey] = useState(provider?.apiKey ?? '')
const [showApiKey, setShowApiKey] = useState(false)
const [notes, setNotes] = useState(provider?.notes ?? '')
const [models, setModels] = useState<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels })
const [modelContextInputs, setModelContextInputs] = useState<ModelContextInputs>(
getModelContextInputs(provider?.models ?? initialPreset.defaultModels, initialPreset, provider),
)
const [models, setModels] = useState<ModelMapping>(initialModels)
const [model1mSupport, setModel1mSupport] = useState<Model1mSupport>(initialModel1mSupport)
const [modelContextInputs, setModelContextInputs] = useState<ModelContextInputs>(initialModelContextInputs)
const [autoCompactWindow, setAutoCompactWindow] = useState(
provider?.autoCompactWindow !== undefined
? String(provider.autoCompactWindow)
@ -1065,6 +1160,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
const autoCompactWindowEnv = autoCompactWindow.trim()
const modelContextWindows = buildModelContextWindows(models, modelContextInputs)
const normalizedModels = normalizeModelMapping(models)
const runtimeModels = applyModel1mSupportMapping(normalizedModels, model1mSupport)
const existingEnv = (settings.env as Record<string, string>) || {}
const cleanedEnv = stripProviderSettingsJsonEnv(existingEnv, presetDefaultEnvKeys)
const mergedEnv: Record<string, unknown> = {
@ -1076,10 +1172,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
: {}),
ANTHROPIC_BASE_URL: needsProxy ? providerProxyBaseUrl : baseUrl,
...buildSettingsJsonAuthEnv(apiFormat, authStrategy, apiKey, selectedPreset),
ANTHROPIC_MODEL: normalizedModels.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: normalizedModels.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: normalizedModels.sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: normalizedModels.opus,
ANTHROPIC_MODEL: runtimeModels.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: runtimeModels.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: runtimeModels.sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: runtimeModels.opus,
}
applyToolSearchEnv(mergedEnv, apiFormat, toolSearchEnabled)
const merged = {
@ -1101,8 +1197,15 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
setBaseUrl(preset.baseUrl)
setApiFormat(preset.apiFormat ?? 'anthropic')
setAuthStrategy(getPresetAuthStrategy(preset))
setModels({ ...preset.defaultModels })
setModelContextInputs(getModelContextInputs(preset.defaultModels, preset))
const nextModels = stripModel1mMarkers(preset.defaultModels)
const nextModel1mSupport = getInitialModel1mSupport(preset.defaultModels)
const nextModelContextInputs = apply1mSupportToContextInputs(
getModelContextInputs(nextModels, preset),
nextModel1mSupport,
)
setModels(nextModels)
setModel1mSupport(nextModel1mSupport)
setModelContextInputs(nextModelContextInputs)
setAutoCompactWindow(getPresetAutoCompactWindow(preset))
setToolSearchEnabled(true)
setShowContextSettings(false)
@ -1214,16 +1317,36 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
setSettingsJson((current) => updateSettingsJsonToolSearch(current, apiFormat, enabled))
}
const handleModelChange = (slot: ModelSlot, value: string) => {
const nextModels = { ...models, [slot]: value }
const hasMarker = hasModel1mMarker(value)
const nextModels = { ...models, [slot]: stripModel1mMarker(value) }
const nextModel1mSupport = hasMarker
? { ...model1mSupport, [slot]: true }
: model1mSupport
const nextInputs = {
...modelContextInputs,
[slot]: getModelContextInputValue(value, selectedPreset, provider),
[slot]: getModelContextInputValue(nextModels[slot], selectedPreset, provider),
}
const nextInputsWith1mSupport = apply1mSupportToContextInput(
nextInputs,
slot,
nextModel1mSupport[slot],
)
setModels(nextModels)
setModel1mSupport(nextModel1mSupport)
setModelContextInputs(nextInputsWith1mSupport)
setSettingsJson((current) => updateSettingsJsonModelContextWindows(
updateSettingsJsonModels(current, normalizeModelMapping(nextModels), nextModel1mSupport),
buildModelContextWindows(nextModels, nextInputsWith1mSupport),
))
}
const handleModel1mSupportChange = (slot: ModelSlot, enabled: boolean) => {
const nextModel1mSupport = { ...model1mSupport, [slot]: enabled }
const nextInputs = apply1mSupportToContextInput(modelContextInputs, slot, enabled)
setModel1mSupport(nextModel1mSupport)
setModelContextInputs(nextInputs)
setSettingsJson((current) => updateSettingsJsonModelContextWindows(
updateSettingsJsonModels(current, normalizeModelMapping(nextModels)),
buildModelContextWindows(nextModels, nextInputs),
updateSettingsJsonModels(current, normalizeModelMapping(models), nextModel1mSupport),
buildModelContextWindows(models, nextInputs),
))
}
const handleModelContextWindowChange = (slot: ModelSlot, value: string) => {
@ -1253,6 +1376,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
const normalizedModels = normalizeModelMapping(models)
const parsedAutoCompactWindow = parseAutoCompactWindowInput(autoCompactWindow)
const parsedModelContextWindows = buildModelContextWindows(models, modelContextInputs)
const storedModel1mSupport = hasAnyModel1mSupport(model1mSupport)
? model1mSupport
: undefined
setIsSubmitting(true)
try {
// Write the edited cc-haha settings.json first so provider-specific model
@ -1276,6 +1402,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
baseUrl: baseUrl.trim(),
apiFormat,
models: normalizedModels,
...(storedModel1mSupport !== undefined && { model1mSupport: storedModel1mSupport }),
...(parsedAutoCompactWindow !== undefined && { autoCompactWindow: parsedAutoCompactWindow }),
...(Object.keys(parsedModelContextWindows).length > 0 && { modelContextWindows: parsedModelContextWindows }),
toolSearchEnabled,
@ -1288,6 +1415,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
authStrategy,
apiFormat,
models: normalizedModels,
model1mSupport: storedModel1mSupport ?? null,
autoCompactWindow: parsedAutoCompactWindow ?? null,
modelContextWindows: Object.keys(parsedModelContextWindows).length > 0
? parsedModelContextWindows
@ -1519,10 +1647,37 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelMapping')}</label>
<div className="grid grid-cols-2 gap-2">
<Input label={t('settings.providers.mainModel')} required value={models.main} onChange={(e) => handleModelChange('main', e.target.value)} placeholder="Model ID" />
<Input label={t('settings.providers.haikuModel')} value={models.haiku} onChange={(e) => handleModelChange('haiku', e.target.value)} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.sonnetModel')} value={models.sonnet} onChange={(e) => handleModelChange('sonnet', e.target.value)} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.opusModel')} value={models.opus} onChange={(e) => handleModelChange('opus', e.target.value)} placeholder={t('settings.providers.sameAsMain')} />
{MODEL_SLOTS.map((slot) => {
const labelKey = slot === 'main'
? 'settings.providers.mainModel'
: slot === 'haiku'
? 'settings.providers.haikuModel'
: slot === 'sonnet'
? 'settings.providers.sonnetModel'
: 'settings.providers.opusModel'
const label = t(labelKey)
return (
<div key={slot} className="min-w-0">
<Input
label={label}
required={slot === 'main'}
value={models[slot]}
onChange={(e) => handleModelChange(slot, e.target.value)}
placeholder={slot === 'main' ? 'Model ID' : t('settings.providers.sameAsMain')}
/>
<label className="mt-1 inline-flex h-6 w-fit cursor-pointer items-center gap-1.5 rounded-[var(--radius-sm)] px-1 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]">
<input
type="checkbox"
checked={model1mSupport[slot]}
onChange={(e) => handleModel1mSupportChange(slot, e.target.checked)}
aria-label={`1M support: ${slot}`}
className="h-3.5 w-3.5 rounded border-[var(--color-border)] text-[var(--color-brand)] focus:ring-[var(--color-brand)]"
/>
<span>{t('settings.providers.model1mSupportShort')}</span>
</label>
</div>
)
})}
</div>
</div>
@ -1700,11 +1855,22 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) newModels.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL
if (Object.keys(newModels).length > 0) {
setModels((prev) => {
const nextModels = { ...prev, ...newModels }
setModelContextInputs(getModelContextInputs(nextModels, {
...selectedPreset,
modelContextWindows: parsedContextWindows,
}))
const mergedModels = { ...prev, ...newModels }
const nextModel1mSupport = {
main: hasModel1mMarker(mergedModels.main),
haiku: hasModel1mMarker(mergedModels.haiku),
sonnet: hasModel1mMarker(mergedModels.sonnet),
opus: hasModel1mMarker(mergedModels.opus),
}
const nextModels = stripModel1mMarkers(mergedModels)
setModel1mSupport(nextModel1mSupport)
setModelContextInputs(apply1mSupportToContextInputs(
getModelContextInputs(nextModels, {
...selectedPreset,
modelContextWindows: parsedContextWindows,
}),
nextModel1mSupport,
))
return nextModels
})
} else if (Object.keys(parsedContextWindows).length > 0) {

View File

@ -18,6 +18,13 @@ export type ModelMapping = {
opus: string
}
export type Model1mSupport = {
main: boolean
haiku: boolean
sonnet: boolean
opus: boolean
}
export type ModelContextWindows = Record<string, number>
export type SavedProvider = {
@ -30,6 +37,7 @@ export type SavedProvider = {
apiFormat: ApiFormat
runtimeKind?: ProviderRuntimeKind
models: ModelMapping
model1mSupport?: Model1mSupport
autoCompactWindow?: number
modelContextWindows?: ModelContextWindows
toolSearchEnabled?: boolean
@ -45,6 +53,7 @@ export type CreateProviderInput = {
apiFormat?: ApiFormat
runtimeKind?: ProviderRuntimeKind
models: ModelMapping
model1mSupport?: Model1mSupport
autoCompactWindow?: number
modelContextWindows?: ModelContextWindows
toolSearchEnabled?: boolean
@ -59,6 +68,7 @@ export type UpdateProviderInput = {
apiFormat?: ApiFormat
runtimeKind?: ProviderRuntimeKind
models?: ModelMapping
model1mSupport?: Model1mSupport | null
autoCompactWindow?: number | null
modelContextWindows?: ModelContextWindows | null
toolSearchEnabled?: boolean

View File

@ -252,6 +252,39 @@ describe('ProviderService', () => {
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES).toBe('thinking')
})
test('custom providers can mark main and role models as 1M-capable', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
models: {
main: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5',
sonnet: 'claude-sonnet-4-6',
opus: 'claude-opus-4-7',
},
model1mSupport: {
main: true,
haiku: false,
sonnet: true,
opus: true,
},
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6[1m]')
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6[1m]')
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7[1m]')
const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(runtimeEnv.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6[1m]')
expect(runtimeEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5')
expect(runtimeEnv.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6[1m]')
expect(runtimeEnv.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7[1m]')
})
test('DeepSeek preset follows the global thinking toggle instead of forcing disabled thinking', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({

View File

@ -45,6 +45,7 @@ export const MANAGED_PROVIDER_ENV_KEYS = [
const CUSTOM_PROVIDER_MODEL_CAPABILITIES = 'thinking,effort,adaptive_thinking,max_effort'
const XIAOMI_MIMO_MODEL_CAPABILITIES = 'thinking'
const AUTH_ENV_KEYS = new Set(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'])
const MODEL_SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
@ -60,6 +61,13 @@ function isProviderModels(value: unknown): value is SavedProvider['models'] {
)
}
function isProviderModel1mSupport(value: unknown): value is SavedProvider['model1mSupport'] {
return (
isRecord(value) &&
MODEL_SLOTS.every((slot) => typeof value[slot] === 'boolean')
)
}
function isSavedProvider(value: unknown): value is SavedProvider {
if (!isRecord(value)) return false
const runtimeKind = value.runtimeKind
@ -74,7 +82,8 @@ function isSavedProvider(value: unknown): value is SavedProvider {
runtimeKind === 'anthropic_compatible' ||
runtimeKind === 'openai_oauth'
) &&
isProviderModels(value.models)
isProviderModels(value.models) &&
(value.model1mSupport === undefined || isProviderModel1mSupport(value.model1mSupport))
)
}
@ -101,14 +110,48 @@ export function normalizeModelMapping(models: SavedProvider['models']): SavedPro
}
}
export function normalizeSavedProvider(provider: SavedProvider): SavedProvider {
const rawProvider = provider as SavedProvider & Record<string, unknown>
function normalizeModel1mSupport(
model1mSupport: SavedProvider['model1mSupport'] | undefined,
): SavedProvider['model1mSupport'] | undefined {
if (!model1mSupport) return undefined
const normalized = {
main: model1mSupport.main === true,
haiku: model1mSupport.haiku === true,
sonnet: model1mSupport.sonnet === true,
opus: model1mSupport.opus === true,
}
return MODEL_SLOTS.some((slot) => normalized[slot]) ? normalized : undefined
}
function applyModel1mSupport(model: string, enabled: boolean | undefined): string {
const trimmed = model.trim()
if (!enabled) return trimmed
return `${trimmed.replace(/\[1m\]$/i, '').replace(/:1m$/i, '').trim()}[1m]`
}
function applyModel1mSupportMapping(
models: SavedProvider['models'],
model1mSupport: SavedProvider['model1mSupport'] | undefined,
): SavedProvider['models'] {
return {
...provider,
main: applyModel1mSupport(models.main, model1mSupport?.main),
haiku: applyModel1mSupport(models.haiku, model1mSupport?.haiku),
sonnet: applyModel1mSupport(models.sonnet, model1mSupport?.sonnet),
opus: applyModel1mSupport(models.opus, model1mSupport?.opus),
}
}
export function normalizeSavedProvider(provider: SavedProvider): SavedProvider {
const { model1mSupport: rawModel1mSupport, ...rest } = provider
const rawProvider = provider as SavedProvider & Record<string, unknown>
const model1mSupport = normalizeModel1mSupport(rawModel1mSupport)
return {
...rest,
apiFormat: provider.apiFormat ?? 'anthropic',
runtimeKind: provider.runtimeKind ?? 'anthropic_compatible',
models: normalizeModelMapping(provider.models),
toolSearchEnabled: normalizeToolSearchEnabled(rawProvider.toolSearchEnabled),
...(model1mSupport !== undefined ? { model1mSupport } : {}),
}
}
@ -273,6 +316,7 @@ export function buildProviderManagedEnv(
: provider.baseUrl
const models = normalizeModelMapping(provider.models)
const runtimeModels = applyModel1mSupportMapping(models, provider.model1mSupport)
const modelContextWindows = {
...getPresetModelContextWindows(provider.presetId),
...(provider.modelContextWindows ?? {}),
@ -303,11 +347,11 @@ export function buildProviderManagedEnv(
}),
ANTHROPIC_BASE_URL: baseUrl,
...buildProviderAuthEnv(provider, presetDefaultEnv, needsProxy),
ANTHROPIC_MODEL: models.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
...attributionHeaderEnvForModel(models.main),
ANTHROPIC_MODEL: runtimeModels.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: runtimeModels.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: runtimeModels.sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: runtimeModels.opus,
...attributionHeaderEnvForModel(runtimeModels.main),
}
}

View File

@ -223,6 +223,7 @@ export class ProviderService {
apiFormat: input.apiFormat ?? 'anthropic',
runtimeKind: input.runtimeKind ?? 'anthropic_compatible',
models: normalizeModelMapping(input.models),
...(input.model1mSupport !== undefined && { model1mSupport: input.model1mSupport }),
...(input.autoCompactWindow !== undefined && { autoCompactWindow: input.autoCompactWindow }),
...(input.modelContextWindows !== undefined && { modelContextWindows: input.modelContextWindows }),
toolSearchEnabled: input.toolSearchEnabled ?? true,
@ -250,11 +251,15 @@ export class ProviderService {
...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }),
...(input.runtimeKind !== undefined && { runtimeKind: input.runtimeKind }),
...(input.models !== undefined && { models: normalizeModelMapping(input.models) }),
...(input.model1mSupport !== undefined && input.model1mSupport !== null && { model1mSupport: input.model1mSupport }),
...(typeof input.autoCompactWindow === 'number' && { autoCompactWindow: input.autoCompactWindow }),
...(input.modelContextWindows !== undefined && input.modelContextWindows !== null && { modelContextWindows: input.modelContextWindows }),
...(input.toolSearchEnabled !== undefined && { toolSearchEnabled: input.toolSearchEnabled }),
...(input.notes !== undefined && { notes: input.notes }),
}
if (input.model1mSupport === null) {
delete updated.model1mSupport
}
if (input.autoCompactWindow === null) {
delete updated.autoCompactWindow
}

View File

@ -43,6 +43,13 @@ export const ModelMappingSchema = z.object({
opus: z.string(),
})
export const Model1mSupportSchema = z.object({
main: z.boolean(),
haiku: z.boolean(),
sonnet: z.boolean(),
opus: z.boolean(),
})
export const AutoCompactWindowSchema = z.number().int().min(16000).max(10000000)
export const ModelContextWindowsSchema = z.record(
z.string().min(1),
@ -60,6 +67,7 @@ export const SavedProviderSchema = z.object({
apiFormat: ApiFormatSchema.default('anthropic'),
runtimeKind: ProviderRuntimeKindSchema.default('anthropic_compatible'),
models: ModelMappingSchema,
model1mSupport: Model1mSupportSchema.optional(),
autoCompactWindow: AutoCompactWindowSchema.optional(),
modelContextWindows: ModelContextWindowsSchema.optional(),
toolSearchEnabled: ToolSearchEnabledSchema.optional(),
@ -82,6 +90,7 @@ export const CreateProviderSchema = z.object({
apiFormat: ApiFormatSchema.default('anthropic'),
runtimeKind: ProviderRuntimeKindSchema.default('anthropic_compatible'),
models: ModelMappingSchema,
model1mSupport: Model1mSupportSchema.optional(),
autoCompactWindow: AutoCompactWindowSchema.optional(),
modelContextWindows: ModelContextWindowsSchema.optional(),
toolSearchEnabled: ToolSearchEnabledSchema.optional(),
@ -96,6 +105,7 @@ export const UpdateProviderSchema = z.object({
apiFormat: ApiFormatSchema.optional(),
runtimeKind: ProviderRuntimeKindSchema.optional(),
models: ModelMappingSchema.optional(),
model1mSupport: Model1mSupportSchema.nullable().optional(),
autoCompactWindow: AutoCompactWindowSchema.nullable().optional(),
modelContextWindows: ModelContextWindowsSchema.nullable().optional(),
toolSearchEnabled: ToolSearchEnabledSchema.optional(),
@ -118,6 +128,7 @@ export const ReorderProvidersSchema = z.object({
// TypeScript types
export type ModelMapping = z.infer<typeof ModelMappingSchema>
export type Model1mSupport = z.infer<typeof Model1mSupportSchema>
export type SavedProvider = z.infer<typeof SavedProviderSchema>
export type ProvidersIndex = z.infer<typeof ProvidersIndexSchema>
export type CreateProviderInput = z.infer<typeof CreateProviderSchema>