Files
ccs/tests/unit/shared/extended-context-utils.test.ts
T
Kenneth Wong 02c45e088a 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
2026-09-04 02:48:15 +08:00

108 lines
3.7 KiB
TypeScript

import { describe, expect, it } from 'bun:test';
import {
ANTHROPIC_MODEL_ENV_KEYS,
EXTENDED_CONTEXT_MODEL_ENV_KEYS,
applyExtendedContextPreferenceToAnthropicModels,
hasAnthropicExtendedContextEnabled,
isAnthropicModelEnvKey,
isExtendedContextModelEnvKey,
} from '../../../src/shared/extended-context-utils';
describe('Anthropic model env keys', () => {
it('includes all five tier mapping keys', () => {
expect([...ANTHROPIC_MODEL_ENV_KEYS]).toEqual([
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_FABLE_MODEL',
]);
});
it('recognizes the fable tier key like the other tiers', () => {
expect(isAnthropicModelEnvKey('ANTHROPIC_DEFAULT_FABLE_MODEL')).toBe(true);
expect(isAnthropicModelEnvKey('ANTHROPIC_DEFAULT_HAIKU_MODEL')).toBe(true);
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);
});
});
describe('applyExtendedContextPreferenceToAnthropicModels', () => {
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',
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[1m]',
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', () => {
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 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);
});
});