From 2b780ceae3bcc5a4cbaf809601e6d50fef1ae15b Mon Sep 17 00:00:00 2001 From: Kenneth Wong Date: Thu, 3 Sep 2026 17:08:23 +0800 Subject: [PATCH] fix(extended-context): never write [1m] into the Fable tier key Claude Code resolves ANTHROPIC_DEFAULT_FABLE_MODEL through a resolver that strips the [1m] suffix before use, unlike the opus/sonnet resolvers which pass their env value through untouched. The stripped value is then treated as an env-supplied default and held to the standard 200k window instead of the model's native 1M, so writing [1m] into that key costs the long context window rather than granting it. Measured against a Claude Code 2.1.259 session (`/context` on a live proxy endpoint): ANTHROPIC_MODEL=claude-fable-5-1[1m] -> 1m window ANTHROPIC_MODEL=claude-fable-5-1 -> 200k window ANTHROPIC_DEFAULT_FABLE_MODEL=claude-fable-5-1[1m] -> 200k window The fix is key-scoped rather than model-scoped, since the suffix is what grants 1M on every other key. Adds envKeyAcceptsExtendedContextSuffix() as the single place recording which keys reject the suffix, enforces it as a floor inside applyExtendedContextPreferenceToAnthropicModels() so no caller can bypass it, and strips a previously saved suffix from that key on the next launch through the auto path. Also extends the preference to CLAUDE_CODE_SUBAGENT_MODEL. Subagent windows come from the same resolver as the main loop (the Explore inheritCap bounds the model tier, not the context window), so a bare subagent model id sits at 200k while the suffixed form gets 1M. The new key list is kept separate from ANTHROPIC_MODEL_ENV_KEYS, which also drives routing, model-id normalization and profile validation. Claude-Session: https://claude.ai/code/session_01NPkafQjVf4pSwPBwBistGk --- .../config/extended-context-config.ts | 12 ++- src/shared/extended-context-utils.ts | 49 ++++++++++- .../cliproxy/extended-context-config.test.ts | 31 +++++++ .../shared/extended-context-utils.test.ts | 86 +++++++++++++++++++ .../provider-editor/use-provider-editor.ts | 6 +- ui/src/lib/extended-context-utils.ts | 4 + 6 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 tests/unit/cliproxy/extended-context-config.test.ts diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 98115888..443591d9 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -13,9 +13,10 @@ import type { CLIProxyProvider } from '../types'; import { supportsExtendedContext } from '../model-catalog'; import { warn } from '../../utils/ui'; import { - ANTHROPIC_MODEL_ENV_KEYS, + EXTENDED_CONTEXT_MODEL_ENV_KEYS, applyExtendedContextPreferenceToAnthropicModels, applyExtendedContextSuffix as applyExtendedContextSuffixShared, + envKeyAcceptsExtendedContextSuffix, hasExtendedContextSuffix, isNativeGeminiModel, stripExtendedContextSuffix, @@ -95,13 +96,20 @@ export function applyExtendedContextConfig( // previously saved [1m] preference just because the model is Claude — only // strip when the model no longer supports extended context. Native Gemini // models still get auto-toggled based on catalog support. - for (const key of ANTHROPIC_MODEL_ENV_KEYS) { + for (const key of EXTENDED_CONTEXT_MODEL_ENV_KEYS) { const value = envVars[key]; if (typeof value !== 'string' || value.trim().length === 0) { continue; } const modelId = stripModelConfigurationSuffixes(value); + // Keys whose resolver strips [1m] must never keep a saved suffix: the + // stripped value loses the model's native long context window. + if (!envKeyAcceptsExtendedContextSuffix(key)) { + envVars[key] = stripExtendedContextSuffix(value); + continue; + } + if (isNativeGeminiModel(modelId)) { envVars[key] = supportsExtendedContext(provider, modelId) ? applyExtendedContextSuffixShared(value) diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index 57468cef..78419abd 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -14,8 +14,42 @@ export const ANTHROPIC_MODEL_ENV_KEYS = [ export type AnthropicModelEnvKey = (typeof ANTHROPIC_MODEL_ENV_KEYS)[number]; +/** + * Model env keys outside the Anthropic tier mappings that still carry a plain + * model id, so the extended-context preference has to cover them too. Kept + * separate from ANTHROPIC_MODEL_ENV_KEYS, which also drives routing, model-id + * normalization and profile validation. + */ +export const EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS = ['CLAUDE_CODE_SUBAGENT_MODEL'] as const; + +/** Every model env key the [1m] preference is applied to. */ +export const EXTENDED_CONTEXT_MODEL_ENV_KEYS = [ + ...ANTHROPIC_MODEL_ENV_KEYS, + ...EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS, +] as const; + +export type ExtendedContextModelEnvKey = (typeof EXTENDED_CONTEXT_MODEL_ENV_KEYS)[number]; + const ANTHROPIC_MODEL_ENV_KEY_SET = new Set(ANTHROPIC_MODEL_ENV_KEYS); +const EXTENDED_CONTEXT_MODEL_ENV_KEY_SET = new Set(EXTENDED_CONTEXT_MODEL_ENV_KEYS); + +/** + * Keys whose value Claude Code resolves through a resolver that removes [1m] + * before use. ANTHROPIC_DEFAULT_FABLE_MODEL is the only one today: its resolver + * strips the suffix (unlike the opus/sonnet resolvers, which pass the value + * through), and the stripped env-supplied default is then held to the standard + * 200k window instead of the model's native 1M. Writing [1m] here therefore + * costs the long context window rather than granting it, and Fable models are + * natively 1M, so the suffix is never needed on this key. + */ +const SUFFIX_STRIPPING_MODEL_ENV_KEYS = new Set(['ANTHROPIC_DEFAULT_FABLE_MODEL']); + +/** True when writing an explicit [1m] suffix into this env key is meaningful. */ +export function envKeyAcceptsExtendedContextSuffix(key: string): boolean { + return !SUFFIX_STRIPPING_MODEL_ENV_KEYS.has(key); +} + /** Check if model is a native Gemini model (auto-enabled behavior). */ export function isNativeGeminiModel(modelId: string): boolean { return modelId.toLowerCase().startsWith('gemini-'); @@ -44,6 +78,11 @@ export function isAnthropicModelEnvKey(key: string): key is AnthropicModelEnvKey return ANTHROPIC_MODEL_ENV_KEY_SET.has(key); } +/** True when key holds a model id the [1m] preference is applied to. */ +export function isExtendedContextModelEnvKey(key: string): key is ExtendedContextModelEnvKey { + return EXTENDED_CONTEXT_MODEL_ENV_KEY_SET.has(key); +} + /** Strip transient config suffixes so model IDs can be checked against catalogs. */ export function stripModelConfigurationSuffixes(modelId: string): string { return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, ''); @@ -53,7 +92,7 @@ export function stripModelConfigurationSuffixes(modelId: string): string { export function hasAnthropicExtendedContextEnabled( env: Partial> ): boolean { - return ANTHROPIC_MODEL_ENV_KEYS.some((key) => { + return EXTENDED_CONTEXT_MODEL_ENV_KEYS.some((key) => { const value = env[key]; return typeof value === 'string' && hasExtendedContextSuffix(value); }); @@ -66,19 +105,21 @@ export function applyExtendedContextPreferenceToAnthropicModels< env: T, enabled: boolean, options: { - supportsExtendedContext?: (modelId: string, key: AnthropicModelEnvKey) => boolean; + supportsExtendedContext?: (modelId: string, key: ExtendedContextModelEnvKey) => boolean; } = {} ): T { const nextEnv: Record = { ...env }; - for (const key of ANTHROPIC_MODEL_ENV_KEYS) { + for (const key of EXTENDED_CONTEXT_MODEL_ENV_KEYS) { const value = nextEnv[key]; if (typeof value !== 'string' || value.trim().length === 0) { continue; } const modelId = stripModelConfigurationSuffixes(value); - const supported = options.supportsExtendedContext?.(modelId, key) ?? true; + const supported = + envKeyAcceptsExtendedContextSuffix(key) && + (options.supportsExtendedContext?.(modelId, key) ?? true); nextEnv[key] = enabled && supported ? applyExtendedContextSuffix(value) : stripExtendedContextSuffix(value); } diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts new file mode 100644 index 00000000..29b7a575 --- /dev/null +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'bun:test'; + +import { applyExtendedContextConfig } from '../../../src/cliproxy/config/extended-context-config'; + +describe('applyExtendedContextConfig', () => { + it('strips a saved [1m] from the fable tier key in auto mode', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + }; + + applyExtendedContextConfig(env, 'claude'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1'); + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5[1m]'); + }); + + it('keeps the fable tier key plain when extended context is forced on', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-fable-5-1', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', + }; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1'); + expect(env.ANTHROPIC_MODEL).toBe('claude-fable-5-1[1m]'); + expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe('claude-fable-5-1[1m]'); + }); +}); diff --git a/tests/unit/shared/extended-context-utils.test.ts b/tests/unit/shared/extended-context-utils.test.ts index 3694f74c..bb319ce1 100644 --- a/tests/unit/shared/extended-context-utils.test.ts +++ b/tests/unit/shared/extended-context-utils.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from 'bun:test'; import { ANTHROPIC_MODEL_ENV_KEYS, + EXTENDED_CONTEXT_MODEL_ENV_KEYS, + applyExtendedContextPreferenceToAnthropicModels, + envKeyAcceptsExtendedContextSuffix, + hasAnthropicExtendedContextEnabled, isAnthropicModelEnvKey, + isExtendedContextModelEnvKey, } from '../../../src/shared/extended-context-utils'; describe('Anthropic model env keys', () => { @@ -22,3 +27,84 @@ describe('Anthropic model env keys', () => { expect(isAnthropicModelEnvKey('ANTHROPIC_BASE_URL')).toBe(false); }); }); + +describe('extended-context model env keys', () => { + it('covers the Anthropic tiers plus the subagent model key', () => { + expect([...EXTENDED_CONTEXT_MODEL_ENV_KEYS]).toEqual([ + ...ANTHROPIC_MODEL_ENV_KEYS, + 'CLAUDE_CODE_SUBAGENT_MODEL', + ]); + expect(isExtendedContextModelEnvKey('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(true); + expect(isAnthropicModelEnvKey('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(false); + }); + + it('marks the fable tier key as suffix-stripping', () => { + expect(envKeyAcceptsExtendedContextSuffix('ANTHROPIC_DEFAULT_FABLE_MODEL')).toBe(false); + expect(envKeyAcceptsExtendedContextSuffix('ANTHROPIC_MODEL')).toBe(true); + expect(envKeyAcceptsExtendedContextSuffix('ANTHROPIC_DEFAULT_OPUS_MODEL')).toBe(true); + expect(envKeyAcceptsExtendedContextSuffix('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(true); + }); +}); + +describe('applyExtendedContextPreferenceToAnthropicModels', () => { + it('never writes [1m] into the fable tier key and strips a saved one', () => { + const env = applyExtendedContextPreferenceToAnthropicModels( + { + ANTHROPIC_MODEL: 'claude-fable-5-1', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', + }, + true + ); + + expect(env).toEqual({ + ANTHROPIC_MODEL: 'claude-fable-5-1[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]', + }); + }); + + it('strips every managed key when the preference is off', () => { + const env = applyExtendedContextPreferenceToAnthropicModels( + { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]', + }, + false + ); + + expect(env).toEqual({ + ANTHROPIC_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', + }); + }); + + it('honors a caller compatibility predicate on top of the key guard', () => { + const env = applyExtendedContextPreferenceToAnthropicModels( + { + ANTHROPIC_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + true, + { supportsExtendedContext: (modelId) => !modelId.startsWith('claude-haiku-') } + ); + + expect(env).toEqual({ + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }); + }); + + it('reads saved intent from the subagent key too', () => { + expect( + hasAnthropicExtendedContextEnabled({ CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]' }) + ).toBe(true); + expect( + hasAnthropicExtendedContextEnabled({ ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1' }) + ).toBe(false); + }); +}); diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index 35ac28b1..6bec47a4 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -12,7 +12,7 @@ import type { ProviderCatalog } from '../provider-model-selector'; import { applyExtendedContextPreferenceToAnthropicModels, hasAnthropicExtendedContextEnabled, - isAnthropicModelEnvKey, + isExtendedContextModelEnvKey, } from '@/lib/extended-context-utils'; import { supportsExtendedContext } from '@/lib/model-catalogs'; import { isValidProvider } from '@/lib/provider-config'; @@ -104,7 +104,7 @@ export function useProviderEditor( const updateEnvValue = useCallback( (key: string, value: string) => { const newEnv = { ...(currentSettings?.env || {}), [key]: value }; - const envWithIntent = isAnthropicModelEnvKey(key) + const envWithIntent = isExtendedContextModelEnvKey(key) ? applySavedLongContextIntent(newEnv, extendedContextEnabled) : newEnv; delete envWithIntent['CCS_EXTENDED_CONTEXT']; @@ -132,7 +132,7 @@ export function useProviderEditor( const updateEnvValues = useCallback( (updates: Record) => { const newEnv = { ...(currentSettings?.env || {}), ...updates }; - const touchesAnthropicModel = Object.keys(updates).some(isAnthropicModelEnvKey); + const touchesAnthropicModel = Object.keys(updates).some(isExtendedContextModelEnvKey); const envWithIntent = touchesAnthropicModel ? applySavedLongContextIntent(newEnv, extendedContextEnabled) : newEnv; diff --git a/ui/src/lib/extended-context-utils.ts b/ui/src/lib/extended-context-utils.ts index 2ce76d69..aa7fde05 100644 --- a/ui/src/lib/extended-context-utils.ts +++ b/ui/src/lib/extended-context-utils.ts @@ -4,10 +4,14 @@ export { ANTHROPIC_MODEL_ENV_KEYS, + EXTENDED_CONTEXT_MODEL_ENV_KEYS, EXTENDED_CONTEXT_SUFFIX, + EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS, applyExtendedContextPreferenceToAnthropicModels, + envKeyAcceptsExtendedContextSuffix, isNativeGeminiModel, isAnthropicModelEnvKey, + isExtendedContextModelEnvKey, hasAnthropicExtendedContextEnabled, hasExtendedContextSuffix, applyExtendedContextSuffix,