mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
8813dff5b1
commit
54a8f3107c
@ -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 = [
|
||||
{
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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 を使用する場合のみ編集してください。',
|
||||
|
||||
@ -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를 사용할 때만 편집하세요.',
|
||||
|
||||
@ -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 時才需要修改。',
|
||||
|
||||
@ -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 时才需要修改。',
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user