mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 07:12:18 +00:00
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:
1 parent
3583b544d0
commit
2b780ceae3
6 files changed
+179
-9
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in new issue
Block a user