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',