From 54a8f3107c26a7b02e214eb8cfc54387b6ffbe61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 16 Jun 2026 13:58:53 +0800 Subject: [PATCH] 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 --- .../src/__tests__/generalSettings.test.tsx | 86 +++++++ desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/jp.ts | 1 + desktop/src/i18n/locales/kr.ts | 1 + desktop/src/i18n/locales/zh-TW.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + desktop/src/pages/Settings.tsx | 224 +++++++++++++++--- desktop/src/types/provider.ts | 10 + src/server/__tests__/providers.test.ts | 33 +++ src/server/services/providerRuntimeEnv.ts | 62 ++++- src/server/services/providerService.ts | 5 + src/server/types/provider.ts | 11 + 12 files changed, 398 insertions(+), 38 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index bccaf193..a956bdec 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -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() + + 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 = [ { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 5723fa77..49966061 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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.', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 37cc49ee..777e0d48 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -417,6 +417,7 @@ export const jp: Record = { '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 を使用する場合のみ編集してください。', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index a565e8d0..2a9829d2 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -417,6 +417,7 @@ export const kr: Record = { '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를 사용할 때만 편집하세요.', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index e18f54a2..41962d67 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -417,6 +417,7 @@ export const zh: Record = { '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 時才需要修改。', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index e3018f93..22bd97d3 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -417,6 +417,7 @@ export const zh: Record = { '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 时才需要修改。', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index b2a8f945..a259249b 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -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 } 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(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(provider?.models ?? { ...initialPreset.defaultModels }) - const [modelContextInputs, setModelContextInputs] = useState( - getModelContextInputs(provider?.models ?? initialPreset.defaultModels, initialPreset, provider), - ) + const [models, setModels] = useState(initialModels) + const [model1mSupport, setModel1mSupport] = useState(initialModel1mSupport) + const [modelContextInputs, setModelContextInputs] = useState(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) || {} const cleanedEnv = stripProviderSettingsJsonEnv(existingEnv, presetDefaultEnvKeys) const mergedEnv: Record = { @@ -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
- handleModelChange('main', e.target.value)} placeholder="Model ID" /> - handleModelChange('haiku', e.target.value)} placeholder={t('settings.providers.sameAsMain')} /> - handleModelChange('sonnet', e.target.value)} placeholder={t('settings.providers.sameAsMain')} /> - 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 ( +
+ handleModelChange(slot, e.target.value)} + placeholder={slot === 'main' ? 'Model ID' : t('settings.providers.sameAsMain')} + /> + +
+ ) + })}
@@ -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) { diff --git a/desktop/src/types/provider.ts b/desktop/src/types/provider.ts index b84fcc2a..ed213492 100644 --- a/desktop/src/types/provider.ts +++ b/desktop/src/types/provider.ts @@ -18,6 +18,13 @@ export type ModelMapping = { opus: string } +export type Model1mSupport = { + main: boolean + haiku: boolean + sonnet: boolean + opus: boolean +} + export type ModelContextWindows = Record 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 diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index b5347ce2..5d2c8465 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -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 + 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({ diff --git a/src/server/services/providerRuntimeEnv.ts b/src/server/services/providerRuntimeEnv.ts index c5fd22ef..e42facb7 100644 --- a/src/server/services/providerRuntimeEnv.ts +++ b/src/server/services/providerRuntimeEnv.ts @@ -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 { 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 +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 + 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), } } diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 68f2d526..80504267 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -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 } diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts index 94714a3e..acd765f1 100644 --- a/src/server/types/provider.ts +++ b/src/server/types/provider.ts @@ -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 +export type Model1mSupport = z.infer export type SavedProvider = z.infer export type ProvidersIndex = z.infer export type CreateProviderInput = z.infer