Merge pull request #1652 from minhbi245/feat/fable-tier-mapping

feat(ui): add fable tier row in model mapping
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-07-22 14:59:24 -04:00
commit 9cbf02b1be
15 files changed
+163 -20

No files matched your search

@@ -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<string>([
'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);
@@ -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<string, string>;
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');
});
+1
View File
@@ -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];
+6 -9
View File
@@ -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',
+1
View File
@@ -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._-]*$/;
@@ -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 });
@@ -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);
});
});
@@ -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 () => {
@@ -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}
@@ -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<typeof model> => 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}
/>
)}
<FlexibleModelSelector
label="Fable (Frontier)"
description="Maps the fable subagent tier; leave empty to keep the native Claude Fable model"
value={fableModel}
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_FABLE_MODEL', model)}
catalog={catalog}
allModels={providerModels}
routing={routing}
/>
<FlexibleModelSelector
label="Opus (Most capable)"
description="For complex reasoning tasks"
@@ -27,6 +27,7 @@ interface ModelConfigTabProps {
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
fableModel?: string;
providerModels: Array<{ id: string; owned_by: string }>;
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}
@@ -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 */
@@ -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,
@@ -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(
<ModelConfigSection
catalog={MODEL_CATALOGS.codex}
savedPresets={[]}
currentModel="gpt-5-codex"
opusModel="gpt-5-codex"
sonnetModel="gpt-5-codex"
haikuModel="gpt-5-codex-mini"
fableModel="gpt-5.6-sol-xhigh"
providerModels={[]}
provider="codex"
onExtendedContextToggle={vi.fn()}
onApplyPreset={vi.fn()}
onUpdateEnvValue={vi.fn()}
onOpenCustomPreset={vi.fn()}
onDeletePreset={vi.fn()}
/>
);
expect(screen.getAllByTestId('flexible-model-selector')).toHaveLength(5);
});
it('groups codex presets by free and paid tiers', async () => {
const onApplyPreset = vi.fn();
@@ -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',