mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 07:12:18 +00:00
Merge remote-tracking branch 'origin/dev' into kai/fix-cliproxy-backend-version-gates
This commit is contained in:
commit
83c76d826d
16 files changed
+433
-32
No files matched your search
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user