From b27a007229388a98bd6254af76b33b1d1ad2135e Mon Sep 17 00:00:00 2001 From: milesnguyen2405 Date: Wed, 22 Jul 2026 14:51:40 +0700 Subject: [PATCH 1/2] feat(ui): add fable tier row in model mapping --- src/shared/extended-context-utils.ts | 1 + src/web-server/routes/settings-routes.ts | 1 + .../shared/extended-context-utils.test.ts | 24 ++++++++++++ .../cliproxy/provider-editor/index.tsx | 2 + .../provider-editor/model-config-section.tsx | 14 ++++++- .../provider-editor/model-config-tab.tsx | 3 ++ .../cliproxy/provider-editor/types.ts | 2 + .../provider-editor/use-provider-editor.ts | 2 + .../model-config-section.test.tsx | 23 +++++++++++ .../use-provider-editor.test.tsx | 38 +++++++++++++++++++ 10 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/unit/shared/extended-context-utils.test.ts diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index d8008462..57468cef 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -9,6 +9,7 @@ export const ANTHROPIC_MODEL_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_FABLE_MODEL', ] as const; export type AnthropicModelEnvKey = (typeof ANTHROPIC_MODEL_ENV_KEYS)[number]; diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 7f26df4e..27b17f26 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -60,6 +60,7 @@ const MODEL_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_FABLE_MODEL', ] as const; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; const SETTINGS_IDENTIFIER_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/; diff --git a/tests/unit/shared/extended-context-utils.test.ts b/tests/unit/shared/extended-context-utils.test.ts new file mode 100644 index 00000000..3694f74c --- /dev/null +++ b/tests/unit/shared/extended-context-utils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'bun:test'; + +import { + ANTHROPIC_MODEL_ENV_KEYS, + isAnthropicModelEnvKey, +} 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); + }); +}); diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index b4df3f7a..b7506691 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -122,6 +122,7 @@ export function ProviderEditor({ opusModel, sonnetModel, haikuModel, + fableModel, extendedContextEnabled, toggleExtendedContext, handleRawJsonChange, @@ -271,6 +272,7 @@ export function ProviderEditor({ opusModel={opusModel} sonnetModel={sonnetModel} haikuModel={haikuModel} + fableModel={fableModel} providerModels={providerModels} routing={routing} extendedContextEnabled={extendedContextEnabled} diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index 52eb5aea..ec64f636 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -43,6 +43,7 @@ export function ModelConfigSection({ opusModel, sonnetModel, haikuModel, + fableModel, providerModels, routing, provider, @@ -67,7 +68,7 @@ export function ModelConfigSection({ const extendedContextModels = useMemo(() => { if (!catalog) return []; - const selectedModels = [currentModel, opusModel, sonnetModel, haikuModel] + const selectedModels = [currentModel, opusModel, sonnetModel, haikuModel, fableModel] .filter((modelId): modelId is string => Boolean(modelId)) .map((modelId) => stripExtendedContextSuffix(modelId)); @@ -75,7 +76,7 @@ export function ModelConfigSection({ return uniqueIds .map((modelId) => findCatalogModel(catalog.provider, modelId, catalog)) .filter((model): model is NonNullable => Boolean(model?.extendedContext)); - }, [catalog, currentModel, opusModel, sonnetModel, haikuModel]); + }, [catalog, currentModel, opusModel, sonnetModel, haikuModel, fableModel]); const resolvedCatalogModels = useMemo( () => getResolvedCatalogModels(catalog, providerModels), @@ -253,6 +254,15 @@ export function ModelConfigSection({ onToggle={onExtendedContextToggle} /> )} + onUpdateEnvValue('ANTHROPIC_DEFAULT_FABLE_MODEL', model)} + catalog={catalog} + allModels={providerModels} + routing={routing} + /> ; routing?: CliproxyProviderRoutingHints; /** Whether extended context (1M tokens) is enabled */ @@ -72,6 +73,7 @@ export function ModelConfigTab({ opusModel, sonnetModel, haikuModel, + fableModel, providerModels, routing, extendedContextEnabled, @@ -163,6 +165,7 @@ export function ModelConfigTab({ opusModel={opusModel} sonnetModel={sonnetModel} haikuModel={haikuModel} + fableModel={fableModel} providerModels={providerModels} routing={routing} provider={provider} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 3e1c6bc2..2f10d017 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -126,6 +126,7 @@ export interface ModelConfigSectionProps { opusModel?: string; sonnetModel?: string; haikuModel?: string; + fableModel?: string; providerModels: Array<{ id: string; owned_by: string }>; routing?: CliproxyProviderRoutingHints; /** Provider name for display */ @@ -154,6 +155,7 @@ export interface UseProviderEditorReturn { opusModel?: string; sonnetModel?: string; haikuModel?: string; + fableModel?: string; /** Whether extended context (1M tokens) is enabled */ extendedContextEnabled: boolean; /** Toggle extended context on/off */ 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 739955c1..35ac28b1 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -85,6 +85,7 @@ export function useProviderEditor( const opusModel = currentSettings?.env?.ANTHROPIC_DEFAULT_OPUS_MODEL; const sonnetModel = currentSettings?.env?.ANTHROPIC_DEFAULT_SONNET_MODEL; const haikuModel = currentSettings?.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL; + const fableModel = currentSettings?.env?.ANTHROPIC_DEFAULT_FABLE_MODEL; // Extended context is enabled if any model has [1m] suffix const extendedContextEnabled = useMemo(() => { @@ -224,6 +225,7 @@ export function useProviderEditor( opusModel, sonnetModel, haikuModel, + fableModel, extendedContextEnabled, toggleExtendedContext, handleRawJsonChange, diff --git a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx index f26e9909..89bf5640 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx @@ -13,6 +13,29 @@ import { ModelConfigSection } from '@/components/cliproxy/provider-editor/model- import { MODEL_CATALOGS } from '@/lib/model-catalogs'; describe('ModelConfigSection presets', () => { + it('renders five tier selectors including the fable row', () => { + render( + + ); + + expect(screen.getAllByTestId('flexible-model-selector')).toHaveLength(5); + }); + it('groups codex presets by free and paid tiers', async () => { const onApplyPreset = vi.fn(); diff --git a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx index 25c3e7ab..f330c78f 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx @@ -152,6 +152,44 @@ describe('useProviderEditor', () => { }); }); + it('derives fableModel and round-trips ANTHROPIC_DEFAULT_FABLE_MODEL updates', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/codex/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'codex', + settings: { + env: { + ANTHROPIC_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.6-sol-xhigh', + }, + }, + mtime: 1, + path: '~/.ccs/codex.settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('codex'), { wrapper }); + + await waitFor(() => expect(result.current.fableModel).toBe('gpt-5.6-sol-xhigh')); + + act(() => { + result.current.updateEnvValue('ANTHROPIC_DEFAULT_FABLE_MODEL', 'gpt-5.6-sol-max'); + }); + + const nextSettings = JSON.parse(result.current.rawJsonContent); + expect(nextSettings.env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('gpt-5.6-sol-max'); + }); + it('preserves explicit codex effort suffixes in editor state updates', async () => { vi.stubGlobal( 'fetch', From f7241d0874088bfbfd498745c28b8d0ea236029b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Jul 2026 14:58:57 -0400 Subject: [PATCH 2/2] fix: propagate fable model mapping across runtime paths --- .../services/profile-lifecycle-validation.ts | 12 ++----- .../__tests__/launch-settings.test.ts | 3 ++ src/utils/shell-executor.ts | 15 ++++----- .../api/profile-lifecycle-service.test.ts | 33 +++++++++++++++++++ .../utils/claudecode-env-stripping.test.ts | 10 ++++++ 5 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/api/services/profile-lifecycle-validation.ts b/src/api/services/profile-lifecycle-validation.ts index d7902584..6897d113 100644 --- a/src/api/services/profile-lifecycle-validation.ts +++ b/src/api/services/profile-lifecycle-validation.ts @@ -10,22 +10,16 @@ import { } from '../../cliproxy/ai-providers/model-id-normalizer'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; +import { ANTHROPIC_MODEL_ENV_KEYS } from '../../shared/extended-context-utils'; import type { ProfileValidationIssue, ProfileValidationSummary } from './profile-types'; -const MODEL_ENV_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', -] as const; - const ALLOWED_ANTHROPIC_ENV_KEYS = new Set([ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', // Written by profile-writer when --extra-models is supplied 'ANTHROPIC_EXTRA_MODELS', - ...MODEL_ENV_KEYS, + ...ANTHROPIC_MODEL_ENV_KEYS, ]); function resolveProviderFromBaseUrl(baseUrl: string): CLIProxyProvider | null { @@ -118,7 +112,7 @@ export function validateApiProfileSettingsPayload(settings: unknown): ProfileVal } const provider = resolveProviderFromBaseUrl(baseUrl); - for (const modelKey of MODEL_ENV_KEYS) { + for (const modelKey of ANTHROPIC_MODEL_ENV_KEYS) { const value = envObj[modelKey]; if (typeof value !== 'string' || value.trim().length === 0) continue; const denyReason = getDeniedModelIdReasonForProvider(value, provider); diff --git a/src/cliproxy/executor/__tests__/launch-settings.test.ts b/src/cliproxy/executor/__tests__/launch-settings.test.ts index b08d27bc..6080ab27 100644 --- a/src/cliproxy/executor/__tests__/launch-settings.test.ts +++ b/src/cliproxy/executor/__tests__/launch-settings.test.ts @@ -37,6 +37,7 @@ describe('buildLaunchSettingsOverlay', () => { env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', ANTHROPIC_MODEL: 'gpt-5.5', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', }, }); @@ -44,12 +45,14 @@ describe('buildLaunchSettingsOverlay', () => { const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex', ANTHROPIC_MODEL: 'gpt-5.5-high', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini-medium', } as NodeJS.ProcessEnv); expect(changed).toBe(true); const env = settings.env as Record; expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:50118/api/provider/codex'); expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5-high'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('gpt-5.4-mini-medium'); // Untouched keys are preserved. expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); }); diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 68a9f1f6..b68b10af 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -18,6 +18,7 @@ import { import SharedManager from '../management/shared-manager'; import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade'; +import { ANTHROPIC_MODEL_ENV_KEYS as SHARED_ANTHROPIC_MODEL_ENV_KEYS } from '../shared/extended-context-utils'; /** * Strip ANTHROPIC_* env vars from an environment object. @@ -40,17 +41,13 @@ export const ANTHROPIC_ROUTING_ENV_KEYS = [ 'ANTHROPIC_API_KEY', ]; const ANTHROPIC_ROUTING_ENV_KEY_SET = new Set(ANTHROPIC_ROUTING_ENV_KEYS); -// NOTE: This is the intentional routing-overlay SUPERSET of model env keys -// (includes ANTHROPIC_SMALL_FAST_MODEL). A separate 4-key `ANTHROPIC_MODEL_ENV_KEYS` -// exists in src/shared/extended-context-utils.ts (re-exported as MODEL_ENV_VAR_KEYS). -// The two are NOT interchangeable — import deliberately by purpose. See issue #1609. +// NOTE: This is the intentional routing-overlay SUPERSET of shared model env keys +// (includes legacy ANTHROPIC_SMALL_FAST_MODEL). Keep the shared list authoritative +// for current tier mappings while preserving the legacy runtime alias. See issue #1609. export const ANTHROPIC_MODEL_ENV_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + ...SHARED_ANTHROPIC_MODEL_ENV_KEYS, 'ANTHROPIC_SMALL_FAST_MODEL', -]; +] as const; const TMUX_SYNC_ENV_KEYS = [ 'CLAUDE_CONFIG_DIR', 'CCS_PROFILE_TYPE', diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index 5d984973..4d124e2b 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -9,6 +9,7 @@ import { importApiProfileBundle, registerApiProfileOrphans, } from '../../../src/api/services/profile-lifecycle-service'; +import { validateApiProfileSettingsPayload } from '../../../src/api/services/profile-lifecycle-validation'; import { createApiProfile } from '../../../src/api/services/profile-writer'; import { loadConfigSafe, @@ -69,6 +70,38 @@ describe('profile lifecycle service', () => { } }); + it('accepts ANTHROPIC_DEFAULT_FABLE_MODEL as a supported model mapping', () => { + const validation = validateApiProfileSettingsPayload({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini', + }, + }); + + expect(validation.valid).toBe(true); + expect(validation.issues).toEqual([]); + }); + + it('enforces the provider denylist for ANTHROPIC_DEFAULT_FABLE_MODEL', () => { + const validation = validateApiProfileSettingsPayload({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-sonnet-4.5', + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.issues).toContainEqual( + expect.objectContaining({ + level: 'error', + code: 'model_denylisted', + field: 'env.ANTHROPIC_DEFAULT_FABLE_MODEL', + }) + ); + }); + it('discovers grandfathered xai/grok orphans while skipping other reserved names', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index ddb3ab71..5187e094 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -264,6 +264,7 @@ describe('CLAUDECODE environment stripping', () => { delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL; delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL; delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; + delete process.env.ANTHROPIC_DEFAULT_FABLE_MODEL; delete process.env.ANTHROPIC_SMALL_FAST_MODEL; delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS; @@ -543,6 +544,7 @@ describe('CLAUDECODE environment stripping', () => { process.env.ANTHROPIC_API_KEY = 'parent-api-key'; process.env.ANTHROPIC_MODEL = 'gpt-5.4'; process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4'; + process.env.ANTHROPIC_DEFAULT_FABLE_MODEL = 'gpt-5.4-mini'; execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'settings', @@ -570,10 +572,18 @@ describe('CLAUDECODE environment stripping', () => { call.args[1] === 'ANTHROPIC_MODEL' && call.args[2] === 'gpt-5.4' ); + const fableModelCall = spawnSyncCalls.find( + (call) => + call.command === 'tmux' && + call.args[0] === 'setenv' && + call.args[1] === 'ANTHROPIC_DEFAULT_FABLE_MODEL' && + call.args[2] === 'gpt-5.4-mini' + ); expect(unsetBaseUrlCall).toBeDefined(); expect(unsetAuthTokenCall).toBeDefined(); expect(modelCall).toBeDefined(); + expect(fableModelCall).toBeDefined(); }); it('headless executor spawn path strips CLAUDECODE before spawn', async () => {