fix: configure provider context windows

Teach provider presets and saved providers to carry per-model context windows so auto-compact uses provider-specific limits instead of a blanket fallback. The desktop provider form now keeps these limits in an advanced context section and syncs the generated settings preview with model changes.

Constraint: Third-party provider APIs do not consistently expose machine-readable model context windows.
Rejected: Keep a single 100K/200K fallback | modern provider windows vary from 128K to 1M+ and the fallback caused late or early compaction.
Confidence: high
Scope-risk: moderate
Directive: Do not change provider compact thresholds without re-running real provider long-context checks.
Tested: bun test src/services/compact/autoCompact.test.ts; bun test src/server/__tests__/provider-presets.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/providers-real.test.ts; bun run check:server; bun run check:desktop; bun run check:policy; bun run check:native; MiniMax/Kimi/DeepSeek live long-context probes; agent-browser provider form flow.
Not-tested: quality:pr policy gate still requires explicit allow-cli-core-change for CLI core edits.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-03 16:57:08 +08:00
parent e72047adfb
commit 8711ba1560
22 changed files with 1322 additions and 16 deletions

View File

@ -188,6 +188,27 @@ export const en = {
'settings.providers.sonnetModel': 'Sonnet Model',
'settings.providers.opusModel': 'Opus Model',
'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.',
'settings.providers.contextSettingsEdit': 'Advanced',
'settings.providers.contextSettingsHide': 'Hide',
'settings.providers.contextSummaryAuto': 'Auto-detect configured models; unknown models use the built-in fallback',
'settings.providers.contextFallbackAuto': 'Unknown models: auto',
'settings.providers.contextFallbackSummary': 'Unknown models: {tokens}',
'settings.providers.modelContextWindows': 'Model context windows',
'settings.providers.mainContextWindow': 'Main model context',
'settings.providers.haikuContextWindow': 'Haiku model context',
'settings.providers.sonnetContextWindow': 'Sonnet model context',
'settings.providers.opusContextWindow': 'Opus model context',
'settings.providers.contextWindowPlaceholder': 'e.g. 200000',
'settings.providers.modelContextWindowsDesc': 'Use each models real context window. Presets are filled automatically; blank values fall back to built-in detection or 200K.',
'settings.providers.modelContextWindowNumberError': 'Enter an integer.',
'settings.providers.modelContextWindowRangeError': 'Must be between 16000 and 10000000.',
'settings.providers.autoCompactWindow': 'Unknown Model Fallback Window',
'settings.providers.autoCompactWindowPlaceholder': 'Optional, e.g. 200000',
'settings.providers.autoCompactWindowDesc': 'Only used for models that cannot be identified; configured model windows take precedence.',
'settings.providers.autoCompactWindowNumberError': 'Enter an integer.',
'settings.providers.autoCompactWindowRangeError': 'Must be between 16000 and 10000000.',
'settings.providers.testConnection': 'Test Connection',
'settings.providers.settingsJson': 'Settings JSON',
'settings.providers.settingsJsonDesc': '~/.claude/cc-haha/settings.json — edit directly, will be written on save.',

View File

@ -190,6 +190,27 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.sonnetModel': 'Sonnet 模型',
'settings.providers.opusModel': 'Opus 模型',
'settings.providers.sameAsMain': '与主模型相同',
'settings.providers.contextSettingsTitle': '上下文与自动压缩',
'settings.providers.contextSettingsDesc': '预设会自动应用模型窗口。只有服务商调整限制,或使用自定义模型 ID 时才需要修改。',
'settings.providers.contextSettingsEdit': '高级',
'settings.providers.contextSettingsHide': '收起',
'settings.providers.contextSummaryAuto': '自动识别已配置模型;未知模型使用内置兜底',
'settings.providers.contextFallbackAuto': '未知模型:自动',
'settings.providers.contextFallbackSummary': '未知模型:{tokens}',
'settings.providers.modelContextWindows': '模型上下文窗口',
'settings.providers.mainContextWindow': '主模型上下文',
'settings.providers.haikuContextWindow': 'Haiku 模型上下文',
'settings.providers.sonnetContextWindow': 'Sonnet 模型上下文',
'settings.providers.opusContextWindow': 'Opus 模型上下文',
'settings.providers.contextWindowPlaceholder': '例如 200000',
'settings.providers.modelContextWindowsDesc': '按每个模型的真实上下文填写,内置预设会自动带出,留空则走内置识别或默认 200K。',
'settings.providers.modelContextWindowNumberError': '请输入整数。',
'settings.providers.modelContextWindowRangeError': '范围必须在 16000 到 10000000 之间。',
'settings.providers.autoCompactWindow': '未知模型兜底窗口',
'settings.providers.autoCompactWindowPlaceholder': '可选,例如 200000',
'settings.providers.autoCompactWindowDesc': '仅用于无法识别的模型;已配置的模型窗口会优先使用。',
'settings.providers.autoCompactWindowNumberError': '请输入整数。',
'settings.providers.autoCompactWindowRangeError': '范围必须在 16000 到 10000000 之间。',
'settings.providers.testConnection': '测试连接',
'settings.providers.settingsJson': '设置 JSON',
'settings.providers.settingsJsonDesc': '~/.claude/cc-haha/settings.json — 直接编辑,保存时写入。',

View File

@ -338,6 +338,147 @@ function requirePreset(preset: ProviderPreset | undefined): ProviderPreset {
return preset
}
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_SLOTS = ['main', 'haiku', 'sonnet', 'opus'] as const
type ModelSlot = typeof MODEL_SLOTS[number]
type ModelContextInputs = Record<ModelSlot, string>
function formatContextWindow(value: number): string {
return value.toLocaleString('en-US')
}
function getPresetAutoCompactWindow(preset: ProviderPreset): string {
return preset.defaultEnv?.[AUTO_COMPACT_WINDOW_ENV_KEY] ?? ''
}
function parseAutoCompactWindowInput(value: string): number | undefined {
const trimmed = value.trim()
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isInteger(parsed)) return undefined
if (parsed < MODEL_CONTEXT_WINDOW_MIN || parsed > MODEL_CONTEXT_WINDOW_MAX) return undefined
return parsed
}
function getAutoCompactWindowErrorKey(value: string): 'number' | 'range' | null {
const trimmed = value.trim()
if (!trimmed) return null
const parsed = Number(trimmed)
if (!Number.isInteger(parsed)) return 'number'
if (parsed < MODEL_CONTEXT_WINDOW_MIN || parsed > MODEL_CONTEXT_WINDOW_MAX) return 'range'
return null
}
function parseModelContextWindowsInput(value: string): number | undefined {
return parseAutoCompactWindowInput(value)
}
function getModelContextWindowErrorKey(value: string): 'number' | 'range' | null {
return getAutoCompactWindowErrorKey(value)
}
function getModelContextInputValue(
model: string | undefined,
preset: ProviderPreset,
provider?: SavedProvider,
): string {
const trimmedModel = model?.trim()
if (!trimmedModel) return ''
const value = provider?.modelContextWindows?.[trimmedModel] ?? preset.modelContextWindows?.[trimmedModel]
return value !== undefined ? String(value) : ''
}
function getModelContextInputs(
models: ModelMapping,
preset: ProviderPreset,
provider?: SavedProvider,
): ModelContextInputs {
const inputs = {} as ModelContextInputs
for (const slot of MODEL_SLOTS) {
inputs[slot] = getModelContextInputValue(models[slot], preset, provider)
}
return inputs
}
function buildModelContextWindows(
models: ModelMapping,
inputs: ModelContextInputs,
): Record<string, number> {
const windows: Record<string, number> = {}
for (const slot of MODEL_SLOTS) {
const model = models[slot]?.trim()
const parsed = parseModelContextWindowsInput(inputs[slot])
if (model && parsed !== undefined) {
windows[model] = parsed
}
}
return windows
}
function updateSettingsJsonAutoCompactWindow(raw: string, value: string): 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 env = { ...existingEnv }
const trimmed = value.trim()
if (trimmed) {
env[AUTO_COMPACT_WINDOW_ENV_KEY] = trimmed
} else {
delete env[AUTO_COMPACT_WINDOW_ENV_KEY]
}
parsed.env = env
return JSON.stringify(parsed, null, 2)
} catch {
return raw
}
}
function updateSettingsJsonModelContextWindows(
raw: string,
modelContextWindows: Record<string, number>,
): 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 env = { ...existingEnv }
if (Object.keys(modelContextWindows).length > 0) {
env[MODEL_CONTEXT_WINDOWS_ENV_KEY] = JSON.stringify(modelContextWindows)
} else {
delete env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
}
parsed.env = env
return JSON.stringify(parsed, null, 2)
} catch {
return raw
}
}
function updateSettingsJsonModels(raw: string, models: ModelMapping): 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
: {}
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,
}
return JSON.stringify(parsed, null, 2)
} catch {
return raw
}
}
function buildFallbackPreset(provider?: SavedProvider): ProviderPreset {
return {
id: provider?.presetId ?? 'custom',
@ -345,6 +486,10 @@ function buildFallbackPreset(provider?: SavedProvider): ProviderPreset {
baseUrl: provider?.baseUrl ?? '',
apiFormat: provider?.apiFormat ?? 'anthropic',
defaultModels: provider?.models ?? { main: '', haiku: '', sonnet: '', opus: '' },
modelContextWindows: provider?.modelContextWindows,
defaultEnv: provider?.autoCompactWindow !== undefined
? { [AUTO_COMPACT_WINDOW_ENV_KEY]: String(provider.autoCompactWindow) }
: undefined,
needsApiKey: true,
websiteUrl: '',
}
@ -403,7 +548,11 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
const regularPresets = availablePresets.filter((p) => !p.featured)
const featuredPresets = availablePresets.filter((p) => p.featured)
const presetDefaultEnvKeys = useMemo(
() => new Set(presets.flatMap((preset) => Object.keys(preset.defaultEnv ?? {}))),
() => new Set([
AUTO_COMPACT_WINDOW_ENV_KEY,
MODEL_CONTEXT_WINDOWS_ENV_KEY,
...presets.flatMap((preset) => Object.keys(preset.defaultEnv ?? {})),
]),
[presets],
)
const fallbackPreset = provider
@ -423,6 +572,15 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
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 [autoCompactWindow, setAutoCompactWindow] = useState(
provider?.autoCompactWindow !== undefined
? String(provider.autoCompactWindow)
: getPresetAutoCompactWindow(initialPreset),
)
const [showContextSettings, setShowContextSettings] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [testResult, setTestResult] = useState<ProviderTestResult | null>(null)
const [isTesting, setIsTesting] = useState(false)
@ -440,6 +598,8 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
import('../api/providers').then(({ providersApi }) => {
providersApi.getSettings().then((settings) => {
const needsProxy = apiFormat !== 'anthropic'
const autoCompactWindowEnv = autoCompactWindow.trim()
const modelContextWindows = buildModelContextWindows(models, modelContextInputs)
const existingEnv = (settings.env as Record<string, string>) || {}
const cleanedEnv = Object.fromEntries(
Object.entries(existingEnv).filter(([key]) => !presetDefaultEnvKeys.has(key)),
@ -450,6 +610,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
env: {
...cleanedEnv,
...(selectedPreset.defaultEnv ?? {}),
...(autoCompactWindowEnv ? { [AUTO_COMPACT_WINDOW_ENV_KEY]: autoCompactWindowEnv } : {}),
...(Object.keys(modelContextWindows).length > 0
? { [MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows) }
: {}),
ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl,
ANTHROPIC_AUTH_TOKEN: needsProxy
? 'proxy-managed'
@ -474,12 +638,17 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
setBaseUrl(preset.baseUrl)
setApiFormat(preset.apiFormat ?? 'anthropic')
setModels({ ...preset.defaultModels })
setModelContextInputs(getModelContextInputs(preset.defaultModels, preset))
setAutoCompactWindow(getPresetAutoCompactWindow(preset))
setShowContextSettings(false)
setTestResult(null)
}
const isCustom = selectedPreset.id === 'custom'
const requiresApiKey = selectedPreset.needsApiKey !== false
const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || !requiresApiKey || apiKey.trim()) && models.main.trim() && !settingsJsonError
const autoCompactWindowErrorKey = getAutoCompactWindowErrorKey(autoCompactWindow)
const modelContextWindowErrorSlots = MODEL_SLOTS.filter((slot) => getModelContextWindowErrorKey(modelContextInputs[slot]))
const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || !requiresApiKey || apiKey.trim()) && models.main.trim() && !settingsJsonError && !autoCompactWindowErrorKey && modelContextWindowErrorSlots.length === 0
const apiKeyUrl = selectedPreset.apiKeyUrl?.trim()
const promoText = selectedPreset.promoText?.trim()
const displayedSettingsJson = showApiKey
@ -503,6 +672,45 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
},
]
const selectedApiFormatLabel = apiFormatItems.find((item) => item.value === apiFormat)?.label ?? t('settings.providers.apiFormatAnthropic')
const configuredContextWindows = buildModelContextWindows(models, modelContextInputs)
const configuredContextSummary = Object.entries(configuredContextWindows)
.filter(([model], index, entries) => entries.findIndex(([candidate]) => candidate === model) === index)
.map(([model, value]) => `${model}: ${formatContextWindow(value)}`)
const parsedFallbackContextWindow = parseAutoCompactWindowInput(autoCompactWindow)
const fallbackContextSummary = parsedFallbackContextWindow !== undefined
? t('settings.providers.contextFallbackSummary', {
tokens: formatContextWindow(parsedFallbackContextWindow),
})
: t('settings.providers.contextFallbackAuto')
const contextSummary = configuredContextSummary.length > 0
? [...configuredContextSummary, fallbackContextSummary].join(' · ')
: t('settings.providers.contextSummaryAuto')
const shouldShowContextFields = showContextSettings || modelContextWindowErrorSlots.length > 0 || !!autoCompactWindowErrorKey
const handleAutoCompactWindowChange = (value: string) => {
setAutoCompactWindow(value)
setSettingsJson((current) => updateSettingsJsonAutoCompactWindow(current, value))
}
const handleModelChange = (slot: ModelSlot, value: string) => {
const nextModels = { ...models, [slot]: value }
const nextInputs = {
...modelContextInputs,
[slot]: getModelContextInputValue(value, selectedPreset, provider),
}
setModels(nextModels)
setModelContextInputs(nextInputs)
setSettingsJson((current) => updateSettingsJsonModelContextWindows(
updateSettingsJsonModels(current, nextModels),
buildModelContextWindows(nextModels, nextInputs),
))
}
const handleModelContextWindowChange = (slot: ModelSlot, value: string) => {
const nextInputs = { ...modelContextInputs, [slot]: value }
setModelContextInputs(nextInputs)
setSettingsJson((current) => updateSettingsJsonModelContextWindows(
current,
buildModelContextWindows(models, nextInputs),
))
}
const renderPresetButton = (preset: ProviderPreset) => (
<button
key={preset.id}
@ -519,6 +727,8 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
const handleSubmit = async () => {
if (!canSubmit) return
const parsedAutoCompactWindow = parseAutoCompactWindowInput(autoCompactWindow)
const parsedModelContextWindows = buildModelContextWindows(models, modelContextInputs)
setIsSubmitting(true)
try {
// Write the edited cc-haha settings.json first so provider-specific model
@ -541,6 +751,8 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
baseUrl: baseUrl.trim(),
apiFormat,
models,
...(parsedAutoCompactWindow !== undefined && { autoCompactWindow: parsedAutoCompactWindow }),
...(Object.keys(parsedModelContextWindows).length > 0 && { modelContextWindows: parsedModelContextWindows }),
notes: notes.trim() || undefined,
})
} else if (provider) {
@ -549,6 +761,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
baseUrl: baseUrl.trim(),
apiFormat,
models,
autoCompactWindow: parsedAutoCompactWindow ?? null,
modelContextWindows: Object.keys(parsedModelContextWindows).length > 0
? parsedModelContextWindows
: null,
notes: notes.trim() || undefined,
}
if (apiKey.trim()) input.apiKey = apiKey.trim()
@ -725,13 +941,103 @@ 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) => setModels({ ...models, main: e.target.value })} placeholder="Model ID" />
<Input label={t('settings.providers.haikuModel')} value={models.haiku} onChange={(e) => setModels({ ...models, haiku: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.sonnetModel')} value={models.sonnet} onChange={(e) => setModels({ ...models, sonnet: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.opusModel')} value={models.opus} onChange={(e) => setModels({ ...models, opus: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
<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')} />
</div>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<button
type="button"
onClick={() => setShowContextSettings((visible) => !visible)}
className="flex w-full items-start gap-3 px-3 py-3 text-left outline-none transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:shadow-[var(--shadow-focus-ring)]"
aria-expanded={shouldShowContextFields}
>
<span className="material-symbols-outlined mt-0.5 text-[18px] text-[var(--color-brand)]">compress</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.providers.contextSettingsTitle')}
</span>
<span className="mt-1 block truncate text-xs text-[var(--color-text-secondary)]">
{contextSummary}
</span>
<span className="mt-1 block text-[11px] leading-5 text-[var(--color-text-tertiary)]">
{t('settings.providers.contextSettingsDesc')}
</span>
</span>
<span className="mt-0.5 inline-flex items-center gap-1 text-xs font-medium text-[var(--color-brand)]">
{shouldShowContextFields
? t('settings.providers.contextSettingsHide')
: t('settings.providers.contextSettingsEdit')}
<span className="material-symbols-outlined text-[16px]">
{shouldShowContextFields ? 'expand_less' : 'expand_more'}
</span>
</span>
</button>
{shouldShowContextFields && (
<div className="border-t border-[var(--color-border)] px-3 pb-3 pt-3">
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelContextWindows')}</label>
<div className="grid grid-cols-2 gap-2">
{MODEL_SLOTS.map((slot) => {
const errorKey = getModelContextWindowErrorKey(modelContextInputs[slot])
const labelKey = slot === 'main'
? 'settings.providers.mainContextWindow'
: slot === 'haiku'
? 'settings.providers.haikuContextWindow'
: slot === 'sonnet'
? 'settings.providers.sonnetContextWindow'
: 'settings.providers.opusContextWindow'
return (
<div key={slot}>
<Input
label={t(labelKey)}
value={modelContextInputs[slot]}
onChange={(e) => handleModelContextWindowChange(slot, e.target.value)}
placeholder={t('settings.providers.contextWindowPlaceholder')}
/>
{errorKey && (
<p className="text-[11px] text-[var(--color-error)] mt-1">
{errorKey === 'number'
? t('settings.providers.modelContextWindowNumberError')
: t('settings.providers.modelContextWindowRangeError')}
</p>
)}
</div>
)
})}
</div>
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
{t('settings.providers.modelContextWindowsDesc')}
</p>
</div>
<div className="mt-3">
<Input
label={t('settings.providers.autoCompactWindow')}
value={autoCompactWindow}
onChange={(e) => handleAutoCompactWindowChange(e.target.value)}
placeholder={t('settings.providers.autoCompactWindowPlaceholder')}
/>
{autoCompactWindowErrorKey ? (
<p className="text-[11px] text-[var(--color-error)] mt-1">
{autoCompactWindowErrorKey === 'number'
? t('settings.providers.autoCompactWindowNumberError')
: t('settings.providers.autoCompactWindowRangeError')}
</p>
) : (
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">
{t('settings.providers.autoCompactWindowDesc')}
</p>
)}
</div>
</div>
)}
</div>
{/* Test connection */}
<div className="flex items-center gap-3">
<Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !models.main.trim()}>
@ -787,13 +1093,42 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
if (nextApiKey && nextApiKey !== '(your API key)' && nextApiKey !== API_KEY_JSON_PLACEHOLDER) {
setApiKey(nextApiKey)
}
if (env[AUTO_COMPACT_WINDOW_ENV_KEY] !== undefined) {
setAutoCompactWindow(String(env[AUTO_COMPACT_WINDOW_ENV_KEY]))
} else {
setAutoCompactWindow('')
}
let parsedContextWindows: Record<string, number> = {}
if (typeof env[MODEL_CONTEXT_WINDOWS_ENV_KEY] === 'string') {
try {
const parsedContext = JSON.parse(env[MODEL_CONTEXT_WINDOWS_ENV_KEY]) as Record<string, unknown>
parsedContextWindows = Object.fromEntries(
Object.entries(parsedContext)
.filter(([, value]) => typeof value === 'number' && Number.isInteger(value)),
) as Record<string, number>
} catch {
parsedContextWindows = {}
}
}
const newModels: Partial<ModelMapping> = {}
if (env.ANTHROPIC_MODEL) newModels.main = env.ANTHROPIC_MODEL
if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) newModels.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL
if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) newModels.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL
if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) newModels.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL
if (Object.keys(newModels).length > 0) {
setModels((prev) => ({ ...prev, ...newModels }))
setModels((prev) => {
const nextModels = { ...prev, ...newModels }
setModelContextInputs(getModelContextInputs(nextModels, {
...selectedPreset,
modelContextWindows: parsedContextWindows,
}))
return nextModels
})
} else if (Object.keys(parsedContextWindows).length > 0) {
setModelContextInputs(getModelContextInputs(models, {
...selectedPreset,
modelContextWindows: parsedContextWindows,
}))
}
}
} catch (err) {

View File

@ -9,6 +9,8 @@ export type ModelMapping = {
opus: string
}
export type ModelContextWindows = Record<string, number>
export type SavedProvider = {
id: string
presetId: string
@ -17,6 +19,8 @@ export type SavedProvider = {
baseUrl: string
apiFormat: ApiFormat
models: ModelMapping
autoCompactWindow?: number
modelContextWindows?: ModelContextWindows
notes?: string
}
@ -27,6 +31,8 @@ export type CreateProviderInput = {
baseUrl: string
apiFormat?: ApiFormat
models: ModelMapping
autoCompactWindow?: number
modelContextWindows?: ModelContextWindows
notes?: string
}
@ -36,6 +42,8 @@ export type UpdateProviderInput = {
baseUrl?: string
apiFormat?: ApiFormat
models?: ModelMapping
autoCompactWindow?: number | null
modelContextWindows?: ModelContextWindows | null
notes?: string
}

View File

@ -19,4 +19,5 @@ export type ProviderPreset = {
promoText?: string
featured?: boolean
defaultEnv?: Record<string, string>
modelContextWindows?: Record<string, number>
}

View File

@ -0,0 +1,297 @@
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { startServer } from '../src/server/index.js'
type ServerMsg = {
type: string
[key: string]: unknown
}
const modelId = process.env.REPRO_MODEL_ID || 'MiniMax-M2.7-highspeed'
const port = Number(process.env.REPRO_SERVER_PORT || 19747)
const baseUrl = `http://127.0.0.1:${port}`
const wsUrl = `ws://127.0.0.1:${port}`
const turnCount = Number(process.env.REPRO_TURNS || 12)
const chunkChars = Number(process.env.REPRO_CHUNK_CHARS || 120_000)
const turnTimeoutMs = Number(process.env.REPRO_TURN_TIMEOUT_MS || 240_000)
const postErrorGraceMs = Number(process.env.REPRO_POST_ERROR_GRACE_MS || 1500)
const existingSessionId = process.env.REPRO_SESSION_ID || ''
const runPrewarm = process.env.REPRO_PREWARM === '1'
const keepWorkDir = process.env.REPRO_KEEP_WORKDIR === '1'
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function makeLongPrompt(turn: number): string {
const seed =
`Issue 247 long-context reproduction turn ${turn}. ` +
'Only answer with exactly "OK". Do not summarize or analyze the filler. '
const filler = (
'Long context filler for MiniMax desktop-session reproduction. ' +
'This text is intentionally repetitive and contains no instructions. '
).repeat(Math.ceil(chunkChars / 104))
return `${seed}\n\n${filler.slice(0, chunkChars)}`
}
function summarizeMessage(msg: ServerMsg): Record<string, unknown> {
if (msg.type === 'content_delta') {
return { type: msg.type, textLength: String(msg.text ?? '').length }
}
if (msg.type === 'error') {
return {
type: msg.type,
code: msg.code,
retryable: msg.retryable,
message: String(msg.message ?? '').slice(0, 1200),
}
}
if (msg.type === 'message_complete') {
return { type: msg.type, usage: msg.usage }
}
if (msg.type === 'status') {
return { type: msg.type, state: msg.state, verb: msg.verb }
}
if (msg.type === 'system_notification') {
return { type: msg.type, subtype: msg.subtype, message: msg.message }
}
return { type: msg.type }
}
async function createSession(workDir: string): Promise<string> {
const res = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir }),
})
if (!res.ok) {
throw new Error(`create session failed: ${res.status} ${await res.text()}`)
}
const body = await res.json() as { sessionId: string }
return body.sessionId
}
async function fetchDiagnostics(sessionId: string) {
const res = await fetch(`${baseUrl}/api/diagnostics/events?limit=200`)
if (!res.ok) return []
const body = await res.json() as { events?: Array<Record<string, unknown>> }
return (body.events ?? []).filter((event) => event.sessionId === sessionId)
}
async function resolveProviderId(): Promise<string> {
if (process.env.REPRO_PROVIDER_ID) return process.env.REPRO_PROVIDER_ID
const res = await fetch(`${baseUrl}/api/providers`)
if (!res.ok) {
throw new Error(`list providers failed: ${res.status} ${await res.text()}`)
}
const body = await res.json() as {
providers?: Array<{ id: string; name: string; presetId?: string }>
}
const provider = (body.providers ?? []).find((entry) =>
entry.presetId === 'minimax' ||
entry.name.toLowerCase() === 'minimax' ||
entry.name.toLowerCase().includes('minimax')
)
if (!provider) {
throw new Error('MiniMax provider not found. Set REPRO_PROVIDER_ID to a configured provider id.')
}
return provider.id
}
async function connect(sessionId: string): Promise<{
ws: WebSocket
messages: ServerMsg[]
waitForConnected: () => Promise<void>
sendTurn: (content: string) => Promise<ServerMsg[]>
collectSince: (index: number, waitMs: number) => Promise<ServerMsg[]>
close: () => void
}> {
const messages: ServerMsg[] = []
let connectedResolve: (() => void) | null = null
let connectedReject: ((err: Error) => void) | null = null
let turnResolve: ((value: ServerMsg[]) => void) | null = null
let turnReject: ((err: Error) => void) | null = null
let turnStartIndex = 0
let turnTimer: ReturnType<typeof setTimeout> | null = null
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
ws.onmessage = (event) => {
const msg = JSON.parse(String(event.data)) as ServerMsg
messages.push(msg)
if (msg.type === 'connected') {
connectedResolve?.()
connectedResolve = null
}
if (turnResolve && (msg.type === 'message_complete' || msg.type === 'error')) {
if (turnTimer) clearTimeout(turnTimer)
const batch = messages.slice(turnStartIndex)
const resolve = turnResolve
turnResolve = null
turnReject = null
resolve(batch)
}
}
ws.onerror = () => {
const err = new Error(`WebSocket error for ${sessionId}`)
connectedReject?.(err)
turnReject?.(err)
}
ws.onclose = () => {
if (turnResolve) {
if (turnTimer) clearTimeout(turnTimer)
const batch = messages.slice(turnStartIndex)
const resolve = turnResolve
turnResolve = null
turnReject = null
resolve(batch)
}
}
return {
ws,
messages,
waitForConnected() {
if (messages.some((msg) => msg.type === 'connected')) return Promise.resolve()
return new Promise<void>((resolve, reject) => {
connectedResolve = resolve
connectedReject = reject
setTimeout(() => reject(new Error(`Timed out waiting for connected ${sessionId}`)), 10_000)
})
},
sendTurn(content: string) {
if (turnResolve) throw new Error('turn already in flight')
turnStartIndex = messages.length
ws.send(JSON.stringify({ type: 'user_message', content }))
return new Promise<ServerMsg[]>((resolve, reject) => {
turnResolve = resolve
turnReject = reject
turnTimer = setTimeout(() => {
turnResolve = null
turnReject = null
reject(new Error(`Timed out waiting for terminal turn event after ${turnTimeoutMs}ms`))
}, turnTimeoutMs)
})
},
async collectSince(index: number, waitMs: number) {
await sleep(waitMs)
return messages.slice(index)
},
close() {
ws.close()
},
}
}
async function main() {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-issue-247-real-'))
const server = startServer(port, '127.0.0.1')
await sleep(500)
try {
const providerId = await resolveProviderId()
const sessionId = existingSessionId || await createSession(workDir)
console.log(JSON.stringify({
event: 'start',
sessionId,
workDir,
providerId,
modelId,
turnCount,
chunkChars,
disableCompact: process.env.DISABLE_COMPACT === '1',
existingSession: Boolean(existingSessionId),
runPrewarm,
keepWorkDir,
}))
const client = await connect(sessionId)
await client.waitForConnected()
client.ws.send(JSON.stringify({ type: 'set_runtime_config', providerId, modelId }))
await sleep(250)
if (runPrewarm) {
const startIndex = client.messages.length
client.ws.send(JSON.stringify({ type: 'prewarm_session' }))
const batch = await client.collectSince(startIndex, 5000)
console.log(JSON.stringify({
event: 'prewarm',
messageTypes: batch.map((msg) => msg.type),
messages: batch.map(summarizeMessage),
}))
}
for (let turn = 1; !runPrewarm && turn <= turnCount; turn++) {
const startedAt = Date.now()
const batch = await client.sendTurn(makeLongPrompt(turn))
const errors = batch.filter((msg) => msg.type === 'error')
const completions = batch.filter((msg) => msg.type === 'message_complete')
console.log(JSON.stringify({
event: 'turn',
turn,
durationMs: Date.now() - startedAt,
messageTypes: batch.map((msg) => msg.type),
errors: errors.map(summarizeMessage),
completions: completions.map(summarizeMessage),
}))
if (errors.length > 0) {
await sleep(postErrorGraceMs)
console.log(JSON.stringify({
event: 'post_error_messages',
turn,
recent: client.messages.slice(-12).map(summarizeMessage),
}))
break
}
}
client.close()
await sleep(1000)
const resumeClient = await connect(sessionId)
await resumeClient.waitForConnected()
resumeClient.ws.send(JSON.stringify({ type: 'set_runtime_config', providerId, modelId }))
await sleep(250)
try {
const batch = await resumeClient.sendTurn('Continue with exactly "OK".')
await sleep(postErrorGraceMs)
console.log(JSON.stringify({
event: 'resume_turn',
messageTypes: resumeClient.messages.slice(1).map((msg) => msg.type),
errors: resumeClient.messages.filter((msg) => msg.type === 'error').map(summarizeMessage),
completions: resumeClient.messages.filter((msg) => msg.type === 'message_complete').map(summarizeMessage),
recent: resumeClient.messages.slice(-12).map(summarizeMessage),
}))
} finally {
resumeClient.close()
}
const diagnostics = await fetchDiagnostics(sessionId)
console.log(JSON.stringify({
event: 'diagnostics',
count: diagnostics.length,
events: diagnostics.map((event) => ({
timestamp: event.timestamp,
type: event.type,
severity: event.severity,
summary: String(event.summary ?? '').slice(0, 1200),
})),
}))
} finally {
server.stop(true)
if (!keepWorkDir) {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {})
}
}
}
main().catch((error) => {
console.error(JSON.stringify({
event: 'fatal',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}))
process.exit(1)
})
.then(() => process.exit(0))

View File

@ -837,6 +837,38 @@ describe('WebSocket Chat Integration', () => {
expect(messages.at(-1)?.type).toBe('message_complete')
}, 15_000)
it('should not duplicate SDK API errors with the final error result', async () => {
const messages = await runTurnUntilComplete(
`chat-api-error-${crypto.randomUUID()}`,
'trigger api error',
)
const errors = messages.filter((m) => m.type === 'error')
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({
code: 'invalid_request',
message: 'Prompt is too long',
})
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
expect(messages.at(-1)?.type).toBe('message_complete')
}, 15_000)
it('should not add a CLI exit error after a reported SDK API error', async () => {
const messages = await runTurnUntilComplete(
`chat-api-error-exit-${crypto.randomUUID()}`,
'trigger api error then exit',
)
const errors = messages.filter((m) => m.type === 'error')
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({
code: 'invalid_request',
message: 'Prompt is too long',
})
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
expect(messages.at(-1)?.type).toBe('message_complete')
}, 15_000)
it('should handle permission_response without error', async () => {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/chat-test-4`)

View File

@ -120,6 +120,31 @@ ws.addEventListener('message', (event) => {
})
continue
}
if (text.includes('trigger api error')) {
emit(ws, {
type: 'assistant',
error: 'invalid_request',
isApiErrorMessage: true,
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Prompt is too long' }],
},
session_id: sessionId,
})
if (text.includes('then exit')) {
setTimeout(() => process.exit(1), 10)
continue
}
emit(ws, {
type: 'result',
subtype: 'success',
is_error: true,
result: 'Prompt is too long',
usage: { input_tokens: 0, output_tokens: 0 },
session_id: sessionId,
})
continue
}
emit(ws, {
type: 'stream_event',
event: { type: 'message_start' },

View File

@ -89,6 +89,7 @@ describe('provider presets API', () => {
expect(kimi?.defaultModels.main).toBe('kimi-k2.6')
expect(kimi?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1')
expect(minimax?.defaultModels.main).toBe('MiniMax-M2.7')
expect(minimax?.modelContextWindows?.['MiniMax-M2.7']).toBe(204800)
expect(jiekouai?.baseUrl).toBe('https://api.jiekou.ai/anthropic')
expect(jiekouai?.defaultModels.main).toBe('claude-sonnet-4-6')
expect(jiekouai?.defaultModels.opus).toBe('claude-opus-4-7')
@ -112,16 +113,25 @@ describe('provider presets API', () => {
expect(lmstudio?.needsApiKey).toBe(false)
expect(lmstudio?.promoText).toContain('http://localhost:1234')
expect(lmstudio?.promoText).toContain('200K')
expect(lmstudio?.defaultEnv).toEqual({ ANTHROPIC_AUTH_TOKEN: 'lmstudio' })
expect(lmstudio?.defaultEnv).toEqual({
ANTHROPIC_AUTH_TOKEN: 'lmstudio',
})
expect(ollama?.needsApiKey).toBe(false)
expect(ollama?.promoText).toContain('http://localhost:11434')
expect(ollama?.promoText).toContain('200K')
expect(ollama?.defaultEnv).toEqual({ ANTHROPIC_AUTH_TOKEN: 'ollama' })
expect(ollama?.defaultEnv).toEqual({
ANTHROPIC_AUTH_TOKEN: 'ollama',
})
expect(deepseek?.apiKeyUrl).toBe('https://platform.deepseek.com/api_keys')
expect(deepseek?.modelContextWindows?.['deepseek-v4-pro']).toBe(1000000)
expect(deepseek?.modelContextWindows?.['deepseek-v4-flash']).toBe(1000000)
expect(zhipu?.apiKeyUrl).toBe('https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D')
expect(zhipu?.promoText).toContain('cc-haha')
expect(zhipu?.defaultEnv?.CC_HAHA_SEND_DISABLED_THINKING).toBe('1')
expect(zhipu?.modelContextWindows?.['glm-5.1']).toBe(200000)
expect(zhipu?.modelContextWindows?.['glm-4.5-air']).toBe(128000)
expect(kimi?.apiKeyUrl).toBe('https://platform.kimi.com/console/api-keys')
expect(kimi?.modelContextWindows?.['kimi-k2.6']).toBe(262144)
expect(minimax?.apiKeyUrl).toBe('https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link')
expect(jiekouai?.apiKeyUrl).toBe('https://jiekou.ai/referral?invited_code=OBNU3K')
expect(jiekouai?.promoText).toContain('官方 8 折')
@ -134,7 +144,9 @@ describe('provider presets API', () => {
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES: 'none',
})
expect(shengsuanyun?.modelContextWindows?.['anthropic/claude-opus-4.7']).toBe(1000000)
expect(custom?.promoText).toBeUndefined()
expect(custom?.defaultEnv).toBeUndefined()
})
test('GET and PUT /api/providers/settings read and write cc-haha settings.json', async () => {

View File

@ -68,6 +68,10 @@ describe('Real Provider Configs', () => {
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('sk-fake-test-key-for-testing-only')
expect((settings.env as Record<string, string>).ANTHROPIC_MODEL).toBe('MiniMax-M2.7-highspeed')
expect(JSON.parse((settings.env as Record<string, string>).CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toMatchObject({
'MiniMax-M2.7': 204800,
'MiniMax-M2.7-highspeed': 204800,
})
// 验证原版 settings.json 没有被创建
expect(await originalSettingsExists()).toBe(false)
@ -101,6 +105,10 @@ describe('Real Provider Configs', () => {
await service.activateProvider(minimax.id)
let settings = await readCcHahaSettings()
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
expect(JSON.parse((settings.env as Record<string, string>).CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toMatchObject({
'MiniMax-M2.7': 204800,
'MiniMax-M2.7-highspeed': 204800,
})
// 切换到接口AI中转站
await service.activateProvider(jiekou.id)
@ -108,6 +116,7 @@ describe('Real Provider Configs', () => {
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('sk-fake-test-key-for-testing-only')
expect((settings.env as Record<string, string>).ANTHROPIC_MODEL).toBe('claude-opus-4-7')
expect((settings.env as Record<string, string>).CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
// 验证 activeId 正确
const list = await service.listProviders()

View File

@ -153,6 +153,28 @@ describe('ProviderService', () => {
expect(provider.notes).toBe('dev environment')
})
test('should preserve optional auto compact window', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ autoCompactWindow: 64000 }))
expect(provider.autoCompactWindow).toBe(64000)
})
test('should preserve optional model context windows', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
modelContextWindows: {
'model-main': 300000,
'model-haiku': 128000,
},
}))
expect(provider.modelContextWindows).toEqual({
'model-main': 300000,
'model-haiku': 128000,
})
})
})
// ─── getProvider ─────────────────────────────────────────────────────────
@ -226,6 +248,58 @@ describe('ProviderService', () => {
expect(env.ANTHROPIC_API_KEY).toBe('sk-new-key')
expect(env.ANTHROPIC_MODEL).toBe('model-main')
})
test('updating active provider should override and clear auto compact window', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput({ autoCompactWindow: 64000 }))
await svc.activateProvider(added.id)
let settings = await readSettings()
let env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('64000')
await svc.updateProvider(added.id, { autoCompactWindow: 32000 })
settings = await readSettings()
env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('32000')
await svc.updateProvider(added.id, { autoCompactWindow: null })
settings = await readSettings()
env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
})
test('updating active provider should override and clear model context windows', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput({
modelContextWindows: { 'model-main': 300000 },
}))
await svc.activateProvider(added.id)
let settings = await readSettings()
let env = settings.env as Record<string, string>
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'model-main': 300000,
})
await svc.updateProvider(added.id, {
modelContextWindows: { 'model-main': 500000 },
})
settings = await readSettings()
env = settings.env as Record<string, string>
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'model-main': 500000,
})
await svc.updateProvider(added.id, { modelContextWindows: null })
settings = await readSettings()
env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBeUndefined()
})
})
// ─── deleteProvider ──────────────────────────────────────────────────────
@ -315,6 +389,7 @@ describe('ProviderService', () => {
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('model-haiku')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('model-sonnet')
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('model-opus')
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
})
test('should include preset default env on activation and runtime env', async () => {
@ -330,16 +405,47 @@ describe('ProviderService', () => {
const env = settings.env as Record<string, string>
expect(env.API_TIMEOUT_MS).toBe('3000000')
expect(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe('1')
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'anthropic/claude-sonnet-4.6': 200000,
'anthropic/claude-haiku-4.5:thinking': 200000,
'anthropic/claude-opus-4.7': 1000000,
})
const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(runtimeEnv.API_TIMEOUT_MS).toBe('3000000')
expect(runtimeEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe('1')
expect(runtimeEnv.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
expect(JSON.parse(runtimeEnv.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'anthropic/claude-sonnet-4.6': 200000,
'anthropic/claude-haiku-4.5:thinking': 200000,
'anthropic/claude-opus-4.7': 1000000,
})
await svc.activateOfficial()
const clearedSettings = await readSettings()
const clearedEnv = (clearedSettings.env as Record<string, string> | undefined) ?? {}
expect(clearedEnv.API_TIMEOUT_MS).toBeUndefined()
expect(clearedEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBeUndefined()
expect(clearedEnv.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
expect(clearedEnv.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBeUndefined()
})
test('provider auto compact window should override preset default env on activation and runtime env', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
presetId: 'custom',
autoCompactWindow: 32000,
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('32000')
const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(runtimeEnv.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('32000')
})
test('should preserve existing settings.json fields on activation', async () => {
@ -452,6 +558,7 @@ describe('Providers API', () => {
baseUrl: 'https://api.example.com',
apiKey: 'sk-test',
apiFormat: 'anthropic',
autoCompactWindow: 64000,
models: {
main: 'gpt-4',
haiku: 'gpt-4-haiku',
@ -462,9 +569,10 @@ describe('Providers API', () => {
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(201)
const body = (await res.json()) as { provider: { name: string; models: { main: string } } }
const body = (await res.json()) as { provider: { name: string; models: { main: string }; autoCompactWindow: number } }
expect(body.provider.name).toBe('New Provider')
expect(body.provider.models.main).toBe('gpt-4')
expect(body.provider.autoCompactWindow).toBe(64000)
})
test('POST /api/providers should return 400 for invalid input', async () => {
@ -476,6 +584,26 @@ describe('Providers API', () => {
expect(res.status).toBe(400)
})
test('POST /api/providers should return 400 for invalid auto compact window', async () => {
const { req, url, segments } = makeRequest('POST', '/api/providers', {
presetId: 'custom',
name: 'New Provider',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test',
apiFormat: 'anthropic',
autoCompactWindow: 8000,
models: {
main: 'gpt-4',
haiku: 'gpt-4-haiku',
sonnet: 'gpt-4-sonnet',
opus: 'gpt-4-opus',
},
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(400)
})
// ─── GET /api/providers/:id ──────────────────────────────────────────────
test('GET /api/providers/:id should return a provider', async () => {

View File

@ -29,6 +29,12 @@
"apiKeyUrl": "https://platform.deepseek.com/api_keys",
"defaultEnv": {
"CC_HAHA_SEND_DISABLED_THINKING": "1"
},
"modelContextWindows": {
"deepseek-v4-pro": 1000000,
"deepseek-v4-flash": 1000000,
"deepseek-chat": 1000000,
"deepseek-reasoner": 1000000
}
},
{
@ -48,6 +54,11 @@
"promoText": "智谱 GLM 为 cc-haha 用户准备了专属邀请福利,使用此链接注册后可领取新用户权益。",
"defaultEnv": {
"CC_HAHA_SEND_DISABLED_THINKING": "1"
},
"modelContextWindows": {
"glm-5.1": 200000,
"glm-5-turbo": 200000,
"glm-4.5-air": 128000
}
},
{
@ -66,6 +77,14 @@
"apiKeyUrl": "https://platform.kimi.com/console/api-keys",
"defaultEnv": {
"CC_HAHA_SEND_DISABLED_THINKING": "1"
},
"modelContextWindows": {
"kimi-k2.6": 262144,
"kimi-k2.5": 262144,
"kimi-k2-0905-preview": 262144,
"kimi-k2-turbo-preview": 262144,
"kimi-k2-thinking": 262144,
"kimi-k2-thinking-turbo": 262144
}
},
{
@ -81,7 +100,16 @@
},
"needsApiKey": true,
"websiteUrl": "https://platform.minimaxi.com",
"apiKeyUrl": "https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link"
"apiKeyUrl": "https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link",
"modelContextWindows": {
"MiniMax-M2.7": 204800,
"MiniMax-M2.7-highspeed": 204800,
"MiniMax-M2.5": 204800,
"MiniMax-M2.5-highspeed": 204800,
"MiniMax-M2.1": 204800,
"MiniMax-M2.1-highspeed": 204800,
"MiniMax-M2": 204800
}
},
{
"id": "jiekouai",
@ -101,6 +129,12 @@
"featured": true,
"defaultEnv": {
"ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "none"
},
"modelContextWindows": {
"claude-sonnet-4-6": 200000,
"claude-haiku-4-5-20251001": 200000,
"claude-haiku-4-5": 200000,
"claude-opus-4-7": 1000000
}
},
{
@ -123,6 +157,11 @@
"API_TIMEOUT_MS": "3000000",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "none"
},
"modelContextWindows": {
"anthropic/claude-sonnet-4.6": 200000,
"anthropic/claude-haiku-4.5:thinking": 200000,
"anthropic/claude-opus-4.7": 1000000
}
},
{

View File

@ -25,6 +25,10 @@ const ProviderPresetSchema = z.object({
promoText: z.string().optional(),
featured: z.boolean().optional(),
defaultEnv: z.record(z.string(), z.string()).optional(),
modelContextWindows: z.record(
z.string().min(1),
z.number().int().min(16000).max(10000000),
).optional(),
})
const ProviderPresetsSchema = z.array(ProviderPresetSchema)

View File

@ -499,6 +499,16 @@ export class ConversationService {
if (session.sdkMessages.length > 40) {
session.sdkMessages.splice(0, 20)
}
const sdkError = this.extractSdkErrorEvent(msg)
if (sdkError) {
void diagnosticsService.recordEvent({
type: sdkError.type,
severity: 'error',
sessionId,
summary: sdkError.summary,
details: sdkError.details,
})
}
if (msg?.type === 'system' && msg.subtype === 'init') {
session.initMessage = msg
}
@ -724,6 +734,8 @@ export class ConversationService {
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
'CC_HAHA_SEND_DISABLED_THINKING',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
] as const
const cleanEnv = { ...process.env }
@ -848,6 +860,8 @@ export class ConversationService {
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
'CC_HAHA_SEND_DISABLED_THINKING',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
].some((key) => typeof env[key] === 'string' && env[key]!.trim().length > 0)
} catch {
return false
@ -942,11 +956,15 @@ export class ConversationService {
const resultMessage = [...recentMessages]
.reverse()
.find((msg) => msg?.type === 'result' && msg.is_error)
const assistantApiError = [...recentMessages]
.reverse()
.find((msg) => this.isAssistantApiErrorMessage(msg))
const authStatus = [...recentMessages]
.reverse()
.find((msg) => msg?.type === 'auth_status')
const detail =
this.extractStartupDetail(resultMessage) ||
this.extractAssistantApiErrorDetail(assistantApiError) ||
this.extractStartupDetail(authStatus) ||
capturedOutput
@ -986,11 +1004,15 @@ export class ConversationService {
const resultMessage = [...recentMessages]
.reverse()
.find((msg) => msg?.type === 'result' && msg.is_error)
const assistantApiError = [...recentMessages]
.reverse()
.find((msg) => this.isAssistantApiErrorMessage(msg))
const authStatus = [...recentMessages]
.reverse()
.find((msg) => msg?.type === 'auth_status')
const detail =
this.extractStartupDetail(resultMessage) ||
this.extractAssistantApiErrorDetail(assistantApiError) ||
this.extractStartupDetail(authStatus) ||
capturedOutput
@ -1037,6 +1059,77 @@ export class ConversationService {
return ''
}
private isAssistantApiErrorMessage(message: any): boolean {
return (
message?.type === 'assistant' &&
(message.isApiErrorMessage === true || typeof message.error === 'string')
)
}
private extractAssistantApiErrorDetail(message: any): string {
if (!this.isAssistantApiErrorMessage(message)) return ''
const text = this.extractAssistantText(message)
const error = typeof message.error === 'string' ? message.error : ''
if (text && error) return `${error}: ${text}`
return text || error
}
private extractAssistantText(message: any): string {
const content = message?.message?.content
if (!Array.isArray(content)) return ''
const textBlock = content.find(
(block: unknown): block is { type: string; text: string } =>
!!block &&
typeof block === 'object' &&
(block as { type?: unknown }).type === 'text' &&
typeof (block as { text?: unknown }).text === 'string',
)
return textBlock?.text || ''
}
private extractSdkErrorEvent(message: any): {
type: string
summary: string
details: Record<string, unknown>
} | null {
if (this.isAssistantApiErrorMessage(message)) {
const summary = this.redactProcessOutput(
this.extractAssistantApiErrorDetail(message) || 'Assistant API error',
)
return {
type: 'sdk_api_error',
summary,
details: {
sdkType: message.type,
error: typeof message.error === 'string' ? message.error : undefined,
isApiErrorMessage: message.isApiErrorMessage === true,
errorDetails:
typeof message.errorDetails === 'string'
? this.redactProcessOutput(message.errorDetails)
: undefined,
},
}
}
if (message?.type === 'result' && message.is_error) {
const summary = this.redactProcessOutput(
this.extractStartupDetail(message) || 'SDK result error',
)
return {
type: 'sdk_result_error',
summary,
details: {
sdkType: message.type,
subtype: message.subtype,
isError: true,
},
}
}
return null
}
private summarizeSdkMessages(messages: any[]): unknown[] {
return messages.slice(-10).map((message) => {
if (!message || typeof message !== 'object') {

View File

@ -16,6 +16,7 @@ import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.
import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesToAnthropic.js'
import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js'
import { PROVIDER_PRESETS } from '../config/providerPresets.js'
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
import type {
SavedProvider,
ProvidersIndex,
@ -38,6 +39,8 @@ const MANAGED_ENV_KEYS = [
'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
MODEL_CONTEXT_WINDOWS_ENV_KEY,
] as const
const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] }
@ -46,6 +49,10 @@ function getPresetDefaultEnv(presetId: string): Record<string, string> {
return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.defaultEnv ?? {}
}
function getPresetModelContextWindows(presetId: string): Record<string, number> {
return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.modelContextWindows ?? {}
}
function getManagedEnvKeys(): string[] {
const keys = new Set<string>(MANAGED_ENV_KEYS)
for (const preset of PROVIDER_PRESETS) {
@ -168,6 +175,8 @@ export class ProviderService {
baseUrl: input.baseUrl,
apiFormat: input.apiFormat ?? 'anthropic',
models: input.models,
...(input.autoCompactWindow !== undefined && { autoCompactWindow: input.autoCompactWindow }),
...(input.modelContextWindows !== undefined && { modelContextWindows: input.modelContextWindows }),
...(input.notes !== undefined && { notes: input.notes }),
}
@ -189,8 +198,16 @@ export class ProviderService {
...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }),
...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }),
...(input.models !== undefined && { models: input.models }),
...(typeof input.autoCompactWindow === 'number' && { autoCompactWindow: input.autoCompactWindow }),
...(input.modelContextWindows !== undefined && input.modelContextWindows !== null && { modelContextWindows: input.modelContextWindows }),
...(input.notes !== undefined && { notes: input.notes }),
}
if (input.autoCompactWindow === null) {
delete updated.autoCompactWindow
}
if (input.modelContextWindows === null) {
delete updated.modelContextWindows
}
index.providers[idx] = updated
await this.writeIndex(index)
@ -251,8 +268,19 @@ export class ProviderService {
? `http://127.0.0.1:${ProviderService.serverPort}${proxyPath}`
: provider.baseUrl
const modelContextWindows = {
...getPresetModelContextWindows(provider.presetId),
...(provider.modelContextWindows ?? {}),
}
return {
...getPresetDefaultEnv(provider.presetId),
...(provider.autoCompactWindow !== undefined && {
CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(provider.autoCompactWindow),
}),
...(Object.keys(modelContextWindows).length > 0 && {
[MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows),
}),
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_API_KEY: needsProxy ? 'proxy-managed' : provider.apiKey,
ANTHROPIC_MODEL: provider.models.main,

View File

@ -21,6 +21,12 @@ export const ModelMappingSchema = z.object({
opus: z.string(),
})
export const AutoCompactWindowSchema = z.number().int().min(16000).max(10000000)
export const ModelContextWindowsSchema = z.record(
z.string().min(1),
z.number().int().min(16000).max(10000000),
)
export const SavedProviderSchema = z.object({
id: z.string(),
presetId: z.string(),
@ -29,6 +35,8 @@ export const SavedProviderSchema = z.object({
baseUrl: z.string(),
apiFormat: ApiFormatSchema.default('anthropic'),
models: ModelMappingSchema,
autoCompactWindow: AutoCompactWindowSchema.optional(),
modelContextWindows: ModelContextWindowsSchema.optional(),
notes: z.string().optional(),
})
@ -44,6 +52,8 @@ export const CreateProviderSchema = z.object({
baseUrl: z.string(),
apiFormat: ApiFormatSchema.default('anthropic'),
models: ModelMappingSchema,
autoCompactWindow: AutoCompactWindowSchema.optional(),
modelContextWindows: ModelContextWindowsSchema.optional(),
notes: z.string().optional(),
})
@ -53,6 +63,8 @@ export const UpdateProviderSchema = z.object({
baseUrl: z.string().optional(),
apiFormat: ApiFormatSchema.optional(),
models: ModelMappingSchema.optional(),
autoCompactWindow: AutoCompactWindowSchema.nullable().optional(),
modelContextWindows: ModelContextWindowsSchema.nullable().optional(),
notes: z.string().optional(),
})

View File

@ -680,6 +680,10 @@ type SessionStreamState = {
/** Tool blocks whose input JSON failed to parse in content_block_stop.
* The assistant message carries the complete input defer to that. */
pendingToolBlocks: Map<string, { toolName: string; toolUseId: string; parentToolUseId?: string }>
lastApiError?: {
message: string
code: string
}
}
const sessionStreamStates = new Map<string, SessionStreamState>()
@ -692,6 +696,7 @@ function getStreamState(sessionId: string): SessionStreamState {
activeBlockTypes: new Map(),
activeToolBlocks: new Map(),
pendingToolBlocks: new Map(),
lastApiError: undefined,
}
sessionStreamStates.set(sessionId, state)
}
@ -761,6 +766,31 @@ function cacheSessionInitMetadata(sessionId: string, cliMsg: any) {
}
}
function extractAssistantText(cliMsg: any): string {
const content = cliMsg?.message?.content
if (!Array.isArray(content)) return ''
const textBlock = content.find(
(block: unknown): block is { type: string; text: string } =>
!!block &&
typeof block === 'object' &&
(block as { type?: unknown }).type === 'text' &&
typeof (block as { text?: unknown }).text === 'string',
)
return textBlock?.text || ''
}
function isDuplicateOfLastApiError(
lastApiError: SessionStreamState['lastApiError'],
resultMessage: string,
): boolean {
if (!lastApiError?.message) return false
if (resultMessage === lastApiError.message) return true
return (
resultMessage.includes(lastApiError.message) &&
/CLI (?:process exited unexpectedly|exited during startup)/i.test(resultMessage)
)
}
function bindPrewarmMetadataCapture(sessionId: string) {
for (const msg of conversationService.getRecentSdkMessages(sessionId)) {
cacheSessionInitMetadata(sessionId, msg)
@ -831,11 +861,14 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
const streamState = getStreamState(sessionId)
switch (cliMsg.type) {
case 'assistant': {
if (cliMsg.error) {
if (cliMsg.error || cliMsg.isApiErrorMessage) {
const message = extractAssistantText(cliMsg) || cliMsg.error || 'Unknown API error'
const code = typeof cliMsg.error === 'string' ? cliMsg.error : 'API_ERROR'
streamState.lastApiError = { message, code }
return [{
type: 'error',
message: cliMsg.message?.content?.[0]?.text || cliMsg.error,
code: cliMsg.error,
message,
code,
}]
}
@ -1078,6 +1111,10 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
(Array.isArray(cliMsg.errors) && cliMsg.errors.length > 0
? cliMsg.errors.join('\n')
: 'Unknown error')
if (isDuplicateOfLastApiError(streamState.lastApiError, resultMessage)) {
streamState.lastApiError = undefined
return [{ type: 'message_complete', usage }]
}
// 错误和完成消息都发送
return [
{
@ -1091,6 +1128,7 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
// Clear stop flag on successful completion too
sessionStopRequested.delete(sessionId)
streamState.lastApiError = undefined
return [{ type: 'message_complete', usage }]
}

View File

@ -0,0 +1,54 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { getEffectiveContextWindowSize } from './autoCompact.js'
import { getContextWindowForModel } from '../../utils/context.js'
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
let originalAutoCompactWindow: string | undefined
let originalContextWindows: string | undefined
beforeEach(() => {
originalAutoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
originalContextWindows = process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
delete process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
})
afterEach(() => {
if (originalAutoCompactWindow === undefined) {
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
} else {
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = originalAutoCompactWindow
}
if (originalContextWindows === undefined) {
delete process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
} else {
process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY] = originalContextWindows
}
})
describe('model context window resolution', () => {
test('uses built-in windows for current third-party coding models', () => {
expect(getContextWindowForModel('deepseek-v4-pro')).toBe(1_000_000)
expect(getContextWindowForModel('MiniMax-M2.7')).toBe(204_800)
expect(getContextWindowForModel('kimi-k2.6')).toBe(262_144)
expect(getContextWindowForModel('glm-5.1')).toBe(200_000)
expect(getContextWindowForModel('glm-4.5-air')).toBe(128_000)
})
test('uses per-model provider overrides before built-in defaults', () => {
process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY] = JSON.stringify({
'deepseek-v4-pro': 500_000,
'custom-model': 300_000,
})
expect(getContextWindowForModel('deepseek-v4-pro')).toBe(500_000)
expect(getContextWindowForModel('provider/custom-model')).toBe(300_000)
})
test('global auto compact window can raise unknown models above the default', () => {
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '1000000'
expect(getEffectiveContextWindowSize('unknown-future-model')).toBe(980_000)
})
})

View File

@ -41,7 +41,7 @@ export function getEffectiveContextWindowSize(model: string): number {
if (autoCompactWindow) {
const parsed = parseInt(autoCompactWindow, 10)
if (!isNaN(parsed) && parsed > 0) {
contextWindow = Math.min(contextWindow, parsed)
contextWindow = parsed
}
}

View File

@ -4,6 +4,7 @@ import { getGlobalConfig } from './config.js'
import { isEnvTruthy } from './envUtils.js'
import { getCanonicalName } from './model/model.js'
import { getModelCapability } from './model/modelCapabilities.js'
import { getConfiguredOrBuiltInModelContextWindow } from './model/modelContextWindows.js'
// Model context window size (200k tokens for all models right now)
export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000
@ -36,7 +37,7 @@ export function has1mContext(model: string): boolean {
if (is1mContextDisabled()) {
return false
}
return /\[1m\]/i.test(model)
return /\[1m\]/i.test(model) || /:1m$/i.test(model)
}
// @[MODEL LAUNCH]: Update this pattern if the new model supports 1M context
@ -71,6 +72,17 @@ export function getContextWindowForModel(
return 1_000_000
}
const configuredWindow = getConfiguredOrBuiltInModelContextWindow(model)
if (configuredWindow !== undefined) {
if (
configuredWindow > MODEL_CONTEXT_WINDOW_DEFAULT &&
is1mContextDisabled()
) {
return MODEL_CONTEXT_WINDOW_DEFAULT
}
return configuredWindow
}
const cap = getModelCapability(model)
if (cap?.max_input_tokens && cap.max_input_tokens >= 100_000) {
if (

View File

@ -58,6 +58,8 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'ANTHROPIC_SMALL_FAST_MODEL',
'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION',
'CLAUDE_CODE_SUBAGENT_MODEL',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
])
const PROVIDER_MANAGED_ENV_PREFIXES = [
@ -146,6 +148,7 @@ export const SAFE_ENV_VARS = new Set([
'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL',
'CLAUDE_CODE_MAX_OUTPUT_TOKENS',
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
'CLAUDE_CODE_SKIP_VERTEX_AUTH',

View File

@ -0,0 +1,134 @@
export const MODEL_CONTEXT_WINDOWS_ENV_KEY = 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS'
export const MODEL_CONTEXT_WINDOW_MIN = 16_000
export const MODEL_CONTEXT_WINDOW_MAX = 10_000_000
const DIRECT_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
'claude-opus-4-7': 1_000_000,
'claude-sonnet-4-6': 200_000,
'claude-haiku-4-5': 200_000,
'deepseek-v4-pro': 1_000_000,
'deepseek-v4-flash': 1_000_000,
'deepseek-chat': 1_000_000,
'deepseek-reasoner': 1_000_000,
'kimi-k2.6': 262_144,
'kimi-k2.5': 262_144,
'kimi-k2-0905-preview': 262_144,
'kimi-k2-turbo-preview': 262_144,
'kimi-k2-thinking': 262_144,
'kimi-k2-thinking-turbo': 262_144,
'minimax-m2.7': 204_800,
'minimax-m2.7-highspeed': 204_800,
'minimax-m2.5': 204_800,
'minimax-m2.5-highspeed': 204_800,
'minimax-m2.1': 204_800,
'minimax-m2.1-highspeed': 204_800,
'minimax-m2': 204_800,
'glm-5.1': 200_000,
'glm-5': 200_000,
'glm-5-turbo': 200_000,
'glm-4.7': 200_000,
'glm-4.6': 200_000,
'glm-4.5': 128_000,
'glm-4.5-air': 128_000,
}
const PATTERN_MODEL_CONTEXT_WINDOWS: Array<[RegExp, number]> = [
[/^anthropic\/claude-opus-4\.7\b/i, 1_000_000],
[/^anthropic\/claude-sonnet-4\.6\b/i, 200_000],
[/^anthropic\/claude-haiku-4\.5\b/i, 200_000],
[/^openai\/gpt-4\.1\b/i, 1_047_576],
[/^openai\/gpt-5(?:[.-]\d+)?\b/i, 400_000],
[/^google\/gemini-(?:2\.0|2\.5|3)/i, 1_048_576],
[/^gemini-(?:2\.0|2\.5|3)/i, 1_048_576],
[/^qwen\/qwen3\.5-(?:plus|flash)\b/i, 1_000_000],
[/^qwen3\.5-(?:plus|flash)\b/i, 1_000_000],
[/^qwen\/qwen3-max\b/i, 262_144],
[/^qwen3-max\b/i, 262_144],
[/qwen-long/i, 10_000_000],
]
export function normalizeModelContextKey(model: string): string {
return model
.trim()
.replace(/\[1m\]$/i, '')
.replace(/:1m$/i, '')
.toLowerCase()
}
function normalizeWindow(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isInteger(value)) {
return undefined
}
if (value < MODEL_CONTEXT_WINDOW_MIN || value > MODEL_CONTEXT_WINDOW_MAX) {
return undefined
}
return value
}
function parseConfiguredContextWindows(): Record<string, number> {
const raw = process.env[MODEL_CONTEXT_WINDOWS_ENV_KEY]
if (!raw?.trim()) {
return {}
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
const windows: Record<string, number> = {}
for (const [model, value] of Object.entries(parsed)) {
const normalized = normalizeWindow(value)
if (normalized !== undefined) {
windows[normalizeModelContextKey(model)] = normalized
}
}
return windows
} catch {
return {}
}
}
function getConfiguredModelContextWindow(model: string): number | undefined {
const configured = parseConfiguredContextWindows()
const normalizedModel = normalizeModelContextKey(model)
const exact = configured[normalizedModel]
if (exact !== undefined) {
return exact
}
for (const [configuredModel, window] of Object.entries(configured)) {
if (
normalizedModel.endsWith(`/${configuredModel}`) ||
normalizedModel.endsWith(`:${configuredModel}`)
) {
return window
}
}
return undefined
}
function getBuiltInModelContextWindow(model: string): number | undefined {
const normalizedModel = normalizeModelContextKey(model)
const exact = DIRECT_MODEL_CONTEXT_WINDOWS[normalizedModel]
if (exact !== undefined) {
return exact
}
for (const [pattern, window] of PATTERN_MODEL_CONTEXT_WINDOWS) {
if (pattern.test(normalizedModel)) {
return window
}
}
return undefined
}
export function getConfiguredOrBuiltInModelContextWindow(
model: string,
): number | undefined {
return (
getConfiguredModelContextWindow(model) ??
getBuiltInModelContextWindow(model)
)
}