From 4d5e52828b9d20759bf20e7b4353192fbecfc868 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 17:41:15 -0400 Subject: [PATCH] feat(cliproxy): add pinned model routing prefixes --- src/cliproxy/catalog-routing.ts | 39 ++++ src/cliproxy/managed-model-prefixes.ts | 116 ++++++++++ src/commands/cliproxy/catalog-subcommand.ts | 69 +++++- src/commands/cliproxy/variant-subcommand.ts | 34 ++- src/shared/cliproxy-model-routing.ts | 205 ++++++++++++++++++ src/web-server/routes/catalog-routes.ts | 5 +- src/web-server/routes/cliproxy-auth-routes.ts | 24 ++ .../unit/cliproxy/model-routing-hints.test.ts | 64 ++++++ .../provider-editor/custom-preset-dialog.tsx | 5 + .../cliproxy/provider-editor/index.tsx | 3 + .../provider-editor/model-config-section.tsx | 34 ++- .../provider-editor/model-config-tab.tsx | 5 +- .../cliproxy/provider-editor/types.ts | 10 +- .../cliproxy/provider-model-selector.tsx | 110 +++++++++- ui/src/lib/api-client.ts | 24 ++ ui/src/pages/cliproxy.tsx | 3 + .../model-config-section.test.tsx | 65 ++++++ 17 files changed, 791 insertions(+), 24 deletions(-) create mode 100644 src/cliproxy/catalog-routing.ts create mode 100644 src/cliproxy/managed-model-prefixes.ts create mode 100644 src/shared/cliproxy-model-routing.ts create mode 100644 tests/unit/cliproxy/model-routing-hints.test.ts diff --git a/src/cliproxy/catalog-routing.ts b/src/cliproxy/catalog-routing.ts new file mode 100644 index 00000000..fc373df8 --- /dev/null +++ b/src/cliproxy/catalog-routing.ts @@ -0,0 +1,39 @@ +import { + buildCliproxyRoutingHints, + type CliproxyProviderRoutingHints, +} from '../shared/cliproxy-model-routing'; +import { fetchCliproxyModels } from './stats-fetcher'; +import { ensureManagedModelPrefixes } from './managed-model-prefixes'; +import { + getResolvedCatalogSnapshot, + type CatalogSource, + type ResolvedCatalogSnapshot, +} from './catalog-cache'; +import type { ProviderCatalog } from './model-catalog'; +import type { CLIProxyProvider } from './types'; + +export interface CatalogRoutingSnapshot { + catalogs: Partial>; + source: CatalogSource; + cacheAge: string | null; + routing: Partial>; +} + +export async function getCatalogRoutingSnapshot(): Promise { + try { + await ensureManagedModelPrefixes(); + } catch { + // Keep catalog rendering non-fatal when prefix sync is unavailable. + } + + const snapshot: ResolvedCatalogSnapshot = await getResolvedCatalogSnapshot(); + const modelsResponse = await fetchCliproxyModels(); + const routing = buildCliproxyRoutingHints(snapshot.catalogs, modelsResponse?.models ?? []); + + return { + catalogs: snapshot.catalogs, + source: snapshot.source, + cacheAge: snapshot.cacheAge, + routing, + }; +} diff --git a/src/cliproxy/managed-model-prefixes.ts b/src/cliproxy/managed-model-prefixes.ts new file mode 100644 index 00000000..6215ec67 --- /dev/null +++ b/src/cliproxy/managed-model-prefixes.ts @@ -0,0 +1,116 @@ +import { getManagedModelPrefix } from '../shared/cliproxy-model-routing'; +import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver'; +import { mapExternalProviderName } from './provider-capabilities'; +import type { CLIProxyProvider } from './types'; + +interface ManagementAuthFileRecord { + account_type?: string; + name: string; + provider?: string; + type?: string; +} + +export interface ManagedPrefixSyncResult { + checked: number; + updated: number; +} + +function normalizeProvider(record: ManagementAuthFileRecord): CLIProxyProvider | null { + const providerName = record.provider?.trim() || record.type?.trim() || ''; + return providerName ? mapExternalProviderName(providerName) : null; +} + +async function listAuthFiles(): Promise { + const target = getProxyTarget(); + const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), { + headers: buildManagementHeaders(target), + }); + + if (!response.ok) { + throw new Error(`auth file listing failed with status ${response.status}`); + } + + const data = (await response.json()) as { files?: ManagementAuthFileRecord[] }; + return Array.isArray(data.files) ? data.files : []; +} + +async function patchAuthFilePrefix(name: string, prefix: string): Promise { + const target = getProxyTarget(); + const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files/fields'), { + method: 'PATCH', + headers: buildManagementHeaders(target, { + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ name, prefix }), + }); + + if (!response.ok) { + throw new Error(`auth file prefix patch failed for ${name} with status ${response.status}`); + } +} + +async function readAuthFilePrefix(name: string): Promise { + const target = getProxyTarget(); + const url = buildProxyUrl( + target, + `/v0/management/auth-files/download?name=${encodeURIComponent(name)}` + ); + const response = await fetch(url, { + headers: buildManagementHeaders(target), + }); + + if (!response.ok) { + throw new Error(`auth file download failed for ${name} with status ${response.status}`); + } + + const content = await response.text(); + try { + const parsed = JSON.parse(content) as { prefix?: unknown }; + return typeof parsed.prefix === 'string' ? parsed.prefix.trim() : null; + } catch { + return null; + } +} + +export async function ensureManagedModelPrefixes( + providers?: CLIProxyProvider[] +): Promise { + const files = await listAuthFiles(); + const allowedProviders = new Set((providers ?? []).map((provider) => provider.trim())); + let checked = 0; + let updated = 0; + + for (const record of files) { + if ((record.account_type || '').trim().toLowerCase() !== 'oauth') { + continue; + } + + const provider = normalizeProvider(record); + if (!provider) { + continue; + } + + if (allowedProviders.size > 0 && !allowedProviders.has(provider)) { + continue; + } + + const prefix = getManagedModelPrefix(provider); + if (!prefix) { + continue; + } + + try { + checked += 1; + const currentPrefix = await readAuthFilePrefix(record.name); + if (currentPrefix === prefix) { + continue; + } + await patchAuthFilePrefix(record.name, prefix); + updated += 1; + } catch { + // Best-effort repair: skip files that cannot be read or patched. + } + } + + return { checked, updated }; +} diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 8bee3556..a26d05fb 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -6,9 +6,11 @@ import { getResolvedCatalog, refreshCatalogFromProxy, } from '../../cliproxy/catalog-cache'; +import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; import type { CLIProxyProvider } from '../../cliproxy/types'; import type { RemoteModelInfo } from '../../cliproxy/management-api-types'; +import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing'; /** Fetch model definitions from CLIProxyAPI for all syncable providers */ async function fetchRemoteCatalogs( @@ -44,7 +46,8 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(header('Model Catalog')); console.log(''); - const cacheAge = getCacheAge(); + const routingSnapshot = await getCatalogRoutingSnapshot(); + const cacheAge = routingSnapshot.cacheAge ?? getCacheAge(); if (cacheAge) { console.log(` Cache: ${color('synced', 'success')} (${cacheAge})`); } else { @@ -55,14 +58,14 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(subheader('Providers:')); for (const provider of SYNCABLE_PROVIDERS) { - const catalog = getResolvedCatalog(provider); + const catalog = routingSnapshot.catalogs[provider] ?? getResolvedCatalog(provider); if (catalog) { const count = catalog.models.length; - console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models`); + const routing = routingSnapshot.routing[provider]; + const suffix = renderRoutingSummary(routing); + console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models${suffix}`); if (verbose) { - for (const model of catalog.models) { - console.log(dim(` - ${model.id} (${model.name})`)); - } + renderVerboseRouting(provider, catalog.models, routing); } } } @@ -74,6 +77,60 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(''); } +function renderRoutingSummary(routing: CliproxyProviderRoutingHints | undefined): string { + if (!routing) { + return ''; + } + + const parts = [`prefix ${routing.prefix}`]; + if (routing.shadowedCount > 0) { + parts.push(`${routing.shadowedCount} shadowed`); + } + if (routing.prefixOnlyCount > 0) { + parts.push(`${routing.prefixOnlyCount} prefix-only`); + } + return parts.length > 0 ? ` ${dim(`(${parts.join(', ')})`)}` : ''; +} + +function renderVerboseRouting( + provider: CLIProxyProvider, + models: Array<{ id: string; name: string }>, + routing: CliproxyProviderRoutingHints | undefined +): void { + if (!routing) { + for (const model of models) { + console.log(dim(` - ${model.id} (${model.name})`)); + } + return; + } + + const routingMap = new Map(routing.models.map((hint) => [hint.modelId, hint])); + for (const model of models) { + const hint = routingMap.get(model.id); + console.log(dim(` - ${model.id} (${model.name})`)); + if (!hint) { + continue; + } + + console.log(dim(` preferred: ${hint.recommendedModelId}`)); + if (hint.unprefixedStatus === 'safe') { + console.log(dim(` unprefixed: resolves to ${routing.displayName}`)); + continue; + } + + if (hint.unprefixedStatus === 'shadowed' && hint.effectiveDisplayName) { + console.log(dim(` unprefixed: currently resolves to ${hint.effectiveDisplayName}`)); + continue; + } + + console.log(dim(` unprefixed: not advertised, use ${hint.recommendedModelId}`)); + } + + if (provider === 'gemini' || provider === 'agy') { + console.log(dim(` short prefix stays backend-pinned even when unprefixed names overlap.`)); + } +} + /** Refresh catalog from CLIProxyAPI */ export async function handleCatalogRefresh(verbose: boolean): Promise { await initUI(); diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index be9cae13..3a30533c 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -11,6 +11,8 @@ import { getProviderAccounts } from '../../cliproxy/account-manager'; import { triggerOAuth } from '../../cliproxy/auth/oauth-handler'; import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog'; +import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; +import { getManagedModelPrefix } from '../../shared/cliproxy-model-routing'; import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types'; import type { TargetType } from '../../targets/target-adapter'; import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; @@ -115,6 +117,11 @@ function formatModelOption(model: ModelEntry): string { return `${model.name}${tierBadge}`; } +function getSelectableModelId(provider: CLIProxyProvider, modelId: string): string { + const prefix = getManagedModelPrefix(provider); + return prefix ? `${prefix}/${modelId}` : modelId; +} + function getBackendLabel(backend: CLIProxyBackend): string { return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; } @@ -174,9 +181,17 @@ async function selectTierConfig( // Select model let model: string | undefined; if (supportsModelConfig(provider as CLIProxyProvider)) { + try { + await ensureManagedModelPrefixes([provider as CLIProxyProvider]); + } catch { + // Keep interactive model selection available even when prefix repair fails. + } const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { - const modelOptions = catalog.models.map((m) => ({ id: m.id, label: formatModelOption(m) })); + const modelOptions = catalog.models.map((m) => ({ + id: getSelectableModelId(provider as CLIProxyProvider, m.id), + label: formatModelOption(m), + })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); model = await InteractivePrompt.selectFromList(`Model for ${tierName}:`, modelOptions, { defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, @@ -426,9 +441,17 @@ export async function handleCreate( let model = parsedArgs.model; if (!model) { if (supportsModelConfig(provider as CLIProxyProvider)) { + try { + await ensureManagedModelPrefixes([provider as CLIProxyProvider]); + } catch { + // Keep variant creation available even when prefix repair fails. + } const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { - const modelOptions = catalog.models.map((m) => ({ id: m.id, label: formatModelOption(m) })); + const modelOptions = catalog.models.map((m) => ({ + id: getSelectableModelId(provider as CLIProxyProvider, m.id), + label: formatModelOption(m), + })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); model = await InteractivePrompt.selectFromList('Select model:', modelOptions, { defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, @@ -667,10 +690,15 @@ export async function handleEdit( if (changeModel) { const providerForModel = newProvider || (variant.provider as CLIProxyProfileName); if (supportsModelConfig(providerForModel as CLIProxyProvider)) { + try { + await ensureManagedModelPrefixes([providerForModel as CLIProxyProvider]); + } catch { + // Keep edit flow available even when prefix repair fails. + } const catalog = getProviderCatalog(providerForModel as CLIProxyProvider); if (catalog) { const modelOptions = catalog.models.map((m) => ({ - id: m.id, + id: getSelectableModelId(providerForModel as CLIProxyProvider, m.id), label: formatModelOption(m), })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); diff --git a/src/shared/cliproxy-model-routing.ts b/src/shared/cliproxy-model-routing.ts new file mode 100644 index 00000000..dcd06724 --- /dev/null +++ b/src/shared/cliproxy-model-routing.ts @@ -0,0 +1,205 @@ +export const MANAGED_MODEL_PREFIXES = { + gemini: 'gcli', + agy: 'agy', +} as const; + +export type ManagedModelPrefixProvider = keyof typeof MANAGED_MODEL_PREFIXES; +export type ModelRoutingStatus = 'safe' | 'shadowed' | 'prefix-only'; + +export interface CatalogLikeModel { + id: string; + name?: string; +} + +export interface CatalogLikeProvider { + provider: string; + displayName: string; + models: CatalogLikeModel[]; +} + +export interface MergedModelLike { + id: string; + owned_by?: string; + type?: string; +} + +export interface CliproxyModelRoutingHint { + modelId: string; + modelName: string; + prefix: string; + pinnedModelId: string; + recommendedModelId: string; + unprefixedStatus: ModelRoutingStatus; + effectiveProvider: string | null; + effectiveDisplayName: string | null; + effectiveOwnedBy: string | null; + summary: string; +} + +export interface CliproxyProviderRoutingHints { + provider: string; + displayName: string; + prefix: string; + safeCount: number; + shadowedCount: number; + prefixOnlyCount: number; + models: CliproxyModelRoutingHint[]; +} + +const PROVIDER_OWNER_HINTS: Record = { + gemini: ['google'], + agy: ['antigravity'], + claude: ['anthropic'], + codex: ['openai'], + qwen: ['alibaba', 'qwen'], + iflow: ['iflow'], + kimi: ['kimi', 'moonshot'], + kiro: ['kiro', 'aws'], + ghcp: ['github', 'copilot'], +}; + +function normalize(value: string | null | undefined): string { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +function getManagedPrefix(provider: string): string | null { + const normalizedProvider = normalize(provider); + if (normalizedProvider in MANAGED_MODEL_PREFIXES) { + return MANAGED_MODEL_PREFIXES[normalizedProvider as ManagedModelPrefixProvider]; + } + return null; +} + +function getDisplayName( + provider: string, + catalogs: Partial> +): string | null { + const normalizedProvider = normalize(provider); + const catalog = catalogs[normalizedProvider]; + return catalog?.displayName?.trim() || null; +} + +function inferProvider(model: MergedModelLike): string | null { + const type = normalize(model.type); + const owner = normalize(model.owned_by); + + const directMatches: Record = { + antigravity: 'agy', + 'github-copilot': 'ghcp', + copilot: 'ghcp', + anthropic: 'claude', + 'gemini-cli': 'gemini', + }; + + if (type && directMatches[type]) { + return directMatches[type]; + } + + for (const [provider, hints] of Object.entries(PROVIDER_OWNER_HINTS)) { + if (hints.some((hint) => type.includes(hint) || owner.includes(hint))) { + return provider; + } + } + + return null; +} + +function buildSummary( + providerDisplayName: string, + hint: Pick< + CliproxyModelRoutingHint, + 'modelId' | 'pinnedModelId' | 'unprefixedStatus' | 'effectiveDisplayName' + > +): string { + if (hint.unprefixedStatus === 'safe') { + return `${hint.modelId} currently resolves to ${providerDisplayName}. Use ${hint.pinnedModelId} to keep it pinned.`; + } + + if (hint.unprefixedStatus === 'shadowed' && hint.effectiveDisplayName) { + return `${hint.modelId} currently resolves to ${hint.effectiveDisplayName}. Use ${hint.pinnedModelId} to force ${providerDisplayName}.`; + } + + return `${hint.modelId} is not advertised unprefixed right now. Use ${hint.pinnedModelId} to target ${providerDisplayName}.`; +} + +export function buildCliproxyRoutingHints( + catalogs: Partial>, + mergedModels: MergedModelLike[] +): Partial> { + const mergedModelMap = new Map(); + for (const model of mergedModels) { + const key = normalize(model.id); + if (key && !mergedModelMap.has(key)) { + mergedModelMap.set(key, model); + } + } + + const result: Partial> = {}; + + for (const [providerKey, catalog] of Object.entries(catalogs)) { + if (!catalog) { + continue; + } + + const prefix = getManagedPrefix(providerKey); + if (!prefix) { + continue; + } + + let safeCount = 0; + let shadowedCount = 0; + let prefixOnlyCount = 0; + + const models = catalog.models.map((model) => { + const mergedModel = mergedModelMap.get(normalize(model.id)); + const effectiveProvider = mergedModel ? inferProvider(mergedModel) : null; + const effectiveDisplayName = + effectiveProvider && getDisplayName(effectiveProvider, catalogs) + ? getDisplayName(effectiveProvider, catalogs) + : mergedModel?.owned_by?.trim() || null; + + let unprefixedStatus: ModelRoutingStatus = 'prefix-only'; + if (!mergedModel) { + prefixOnlyCount += 1; + } else if (effectiveProvider === providerKey) { + unprefixedStatus = 'safe'; + safeCount += 1; + } else { + unprefixedStatus = 'shadowed'; + shadowedCount += 1; + } + + const hint: CliproxyModelRoutingHint = { + modelId: model.id, + modelName: model.name?.trim() || model.id, + prefix, + pinnedModelId: `${prefix}/${model.id}`, + recommendedModelId: `${prefix}/${model.id}`, + unprefixedStatus, + effectiveProvider, + effectiveDisplayName, + effectiveOwnedBy: mergedModel?.owned_by?.trim() || null, + summary: '', + }; + + hint.summary = buildSummary(catalog.displayName, hint); + return hint; + }); + + result[providerKey] = { + provider: providerKey, + displayName: catalog.displayName, + prefix, + safeCount, + shadowedCount, + prefixOnlyCount, + models, + }; + } + + return result; +} + +export function getManagedModelPrefix(provider: string): string | null { + return getManagedPrefix(provider); +} diff --git a/src/web-server/routes/catalog-routes.ts b/src/web-server/routes/catalog-routes.ts index c2cfba08..ea27811b 100644 --- a/src/web-server/routes/catalog-routes.ts +++ b/src/web-server/routes/catalog-routes.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from 'express'; -import { getResolvedCatalogSnapshot } from '../../cliproxy/catalog-cache'; +import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; const router = Router(); @@ -9,9 +9,10 @@ const router = Router(); */ router.get('/', async (_req: Request, res: Response): Promise => { try { - const snapshot = await getResolvedCatalogSnapshot(); + const snapshot = await getCatalogRoutingSnapshot(); res.json({ catalogs: snapshot.catalogs, + routing: snapshot.routing, source: snapshot.source, cache: { synced: snapshot.source !== 'static' || snapshot.cacheAge !== null, diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 1744da18..8cf2d4b0 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -32,6 +32,7 @@ import { buildManagementHeaders, } from '../../cliproxy/proxy-target-resolver'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; +import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; import { @@ -315,6 +316,12 @@ export function getStartAuthNicknameError( */ router.get('/', async (_req: Request, res: Response): Promise => { try { + try { + await ensureManagedModelPrefixes(); + } catch { + // Keep auth status available even when prefix repair cannot run. + } + // Check if remote mode is enabled const target = getProxyTarget(); if (target.isRemote) { @@ -386,6 +393,12 @@ router.get('/', async (_req: Request, res: Response): Promise => { */ router.get('/accounts', async (_req: Request, res: Response): Promise => { try { + try { + await ensureManagedModelPrefixes(); + } catch { + // Non-fatal: account listing should still work without prefix repair. + } + // Check if remote mode is enabled const target = getProxyTarget(); if (target.isRemote) { @@ -677,6 +690,12 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise { + it('uses short managed prefixes for overlapping Gemini and Antigravity models', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }], + }, + agy: { + provider: 'agy', + displayName: 'Antigravity', + models: [{ id: 'gemini-3-flash', name: 'Gemini 3 Flash' }], + }, + }, + [ + { id: 'gemini-3-flash-preview', owned_by: 'antigravity', type: 'antigravity' }, + { id: 'gemini-3-flash', owned_by: 'antigravity', type: 'antigravity' }, + ] + ); + + expect(getManagedModelPrefix('gemini')).toBe('gcli'); + expect(getManagedModelPrefix('agy')).toBe('agy'); + + expect(routing.gemini?.models[0]).toMatchObject({ + recommendedModelId: 'gcli/gemini-3-flash-preview', + unprefixedStatus: 'shadowed', + effectiveProvider: 'agy', + effectiveDisplayName: 'Antigravity', + }); + + expect(routing.agy?.models[0]).toMatchObject({ + recommendedModelId: 'agy/gemini-3-flash', + unprefixedStatus: 'safe', + effectiveProvider: 'agy', + }); + }); + + it('marks models as prefix-only when they are not advertised unprefixed', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro' }], + }, + }, + [] + ); + + expect(routing.gemini?.prefixOnlyCount).toBe(1); + expect(routing.gemini?.models[0]).toMatchObject({ + recommendedModelId: 'gcli/gemini-3.1-pro-preview', + unprefixedStatus: 'prefix-only', + effectiveProvider: null, + }); + }); +}); diff --git a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx index 6a494631..3b3671e2 100644 --- a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx +++ b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx @@ -29,6 +29,7 @@ export function CustomPresetDialog({ isSaving, catalog, allModels, + routing, }: CustomPresetDialogProps) { const { t } = useTranslation(); const [values, setValues] = useState(currentValues); @@ -78,6 +79,7 @@ export function CustomPresetDialog({ onChange={(model) => setValues({ ...values, default: model })} catalog={catalog} allModels={allModels} + routing={routing} /> setValues({ ...values, opus: model })} catalog={catalog} allModels={allModels} + routing={routing} /> setValues({ ...values, sonnet: model })} catalog={catalog} allModels={allModels} + routing={routing} /> setValues({ ...values, haiku: model })} catalog={catalog} allModels={allModels} + routing={routing} /> diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index bf0bac22..644d3d9c 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -33,6 +33,7 @@ export function ProviderEditor({ displayName, authStatus, catalog, + routing, logoProvider, baseProvider, isRemoteMode, @@ -263,6 +264,7 @@ export function ProviderEditor({ sonnetModel={sonnetModel} haikuModel={haikuModel} providerModels={providerModels} + routing={routing} extendedContextEnabled={extendedContextEnabled} onExtendedContextToggle={toggleExtendedContext} onApplyPreset={handleApplyPreset} @@ -349,6 +351,7 @@ export function ProviderEditor({ isSaving={createPresetMutation.isPending} catalog={catalog} allModels={providerModels} + routing={routing} /> ); 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 0f9deffb..52163244 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -16,7 +16,10 @@ import type { ModelConfigSectionProps } from './types'; type CatalogPresetModel = NonNullable['models'][number]; -function getPresetUpdates(model: CatalogPresetModel): Record { +function getPresetUpdates( + model: CatalogPresetModel, + toPreferredModelId: (modelId: string) => string +): Record { const mapping = model.presetMapping || { default: model.id, opus: model.id, @@ -25,10 +28,10 @@ function getPresetUpdates(model: CatalogPresetModel): Record { }; return { - ANTHROPIC_MODEL: mapping.default, - ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus, - ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet, - ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku, + ANTHROPIC_MODEL: toPreferredModelId(mapping.default), + ANTHROPIC_DEFAULT_OPUS_MODEL: toPreferredModelId(mapping.opus), + ANTHROPIC_DEFAULT_SONNET_MODEL: toPreferredModelId(mapping.sonnet), + ANTHROPIC_DEFAULT_HAIKU_MODEL: toPreferredModelId(mapping.haiku), }; } @@ -40,6 +43,7 @@ export function ModelConfigSection({ sonnetModel, haikuModel, providerModels, + routing, provider, extendedContextEnabled, onExtendedContextToggle, @@ -49,6 +53,14 @@ export function ModelConfigSection({ onDeletePreset, isDeletePending, }: ModelConfigSectionProps) { + const routingHintMap = useMemo( + () => + new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)), + [routing] + ); + const toPreferredModelId = (modelId: string): string => + routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId; + const extendedContextModels = useMemo(() => { if (!catalog) return []; @@ -126,7 +138,7 @@ export function ModelConfigSection({ variant="outline" size="sm" className="text-xs h-7 gap-1" - onClick={() => onApplyPreset(getPresetUpdates(model))} + onClick={() => onApplyPreset(getPresetUpdates(model, toPreferredModelId))} > Configure which models to use for each tier

+ {routing ? ( +

+ Preferred pinned model names use the {routing.prefix}/ prefix. Unprefixed + names can still resolve to a different backend when providers overlap. +

+ ) : null} {provider === 'codex' && (

Codex tip: suffixes -medium, -high, and -xhigh{' '} @@ -209,6 +227,7 @@ export function ModelConfigSection({ onChange={(model) => onUpdateEnvValue('ANTHROPIC_MODEL', model)} catalog={catalog} allModels={providerModels} + routing={routing} /> {/* Extended Context Toggle - shows when any saved mapping supports it */} {extendedContextModels.length > 0 && onExtendedContextToggle && ( @@ -226,6 +245,7 @@ export function ModelConfigSection({ onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)} catalog={catalog} allModels={providerModels} + routing={routing} /> onUpdateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)} catalog={catalog} allModels={providerModels} + routing={routing} /> onUpdateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)} catalog={catalog} allModels={providerModels} + routing={routing} /> diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx index 320a0495..5ba8219e 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -10,7 +10,7 @@ import { ModelConfigSection } from './model-config-section'; import { AccountsSection } from './accounts-section'; import { api } from '@/lib/api-client'; import type { ProviderCatalog } from '../provider-model-selector'; -import type { OAuthAccount } from '@/lib/api-client'; +import type { OAuthAccount, CliproxyProviderRoutingHints } from '@/lib/api-client'; import { QUOTA_SUPPORTED_PROVIDERS, type QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats'; interface ModelConfigTabProps { @@ -28,6 +28,7 @@ interface ModelConfigTabProps { sonnetModel?: string; haikuModel?: string; providerModels: Array<{ id: string; owned_by: string }>; + routing?: CliproxyProviderRoutingHints; /** Whether extended context (1M tokens) is enabled */ extendedContextEnabled?: boolean; /** Callback when extended context toggle changes */ @@ -71,6 +72,7 @@ export function ModelConfigTab({ sonnetModel, haikuModel, providerModels, + routing, extendedContextEnabled, onExtendedContextToggle, onApplyPreset, @@ -160,6 +162,7 @@ export function ModelConfigTab({ sonnetModel={sonnetModel} haikuModel={haikuModel} providerModels={providerModels} + routing={routing} provider={provider} extendedContextEnabled={extendedContextEnabled} onExtendedContextToggle={onExtendedContextToggle} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index ebb7dde4..fc49b7e3 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -3,7 +3,12 @@ */ import type { ReactNode } from 'react'; -import type { AuthStatus, OAuthAccount, CliTarget } from '@/lib/api-client'; +import type { + AuthStatus, + OAuthAccount, + CliTarget, + CliproxyProviderRoutingHints, +} from '@/lib/api-client'; import type { ProviderCatalog } from '../provider-model-selector'; export interface SettingsResponse { @@ -20,6 +25,7 @@ export interface ProviderEditorProps { displayName: string; authStatus: AuthStatus; catalog?: ProviderCatalog; + routing?: CliproxyProviderRoutingHints; /** Provider type for logo display (defaults to provider) */ logoProvider?: string; /** Base provider for model filtering (defaults to provider). For variants, this is the parent provider. */ @@ -92,6 +98,7 @@ export interface CustomPresetDialogProps { isSaving?: boolean; catalog?: ProviderCatalog; allModels: { id: string; owned_by: string }[]; + routing?: CliproxyProviderRoutingHints; } export interface RawEditorSectionProps { @@ -118,6 +125,7 @@ export interface ModelConfigSectionProps { sonnetModel?: string; haikuModel?: string; providerModels: Array<{ id: string; owned_by: string }>; + routing?: CliproxyProviderRoutingHints; /** Provider name for display */ provider: string; /** Whether extended context (1M tokens) is enabled */ diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 845c2910..d2924f50 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; import { SearchableSelect } from '@/components/ui/searchable-select'; import { Skeleton } from '@/components/ui/skeleton'; +import type { CliproxyProviderRoutingHints } from '@/lib/api-client'; import { getCodexEffortDisplay } from '@/lib/codex-effort'; import { getResolvedCatalogModels } from '@/lib/model-catalogs'; import { cn } from '@/lib/utils'; @@ -291,9 +292,20 @@ interface FlexibleModelSelectorProps { onChange: (model: string) => void; catalog?: ProviderCatalog; allModels: { id: string; owned_by: string }[]; + routing?: CliproxyProviderRoutingHints; disabled?: boolean; } +function normalizeModelValue( + value: string | undefined, + routing?: CliproxyProviderRoutingHints +): string { + if (!value) return ''; + if (!routing?.prefix) return value; + const prefix = `${routing.prefix}/`; + return value.startsWith(prefix) ? value.slice(prefix.length) : value; +} + export function FlexibleModelSelector({ label, description, @@ -301,6 +313,7 @@ export function FlexibleModelSelector({ onChange, catalog, allModels, + routing, disabled, }: FlexibleModelSelectorProps) { const { t } = useTranslation(); @@ -310,22 +323,50 @@ export function FlexibleModelSelector({ [allModels, catalog] ); const catalogModelIds = new Set(resolvedCatalogModels.map((model) => model.id)); + const routingHints = useMemo( + () => + new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)), + [routing] + ); + const selectedRoutingHint = useMemo( + () => routingHints.get(normalizeModelValue(value, routing).toLowerCase()), + [routing, routingHints, value] + ); const recommendedOptions = resolvedCatalogModels.map((model) => ({ - value: model.id, + value: routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id, groupKey: 'recommended', - searchText: `${model.id} ${model.name}`, + searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`, keywords: [model.tier ?? '', catalog?.provider ?? ''], triggerContent: (

- {model.id} + + {routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id} + + {routingHints.get(model.id.toLowerCase()) ? ( + + {routingHints.get(model.id.toLowerCase())?.prefix} + + ) : null} {isCodexProvider && }
), itemContent: (
- {model.id} + + {routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id} + {model.tier === 'paid' && } + {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( + + Shadowed + + ) : null} + {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( + + Prefix only + + ) : null} {isCodexProvider && }
), @@ -351,6 +392,33 @@ export function FlexibleModelSelector({ ), })); + const selectedValueMissing = + Boolean(value) && + !recommendedOptions.some((option) => option.value === value) && + !allModelOptions.some((option) => option.value === value); + const legacySelectedOption = value + ? { + value, + groupKey: 'current', + searchText: value, + triggerContent: ( +
+ {value} + + Current + +
+ ), + itemContent: ( +
+ {value} + + Current + +
+ ), + } + : null; const hasAvailableModels = recommendedOptions.length + allModelOptions.length > 0; return ( @@ -372,6 +440,14 @@ export function FlexibleModelSelector({ } triggerClassName="h-9" groups={[ + ...(selectedValueMissing && legacySelectedOption + ? [ + { + key: 'current', + label: Current value, + }, + ] + : []), { key: 'recommended', label: ( @@ -387,8 +463,32 @@ export function FlexibleModelSelector({ ), }, ]} - options={[...recommendedOptions, ...allModelOptions]} + options={[ + ...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []), + ...recommendedOptions, + ...allModelOptions, + ]} /> + {selectedRoutingHint ? ( +
+
+ Preferred pinned model: {selectedRoutingHint.recommendedModelId} +
+

{selectedRoutingHint.summary}

+
+ ) : null} + {value && !selectedRoutingHint && normalizeModelValue(value, routing) !== value ? ( +
+ Using pinned model: {value} +
+ ) : null} ); } diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 54c491c0..9b28b188 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -468,6 +468,29 @@ export interface CliproxyCatalogModel { presetMapping?: CliproxyCatalogPresetMapping; } +export interface CliproxyModelRoutingHint { + modelId: string; + modelName: string; + prefix: string; + pinnedModelId: string; + recommendedModelId: string; + unprefixedStatus: 'safe' | 'shadowed' | 'prefix-only'; + effectiveProvider: string | null; + effectiveDisplayName: string | null; + effectiveOwnedBy: string | null; + summary: string; +} + +export interface CliproxyProviderRoutingHints { + provider: string; + displayName: string; + prefix: string; + safeCount: number; + shadowedCount: number; + prefixOnlyCount: number; + models: CliproxyModelRoutingHint[]; +} + export interface CliproxyProviderCatalog { provider: string; displayName: string; @@ -477,6 +500,7 @@ export interface CliproxyProviderCatalog { export interface CliproxyCatalogResponse { catalogs: Partial>; + routing?: Partial>; source: 'live' | 'cache' | 'static'; cache: { synced: boolean; diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index ee21191f..7c5b1e8f 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -264,6 +264,7 @@ export function CliproxyPage() { const isRemoteMode = authData?.source === 'remote'; const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); + const routingHints = catalogData?.routing ?? {}; const fetchedCatalogsReady = Boolean(catalogData); // Wrapper to persist provider selection to localStorage @@ -459,6 +460,7 @@ export function CliproxyPage() { })} authStatus={parentAuthForVariant} catalog={catalogs[selectedVariantData.provider]} + routing={routingHints[selectedVariantData.provider]} logoProvider={selectedVariantData.provider} baseProvider={selectedVariantData.provider} defaultTarget={selectedVariantData.target} @@ -512,6 +514,7 @@ export function CliproxyPage() { displayName={selectedStatus.displayName} authStatus={selectedStatus} catalog={catalogs[selectedStatus.provider]} + routing={routingHints[selectedStatus.provider]} isRemoteMode={isRemoteMode} topNotice={ showAccountSafetyWarning ? ( 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 6ecbdf4b..923e01e6 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 @@ -110,4 +110,69 @@ describe('ModelConfigSection presets', () => { ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-9-flash-preview', }); }); + + it('applies managed short prefixes when routing guidance is available', async () => { + const onApplyPreset = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Gemini Flash' })); + + expect(onApplyPreset).toHaveBeenCalledWith({ + ANTHROPIC_MODEL: 'gcli/gemini-3-flash-preview', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gcli/gemini-3.1-pro-preview', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gcli/gemini-3.1-pro-preview', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gcli/gemini-3-flash-preview', + }); + }); });