diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index c7a97bbc..b89917eb 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -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 } ] } diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index ab01fc80..95856df3 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -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 | diff --git a/src/cliproxy/__tests__/thinking-validator.test.ts b/src/cliproxy/__tests__/thinking-validator.test.ts index b6a47489..41be1a1a 100644 --- a/src/cliproxy/__tests__/thinking-validator.test.ts +++ b/src/cliproxy/__tests__/thinking-validator.test.ts @@ -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. diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 98115888..98fb26c9 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -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); + } } diff --git a/src/cliproxy/executor/__tests__/launch-settings.test.ts b/src/cliproxy/executor/__tests__/launch-settings.test.ts index 6080ab27..0b313b84 100644 --- a/src/cliproxy/executor/__tests__/launch-settings.test.ts +++ b/src/cliproxy/executor/__tests__/launch-settings.test.ts @@ -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; + 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' }, diff --git a/src/cliproxy/executor/launch-settings.ts b/src/cliproxy/executor/launch-settings.ts index 5e33bf7e..aadbd96e 100644 --- a/src/cliproxy/executor/launch-settings.ts +++ b/src/cliproxy/executor/launch-settings.ts @@ -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 { return typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index ff2d632b..0b6cb2f8 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -531,10 +531,27 @@ export const MODEL_CATALOG: Partial> = }, 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. */ diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index 57468cef..ebed49c9 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -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(ANTHROPIC_MODEL_ENV_KEYS); +const EXTENDED_CONTEXT_MODEL_ENV_KEY_SET = new Set(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> ): 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 = { ...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; diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index b4eec3e5..d1cdfacf 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -216,20 +216,21 @@ const PRICING_REGISTRY: Record = { 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 = { 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 diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts new file mode 100644 index 00000000..b8422b9f --- /dev/null +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -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(); + }); + }); +}); diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 2df898fc..8202db38 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -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)', () => { diff --git a/tests/unit/shared/extended-context-utils.test.ts b/tests/unit/shared/extended-context-utils.test.ts index 3694f74c..c1ce474a 100644 --- a/tests/unit/shared/extended-context-utils.test.ts +++ b/tests/unit/shared/extended-context-utils.test.ts @@ -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); + }); +}); diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index 35ac28b1..6bec47a4 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -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) => { 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; diff --git a/ui/src/lib/extended-context-utils.ts b/ui/src/lib/extended-context-utils.ts index 2ce76d69..1dbb35b8 100644 --- a/ui/src/lib/extended-context-utils.ts +++ b/ui/src/lib/extended-context-utils.ts @@ -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, diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 32c3d523..265e49e9 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -977,10 +977,22 @@ export const MODEL_CATALOGS: Record = { 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', diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index 06615ca0..d8ce4632 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -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');