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
This commit is contained in:
Kenneth Wong committed 2026-09-03 17:08:23 +08:00
1 parent 3583b544d0
commit 2b780ceae3
6 files changed
+179 -9

No files matched your search

+10 -2
View File
@@ -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)
+45 -4
View File
@@ -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<string>(ANTHROPIC_MODEL_ENV_KEYS);
const EXTENDED_CONTEXT_MODEL_ENV_KEY_SET = new Set<string>(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<string>(['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<Record<string, string | undefined>>
): 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<string, string | undefined> = { ...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);
}
@@ -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]');
});
});
@@ -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);
});
});
@@ -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<string, string>) => {
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;
+4
View File
@@ -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,