From 3583b544d01170d0352a49f3e4bfc8b5ecd2c953 Mon Sep 17 00:00:00 2001 From: Kenneth Wong Date: Thu, 3 Sep 2026 16:26:41 +0800 Subject: [PATCH 1/4] feat(models): add Claude Fable 5.1 and correct Sonnet 5 pricing Register `claude-fable-5-1` in the CLIProxy model catalog, the dashboard catalog, and the usage pricing registry. Pricing is taken from Anthropic's official pricing page: - Fable 5.1 base rates are $10/$50 per MTok with a $12.50 5m cache write, matching Fable 5. - Cache hits bill at 0.025x base input ($0.25/MTok) rather than the standard 0.1x multiplier. Anthropic applies that reduced rate only to Fable 5.1 and Mythos 5.1, so this entry cannot derive its cache rates from CACHE_READ_MULTIPLIER. This also corrects Claude Sonnet 5 from $3/$15 to $2/$10 (cache write $2.50, cache read $0.20). The launch introductory rate became the standard price and the increase scheduled for 2026-09-01 was cancelled, so the previous entry over-reported Sonnet 5 usage cost by 50%. Thinking on Fable 5.1 is always on and can only be steered through effort levels, so the catalog entry exposes the same `low`..`max` level surface as Fable 5 and Opus 5 instead of a manual token budget. The GitHub Copilot catalog is deliberately left unchanged, since Copilot availability for Fable 5.1 is not verified; surfacing it there would offer a model the backend may reject. Regenerates docs/reports/hardening-inventory.{json,md} so the ci-parity gate matches the source tree. --- docs/reports/hardening-inventory.json | 6 ++-- docs/reports/hardening-inventory.md | 4 +-- .../__tests__/thinking-validator.test.ts | 8 +++++ src/cliproxy/model-catalog.ts | 19 +++++++++++- src/web-server/model-pricing.ts | 31 +++++++++++++------ tests/unit/model-pricing.test.ts | 22 ++++++++++--- ui/src/lib/model-catalogs.ts | 14 ++++++++- ui/tests/unit/ui/lib/preset-utils.test.ts | 1 + 8 files changed, 83 insertions(+), 22 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 91e33acb..43bed65e 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -739,7 +739,7 @@ }, { "file": "src/web-server/model-pricing.ts", - "loc": 1127 + "loc": 1138 }, { "file": "src/cliproxy/config/generator.ts", @@ -778,8 +778,8 @@ "loc": 939 }, { - "file": "src/cliproxy/accounts/registry.ts", - "loc": 871 + "file": "src/cliproxy/model-catalog.ts", + "loc": 884 } ] } diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 506b59c8..385cea08 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -93,7 +93,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `src/web-server/routes/cliproxy-auth-routes.ts` | 1531 | | `src/cliproxy/auth/oauth-handler.ts` | 1510 | | `src/cursor/cursor-executor.ts` | 1234 | -| `src/web-server/model-pricing.ts` | 1127 | +| `src/web-server/model-pricing.ts` | 1138 | | `src/cliproxy/config/generator.ts` | 1109 | | `src/cliproxy/auth/oauth-process.ts` | 1048 | | `src/cliproxy/config/env-builder.ts` | 1045 | @@ -103,5 +103,5 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `src/cliproxy/quota/quota-manager.ts` | 954 | | `src/web-server/services/codex-dashboard-service.ts` | 940 | | `src/glmt/glmt-proxy.ts` | 939 | -| `src/cliproxy/accounts/registry.ts` | 871 | +| `src/cliproxy/model-catalog.ts` | 884 | diff --git a/src/cliproxy/__tests__/thinking-validator.test.ts b/src/cliproxy/__tests__/thinking-validator.test.ts index b6a47489..41be1a1a 100644 --- a/src/cliproxy/__tests__/thinking-validator.test.ts +++ b/src/cliproxy/__tests__/thinking-validator.test.ts @@ -60,6 +60,14 @@ describe('Thinking Validator', () => { expect(result.warning).toBeUndefined(); }); + it('should treat max as a distinct top tier on Claude Fable 5.1', () => { + // Fable 5.1 has always-on adaptive thinking steered only by effort level. + const result = validateThinking('claude', 'claude-fable-5-1', 'max'); + expect(result.valid).toBe(true); + expect(result.value).toBe('max'); + expect(result.warning).toBeUndefined(); + }); + it('should treat max as a distinct top tier on Claude Fable 5', () => { // Fable 5 shares Opus 4.8's adaptive thinking surface; max must remain // distinct from xhigh. diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index ff2d632b..509639bc 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -531,10 +531,27 @@ export const MODEL_CATALOG: Partial> = }, extendedContext: true, }, + { + id: 'claude-fable-5-1', + name: 'Claude Fable 5.1', + description: 'Most powerful model (1M context, 128K output)', + contextWindow: 1000000, + nativeImageInput: true, + // Thinking is always on and cannot be disabled: Anthropic rejects both + // `thinking.type: "disabled"` and manual budget_tokens with 400. Depth is + // steered only through effort levels (default `high`). + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + maxLevel: 'max', + dynamicAllowed: true, + }, + extendedContext: true, + }, { id: 'claude-fable-5', name: 'Claude Fable 5', - description: 'Most powerful model', + description: 'Previous most powerful model', contextWindow: 1000000, nativeImageInput: true, // New tier above Opus. Same adaptive-thinking surface as Opus 4.8: diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index b4eec3e5..d1cdfacf 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -216,20 +216,21 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 3.75, cacheReadPerMillion: 0.3, }, - // Claude Sonnet 5 ($3/$15) — latest Sonnet; shares the standard Sonnet rates. + // Claude Sonnet 5 ($2/$10) — the launch introductory rate is now the standard + // price; Anthropic cancelled the scheduled 2026-09-01 increase to $3/$15. // Registered explicitly so it is a known model rather than relying on the - // unknown-model fallback that coincidentally matches these rates. + // unknown-model fallback. 'claude-sonnet-5': { - inputPerMillion: 3.0, - outputPerMillion: 15.0, - cacheCreationPerMillion: 3.75, - cacheReadPerMillion: 0.3, + inputPerMillion: 2.0, + outputPerMillion: 10.0, + cacheCreationPerMillion: 2.5, + cacheReadPerMillion: 0.2, }, 'claude-sonnet-5-thinking': { - inputPerMillion: 3.0, - outputPerMillion: 15.0, - cacheCreationPerMillion: 3.75, - cacheReadPerMillion: 0.3, + inputPerMillion: 2.0, + outputPerMillion: 10.0, + cacheCreationPerMillion: 2.5, + cacheReadPerMillion: 0.2, }, // Claude 4 Opus ($15/$75) 'claude-4-opus-20250514': { @@ -360,6 +361,16 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 12.5, cacheReadPerMillion: 1.0, }, + // Claude Fable 5.1 ($10/$50) — same tier and base rates as Fable 5, but cache + // hits are billed at 0.025x base input ($0.25/MTok) instead of the usual 0.1x. + // Anthropic applies that reduced multiplier only to Fable 5.1 and Mythos 5.1, + // so this entry cannot use CACHE_READ_MULTIPLIER-derived rates. + 'claude-fable-5-1': { + inputPerMillion: 10.0, + outputPerMillion: 50.0, + cacheCreationPerMillion: 12.5, + cacheReadPerMillion: 0.25, + }, // --------------------------------------------------------------------------- // OpenAI Models - Source: better-ccusage diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 2df898fc..8202db38 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -277,12 +277,24 @@ describe('model-pricing', () => { expect(fable5.cacheReadPerMillion).toBe(1.0); }); + it('should return correct pricing for Claude Fable 5.1', () => { + // Same base rates as Fable 5, but cache hits bill at 0.025x base input + // ($0.25/MTok) rather than the standard 0.1x multiplier. + const fable51 = getModelPricing('claude-fable-5-1'); + expect(fable51.inputPerMillion).toBe(10.0); + expect(fable51.outputPerMillion).toBe(50.0); + expect(fable51.cacheCreationPerMillion).toBe(12.5); + expect(fable51.cacheReadPerMillion).toBe(0.25); + }); + it('should return correct pricing for Claude Sonnet 5', () => { + // The $2/$10 launch introductory rate became the standard price; the + // scheduled 2026-09-01 increase to $3/$15 was cancelled. const sonnet5 = getModelPricing('claude-sonnet-5'); - expect(sonnet5.inputPerMillion).toBe(3.0); - expect(sonnet5.outputPerMillion).toBe(15.0); - expect(sonnet5.cacheCreationPerMillion).toBe(3.75); - expect(sonnet5.cacheReadPerMillion).toBe(0.3); + expect(sonnet5.inputPerMillion).toBe(2.0); + expect(sonnet5.outputPerMillion).toBe(10.0); + expect(sonnet5.cacheCreationPerMillion).toBe(2.5); + expect(sonnet5.cacheReadPerMillion).toBe(0.2); }); it('should return Opus-tier pricing for Claude Opus 5', () => { @@ -430,7 +442,7 @@ describe('model-pricing', () => { cacheReadTokens: 1_000_000, }; const cost = calculateCost(usage, 'claude-sonnet-5'); - expect(cost).toBe(22.05); // 3 + 15 + 3.75 + 0.3 + expect(cost).toBe(14.7); // 2 + 10 + 2.5 + 0.2 }); it('should calculate fast-tier Claude Opus 4.8 cost (2x premium)', () => { diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 32c3d523..265e49e9 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -977,10 +977,22 @@ export const MODEL_CATALOGS: Record = { haiku: 'claude-haiku-4-5-20251001', }, }, + { + id: 'claude-fable-5-1', + name: 'Claude Fable 5.1', + description: 'Most powerful model', + extendedContext: true, + presetMapping: { + default: 'claude-fable-5-1', + opus: 'claude-fable-5-1', + sonnet: 'claude-sonnet-5', + haiku: 'claude-haiku-4-5-20251001', + }, + }, { id: 'claude-fable-5', name: 'Claude Fable 5', - description: 'Most powerful model', + description: 'Previous most powerful model', extendedContext: true, presetMapping: { default: 'claude-fable-5', diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index 06615ca0..d8ce4632 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -22,6 +22,7 @@ describe('claude preset utils', () => { expect(claudeCatalog.defaultModel).toBe('claude-sonnet-5'); expect(ids).toContain('claude-sonnet-5'); + expect(ids).toContain('claude-fable-5-1'); expect(ids).toContain('claude-fable-5'); expect(ids).toContain('claude-opus-5'); expect(ids).toContain('claude-opus-4-8'); From 2b780ceae3bcc5a4cbaf809601e6d50fef1ae15b Mon Sep 17 00:00:00 2001 From: Kenneth Wong Date: Thu, 3 Sep 2026 17:08:23 +0800 Subject: [PATCH 2/4] 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, From 02c45e088a841fe9a8d6192743f18a8f99cd2724 Mon Sep 17 00:00:00 2001 From: Kenneth Wong Date: Fri, 4 Sep 2026 02:48:15 +0800 Subject: [PATCH 3/4] fix(extended-context): suffix the Fable tier and default it on 1M launches The previous commit assumed Claude Code's fable resolver strips [1m] and therefore never wrote the suffix into ANTHROPIC_DEFAULT_FABLE_MODEL. That premise only holds when ANTHROPIC_BASE_URL is unset or points at api.anthropic.com. Behind a proxy (CLIProxy, headroom, ...) Claude Code 2.1.259 does the opposite: - the fable alias resolver passes the env value through untouched, so a saved `claude-fable-5-1[1m]` reaches the model picker as-is; - the context-window resolver grants 1M unconditionally when the model id carries [1m], and otherwise trusts a natively-1M model only when the base URL is first-party. Bare `claude-fable-5-1` via 127.0.0.1 is clamped to 200k even though CLIProxy's /v1/models advertises max_input_tokens 1M. So the suffix is the only thing that turns the long window on for CCS, and stripping it from the Fable key is what kept `ccs claude --model fable` at 200k. A headroom settings profile with the suffix saved on that key showed the 1M window in the same Claude Code build, which is how the inversion was caught. Changes: - Drop the suffix-stripping key guard; ANTHROPIC_DEFAULT_FABLE_MODEL now receives and keeps [1m] exactly like the opus/sonnet tiers. - On a long-context launch (explicit --1m, or a saved [1m] on any Anthropic tier key) fill a missing Fable tier with the catalog Fable model plus [1m]. The model-neutral claude profile pins no Fable model, so `--model fable` used to fall back to Claude Code's bare default and lose the window even when every other tier asked for 1M. An explicit mapping always wins, --no-1m never fills, and providers without a catalog Fable model are left alone. - Add getDefaultFableTierModel() to the model catalog for that default. Verified with a stub Claude binary that dumps its environment: ccs claude --model fable -> ANTHROPIC_DEFAULT_FABLE_MODEL=claude-fable-5-1[1m] ccs claude --1m -> ANTHROPIC_DEFAULT_FABLE_MODEL=claude-fable-5-1[1m] ccs claude --no-1m --model fable -> no Fable tier written, ANTHROPIC_MODEL stripped --- .../config/extended-context-config.ts | 51 ++++++++-- src/cliproxy/model-catalog.ts | 11 +++ src/shared/extended-context-utils.ts | 26 ++--- .../cliproxy/extended-context-config.test.ts | 94 ++++++++++++++++++- .../shared/extended-context-utils.test.ts | 23 ++--- ui/src/lib/extended-context-utils.ts | 1 - 6 files changed, 162 insertions(+), 44 deletions(-) diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 443591d9..98fb26c9 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -10,13 +10,13 @@ */ import type { CLIProxyProvider } from '../types'; -import { supportsExtendedContext } from '../model-catalog'; +import { getDefaultFableTierModel, 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, @@ -64,6 +64,39 @@ export function shouldApplyExtendedContext( return false; } +/** + * Whether a saved Anthropic tier mapping still carries a [1m] suffix after the + * per-key pass, i.e. the launch is a long-context launch. + */ +function hasSavedLongContextTier(envVars: NodeJS.ProcessEnv): boolean { + return ANTHROPIC_MODEL_ENV_KEYS.some((key) => { + const value = envVars[key]; + return typeof value === 'string' && hasExtendedContextSuffix(value); + }); +} + +/** + * Give a long-context launch a fable tier when the profile does not map one. + * + * Claude Code falls back to its own bare Fable id for `--model fable` / the + * fable tier, and behind a proxy base URL a bare natively-1M id is clamped to + * the standard 200k window. Only the [1m] suffix lifts that clamp, so a + * model-neutral claude profile that asked for 1M elsewhere would otherwise get + * a 200k Fable. An explicit mapping (dashboard "fable tier" field or settings + * file) always wins; providers without a catalog Fable model are untouched. + */ +function fillFableTierForLongContext(envVars: NodeJS.ProcessEnv, provider: CLIProxyProvider): void { + const existing = envVars.ANTHROPIC_DEFAULT_FABLE_MODEL; + if (typeof existing === 'string' && existing.trim().length > 0) { + return; + } + const fableModel = getDefaultFableTierModel(provider); + if (!fableModel || !supportsExtendedContext(provider, fableModel)) { + return; + } + envVars.ANTHROPIC_DEFAULT_FABLE_MODEL = applyExtendedContextSuffixShared(fableModel); +} + /** * Apply extended context configuration to env vars. * Modifies ANTHROPIC_MODEL and tier models with [1m] suffix. @@ -89,6 +122,9 @@ export function applyExtendedContextConfig( ), }) ); + if (extendedContextOverride) { + fillFableTierForLongContext(envVars, provider); + } return; } @@ -103,13 +139,6 @@ export function applyExtendedContextConfig( } 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) @@ -121,4 +150,8 @@ export function applyExtendedContextConfig( envVars[key] = stripExtendedContextSuffix(value); } } + + if (hasSavedLongContextTier(envVars)) { + fillFableTierForLongContext(envVars, provider); + } } diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 509639bc..0b6cb2f8 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -866,6 +866,17 @@ export function supportsExtendedContext(provider: CLIProxyProvider, modelId: str return model?.extendedContext === true; } +/** + * Catalog model that backs the Claude Code `fable` tier for a provider. + * The catalog lists the newest Fable first, so the first match is the default. + * Returns undefined for providers that serve no Fable model. + */ +export function getDefaultFableTierModel(provider: CLIProxyProvider): string | undefined { + const catalog = MODEL_CATALOG[provider]; + if (!catalog) return undefined; + return catalog.models.find((model) => model.id.toLowerCase().startsWith('claude-fable-'))?.id; +} + /** * Check if a model can read image inputs natively. */ diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index 78419abd..8c717720 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -34,21 +34,15 @@ 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. +/* + * Why every key, including ANTHROPIC_DEFAULT_FABLE_MODEL, carries the suffix: + * Claude Code only grants a natively-1M model (Fable, Opus 5) its full window + * without the suffix when ANTHROPIC_BASE_URL is unset or points at + * api.anthropic.com. Behind any proxy (CLIProxy, headroom, ...) a bare id is + * clamped to 200k even when the backend advertises 1M, and the fable alias + * resolver passes the env value through untouched in that case. The [1m] + * suffix is therefore the only thing that turns the long window on for CCS. */ -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 { @@ -117,9 +111,7 @@ export function applyExtendedContextPreferenceToAnthropicModels< } const modelId = stripModelConfigurationSuffixes(value); - const supported = - envKeyAcceptsExtendedContextSuffix(key) && - (options.supportsExtendedContext?.(modelId, key) ?? true); + const supported = 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 index 29b7a575..b8422b9f 100644 --- a/tests/unit/cliproxy/extended-context-config.test.ts +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -3,7 +3,7 @@ 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', () => { + it('keeps a saved [1m] on 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]', @@ -11,11 +11,11 @@ describe('applyExtendedContextConfig', () => { applyExtendedContextConfig(env, 'claude'); - expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5[1m]'); }); - it('keeps the fable tier key plain when extended context is forced on', () => { + it('suffixes the fable tier key like every other tier when forced on', () => { const env: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-fable-5-1', ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', @@ -24,8 +24,94 @@ describe('applyExtendedContextConfig', () => { applyExtendedContextConfig(env, 'claude', true); - expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); expect(env.ANTHROPIC_MODEL).toBe('claude-fable-5-1[1m]'); expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe('claude-fable-5-1[1m]'); }); + + it('strips the fable tier key with the others when forced off', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + }; + + applyExtendedContextConfig(env, 'claude', false); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1'); + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5'); + }); + + describe('fable tier default on the claude provider', () => { + it('fills a missing fable tier with the catalog Fable model plus [1m] when a saved tier carries [1m]', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + }; + + applyExtendedContextConfig(env, 'claude'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); + }); + + it('fills a missing fable tier when --1m is passed', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5', + }; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5[1m]'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); + }); + + it('fills a missing fable tier on a model-neutral claude launch when --1m is passed', () => { + const env: NodeJS.ProcessEnv = {}; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); + expect(Object.keys(env)).toEqual(['ANTHROPIC_DEFAULT_FABLE_MODEL']); + }); + + it('leaves the fable tier alone when no long-context intent exists', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5', + }; + + applyExtendedContextConfig(env, 'claude'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBeUndefined(); + }); + + it('leaves the fable tier alone when --no-1m is passed', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + }; + + applyExtendedContextConfig(env, 'claude', false); + + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBeUndefined(); + }); + + it('never overrides an explicit fable tier mapping', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5[1m]', + }; + + applyExtendedContextConfig(env, 'claude'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5[1m]'); + }); + + it('does not invent a fable tier for providers without Fable models', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gpt-5.4[1m]', + }; + + applyExtendedContextConfig(env, 'codex'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBeUndefined(); + }); + }); }); diff --git a/tests/unit/shared/extended-context-utils.test.ts b/tests/unit/shared/extended-context-utils.test.ts index bb319ce1..a65af8b1 100644 --- a/tests/unit/shared/extended-context-utils.test.ts +++ b/tests/unit/shared/extended-context-utils.test.ts @@ -4,7 +4,6 @@ import { ANTHROPIC_MODEL_ENV_KEYS, EXTENDED_CONTEXT_MODEL_ENV_KEYS, applyExtendedContextPreferenceToAnthropicModels, - envKeyAcceptsExtendedContextSuffix, hasAnthropicExtendedContextEnabled, isAnthropicModelEnvKey, isExtendedContextModelEnvKey, @@ -37,22 +36,17 @@ describe('extended-context model env keys', () => { 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', () => { + it('writes [1m] into every managed key, the fable tier included', () => { + // Behind a proxy base URL Claude Code clamps a bare Fable id to 200k, so the + // fable tier needs the suffix exactly like the opus/sonnet tiers do. 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]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', }, true @@ -61,7 +55,7 @@ describe('applyExtendedContextPreferenceToAnthropicModels', () => { 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', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]', }); }); @@ -83,7 +77,7 @@ describe('applyExtendedContextPreferenceToAnthropicModels', () => { }); }); - it('honors a caller compatibility predicate on top of the key guard', () => { + it('honors a caller compatibility predicate', () => { const env = applyExtendedContextPreferenceToAnthropicModels( { ANTHROPIC_MODEL: 'claude-opus-5', @@ -99,10 +93,13 @@ describe('applyExtendedContextPreferenceToAnthropicModels', () => { }); }); - it('reads saved intent from the subagent key too', () => { + it('reads saved intent from any managed key', () => { expect( hasAnthropicExtendedContextEnabled({ CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]' }) ).toBe(true); + expect( + hasAnthropicExtendedContextEnabled({ ANTHROPIC_DEFAULT_FABLE_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/lib/extended-context-utils.ts b/ui/src/lib/extended-context-utils.ts index aa7fde05..1dbb35b8 100644 --- a/ui/src/lib/extended-context-utils.ts +++ b/ui/src/lib/extended-context-utils.ts @@ -8,7 +8,6 @@ export { EXTENDED_CONTEXT_SUFFIX, EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS, applyExtendedContextPreferenceToAnthropicModels, - envKeyAcceptsExtendedContextSuffix, isNativeGeminiModel, isAnthropicModelEnvKey, isExtendedContextModelEnvKey, From 7ae7a5eba6c75871f6822567079d841d9cd73789 Mon Sep 17 00:00:00 2001 From: Kenneth Wong Date: Fri, 4 Sep 2026 08:34:33 +0800 Subject: [PATCH 4/4] fix(extended-context): manage ANTHROPIC_DEFAULT_MODEL and keep the overlay in sync Claude Code reads ANTHROPIC_DEFAULT_MODEL as its lowest-priority startup model (after --model, ANTHROPIC_MODEL and the settings `model` field). It is a plain model id, so behind a proxy a bare value is clamped to 200k exactly like the tier keys. Add it to EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS so --1m/--no-1m and the saved preference toggle it with the rest. The `--settings` launch overlay only carried the routing keys plus the five Anthropic tier keys. Claude Code applies the settings `env` block on top of the process environment, so a bare CLAUDE_CODE_SUBAGENT_MODEL or ANTHROPIC_DEFAULT_MODEL persisted on disk silently undid the resolved --1m result (and a saved [1m] undid --no-1m). Overlay the extended-context extra keys too. Verified with a stub Claude binary reading the overlay file: ccs claude --no-1m -> ANTHROPIC_DEFAULT_MODEL=claude-opus-5, CLAUDE_CODE_SUBAGENT_MODEL=claude-sonnet-5 ccs claude --1m -> both keys suffixed with [1m] --- .../__tests__/launch-settings.test.ts | 24 +++++++++++++++++++ src/cliproxy/executor/launch-settings.ts | 11 +++++++-- src/shared/extended-context-utils.ts | 9 ++++++- .../shared/extended-context-utils.test.ts | 16 ++++++++++++- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/cliproxy/executor/__tests__/launch-settings.test.ts b/src/cliproxy/executor/__tests__/launch-settings.test.ts index 6080ab27..0b313b84 100644 --- a/src/cliproxy/executor/__tests__/launch-settings.test.ts +++ b/src/cliproxy/executor/__tests__/launch-settings.test.ts @@ -57,6 +57,30 @@ describe('buildLaunchSettingsOverlay', () => { expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); }); + it('overlays the extended-context extra model keys so a saved bare value cannot clobber --1m/--no-1m', () => { + writePersisted({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5[1m]', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-sonnet-5[1m]', + }, + }); + + const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-sonnet-5', + } as NodeJS.ProcessEnv); + + expect(changed).toBe(true); + const env = settings.env as Record; + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5'); + expect(env.ANTHROPIC_DEFAULT_MODEL).toBe('claude-opus-5'); + expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe('claude-sonnet-5'); + }); + it('preserves non-env settings (permissions, hooks, etc.)', () => { writePersisted({ env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' }, diff --git a/src/cliproxy/executor/launch-settings.ts b/src/cliproxy/executor/launch-settings.ts index 5e33bf7e..aadbd96e 100644 --- a/src/cliproxy/executor/launch-settings.ts +++ b/src/cliproxy/executor/launch-settings.ts @@ -26,6 +26,7 @@ import * as os from 'os'; import * as path from 'path'; import { ANTHROPIC_MODEL_ENV_KEYS, ANTHROPIC_ROUTING_ENV_KEYS } from '../../utils/shell-executor'; +import { EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS } from '../../shared/extended-context-utils'; // SIBLING HELPER: src/utils/openai-compat-launch-settings.ts solves the same // "persisted --settings env clobbers runtime routing env" problem by STRIPPING @@ -38,9 +39,15 @@ import { ANTHROPIC_MODEL_ENV_KEYS, ANTHROPIC_ROUTING_ENV_KEYS } from '../../util * Environment keys that control provider routing/model selection and are read * by Claude from the settings `env` block. These must reflect the resolved * proxy-chain environment, not the persisted on-disk values. Reuses the - * canonical routing/model key lists from shell-executor. + * canonical routing/model key lists from shell-executor, plus the extra model + * keys the [1m] preference manages (subagent + startup default): a saved bare + * value there would otherwise clobber the resolved --1m/--no-1m result. */ -const ROUTING_ENV_KEYS = [...ANTHROPIC_ROUTING_ENV_KEYS, ...ANTHROPIC_MODEL_ENV_KEYS]; +const ROUTING_ENV_KEYS = [ + ...ANTHROPIC_ROUTING_ENV_KEYS, + ...ANTHROPIC_MODEL_ENV_KEYS, + ...EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS, +]; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index 8c717720..ebed49c9 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -19,8 +19,15 @@ export type AnthropicModelEnvKey = (typeof ANTHROPIC_MODEL_ENV_KEYS)[number]; * 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. + * + * ANTHROPIC_DEFAULT_MODEL is Claude Code's lowest-priority startup model + * (after --model, ANTHROPIC_MODEL and the settings `model` field), so it needs + * the same [1m] treatment whenever a profile carries it. */ -export const EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS = ['CLAUDE_CODE_SUBAGENT_MODEL'] as const; +export const EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS = [ + 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_DEFAULT_MODEL', +] as const; /** Every model env key the [1m] preference is applied to. */ export const EXTENDED_CONTEXT_MODEL_ENV_KEYS = [ diff --git a/tests/unit/shared/extended-context-utils.test.ts b/tests/unit/shared/extended-context-utils.test.ts index a65af8b1..c1ce474a 100644 --- a/tests/unit/shared/extended-context-utils.test.ts +++ b/tests/unit/shared/extended-context-utils.test.ts @@ -28,13 +28,16 @@ describe('Anthropic model env keys', () => { }); describe('extended-context model env keys', () => { - it('covers the Anthropic tiers plus the subagent model key', () => { + it('covers the Anthropic tiers plus the subagent and startup-default model keys', () => { expect([...EXTENDED_CONTEXT_MODEL_ENV_KEYS]).toEqual([ ...ANTHROPIC_MODEL_ENV_KEYS, 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_DEFAULT_MODEL', ]); expect(isExtendedContextModelEnvKey('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(true); + expect(isExtendedContextModelEnvKey('ANTHROPIC_DEFAULT_MODEL')).toBe(true); expect(isAnthropicModelEnvKey('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(false); + expect(isAnthropicModelEnvKey('ANTHROPIC_DEFAULT_MODEL')).toBe(false); }); }); @@ -64,6 +67,7 @@ describe('applyExtendedContextPreferenceToAnthropicModels', () => { const env = applyExtendedContextPreferenceToAnthropicModels( { ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5[1m]', ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]', }, @@ -72,11 +76,21 @@ describe('applyExtendedContextPreferenceToAnthropicModels', () => { expect(env).toEqual({ ANTHROPIC_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5', ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', }); }); + it('suffixes the startup-default model key when the preference is on', () => { + const env = applyExtendedContextPreferenceToAnthropicModels( + { ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5' }, + true + ); + + expect(env).toEqual({ ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5[1m]' }); + }); + it('honors a caller compatibility predicate', () => { const env = applyExtendedContextPreferenceToAnthropicModels( {