From 8711ba156064a2cb52b7bafed7e7a6cf8520412e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 3 May 2026 16:57:08 +0800 Subject: [PATCH 1/3] 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. --- desktop/src/i18n/locales/en.ts | 21 ++ desktop/src/i18n/locales/zh.ts | 21 ++ desktop/src/pages/Settings.tsx | 349 +++++++++++++++++- desktop/src/types/provider.ts | 8 + desktop/src/types/providerPreset.ts | 1 + scripts/repro-issue-247-real.ts | 297 +++++++++++++++ src/server/__tests__/conversations.test.ts | 32 ++ src/server/__tests__/fixtures/mock-sdk-cli.ts | 25 ++ src/server/__tests__/provider-presets.test.ts | 16 +- src/server/__tests__/providers-real.test.ts | 9 + src/server/__tests__/providers.test.ts | 130 ++++++- src/server/config/providerPresets.json | 41 +- src/server/config/providerPresets.ts | 4 + src/server/services/conversationService.ts | 93 +++++ src/server/services/providerService.ts | 28 ++ src/server/types/provider.ts | 12 + src/server/ws/handler.ts | 44 ++- src/services/compact/autoCompact.test.ts | 54 +++ src/services/compact/autoCompact.ts | 2 +- src/utils/context.ts | 14 +- src/utils/managedEnvConstants.ts | 3 + src/utils/model/modelContextWindows.ts | 134 +++++++ 22 files changed, 1322 insertions(+), 16 deletions(-) create mode 100644 scripts/repro-issue-247-real.ts create mode 100644 src/services/compact/autoCompact.test.ts create mode 100644 src/utils/model/modelContextWindows.ts diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 11a426a7..61ab89d3 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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 model’s 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.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 7e01828e..42bf3744 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -190,6 +190,27 @@ export const zh: Record = { '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 — 直接编辑,保存时写入。', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 7881bd7e..ebf0c1d6 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -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 + +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 { + const windows: Record = {} + 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 } + 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 { + try { + const parsed = JSON.parse(raw || '{}') as { env?: Record } + 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 } + 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(provider?.models ?? { ...initialPreset.defaultModels }) + const [modelContextInputs, setModelContextInputs] = useState( + 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(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) || {} 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) => ( + + {shouldShowContextFields && ( +
+
+ +
+ {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 ( +
+ handleModelContextWindowChange(slot, e.target.value)} + placeholder={t('settings.providers.contextWindowPlaceholder')} + /> + {errorKey && ( +

+ {errorKey === 'number' + ? t('settings.providers.modelContextWindowNumberError') + : t('settings.providers.modelContextWindowRangeError')} +

+ )} +
+ ) + })} +
+

+ {t('settings.providers.modelContextWindowsDesc')} +

+
+ +
+ handleAutoCompactWindowChange(e.target.value)} + placeholder={t('settings.providers.autoCompactWindowPlaceholder')} + /> + {autoCompactWindowErrorKey ? ( +

+ {autoCompactWindowErrorKey === 'number' + ? t('settings.providers.autoCompactWindowNumberError') + : t('settings.providers.autoCompactWindowRangeError')} +

+ ) : ( +

+ {t('settings.providers.autoCompactWindowDesc')} +

+ )} +
+
+ )} + + {/* Test connection */}
) : null} + {apiFormat === 'anthropic' && ( +
+ + + items={authStrategyItems} + value={authStrategy} + onChange={handleAuthStrategyChange} + width="100%" + className="block w-full" + trigger={ + + } + /> +
+ )} +