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,