Merge remote-tracking branch 'origin/dev' into kai/fix-cliproxy-backend-version-gates

This commit is contained in:
Tam Nhu Tran committed 2026-09-09 15:14:58 -06:00
commit 83c76d826d
16 files changed
+433 -32

No files matched your search

+3 -3
View File
@@ -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
}
]
}
+2 -2
View File
@@ -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 |
@@ -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.
+43 -2
View File
@@ -10,10 +10,11 @@
*/
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,
hasExtendedContextSuffix,
@@ -63,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.
@@ -88,6 +122,9 @@ export function applyExtendedContextConfig(
),
})
);
if (extendedContextOverride) {
fillFableTierForLongContext(envVars, provider);
}
return;
}
@@ -95,7 +132,7 @@ 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;
@@ -113,4 +150,8 @@ export function applyExtendedContextConfig(
envVars[key] = stripExtendedContextSuffix(value);
}
}
if (hasSavedLongContextTier(envVars)) {
fillFableTierForLongContext(envVars, provider);
}
}
@@ -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<string, string>;
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' },
+9 -2
View File
@@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
+29 -1
View File
@@ -531,10 +531,27 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
},
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:
@@ -849,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.
*/
+43 -3
View File
@@ -14,8 +14,43 @@ 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.
*
* 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',
'ANTHROPIC_DEFAULT_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);
/*
* 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.
*/
/** Check if model is a native Gemini model (auto-enabled behavior). */
export function isNativeGeminiModel(modelId: string): boolean {
return modelId.toLowerCase().startsWith('gemini-');
@@ -44,6 +79,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 +93,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,12 +106,12 @@ 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;
+21 -10
View File
@@ -216,20 +216,21 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
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<string, ModelPricing> = {
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
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'bun:test';
import { applyExtendedContextConfig } from '../../../src/cliproxy/config/extended-context-config';
describe('applyExtendedContextConfig', () => {
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]',
};
applyExtendedContextConfig(env, 'claude');
expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]');
expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5[1m]');
});
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',
CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1',
};
applyExtendedContextConfig(env, 'claude', true);
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();
});
});
});
+17 -5
View File
@@ -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)', () => {
@@ -2,7 +2,11 @@ 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', () => {
@@ -22,3 +26,96 @@ 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 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);
});
});
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_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_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(
{
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);
});
});
@@ -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;
+3
View File
@@ -4,10 +4,13 @@
export {
ANTHROPIC_MODEL_ENV_KEYS,
EXTENDED_CONTEXT_MODEL_ENV_KEYS,
EXTENDED_CONTEXT_SUFFIX,
EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS,
applyExtendedContextPreferenceToAnthropicModels,
isNativeGeminiModel,
isAnthropicModelEnvKey,
isExtendedContextModelEnvKey,
hasAnthropicExtendedContextEnabled,
hasExtendedContextSuffix,
applyExtendedContextSuffix,
+13 -1
View File
@@ -977,10 +977,22 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
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',
@@ -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');