From 4d5e52828b9d20759bf20e7b4353192fbecfc868 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 17:41:15 -0400 Subject: [PATCH 1/7] 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', + }); + }); }); From b8a8f841536f89386e3452261037f0fdcc389059 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 17:41:50 -0400 Subject: [PATCH 2/7] docs(roadmap): note cliproxy routing prefix clarity --- docs/project-roadmap.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 8ef5aee2..6ae4fc8e 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-08 +Last Updated: 2026-04-09 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-09**: **#938** Cliproxy model routing now exposes backend-pinned short prefixes for overlapping OAuth backends. CCS repairs managed OAuth auth-file prefixes for Gemini CLI (`gcli`) and Antigravity (`agy`), enriches `/api/cliproxy/catalog` with routing hints that show whether an unprefixed model is safe, shadowed, or prefix-only, upgrades `ccs cliproxy catalog` plus interactive variant model pickers to surface the pinned names, and updates the `ccs config` Cliproxy model selection UI so users can see the preferred call name and current effective backend before saving settings. - **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank. - **2026-04-08**: **#929** Image Analysis hardening now makes the managed `ccs-image-analysis` MCP path authoritative on healthy Claude-target launches, suppresses stale CCS-managed image `Read` hooks instead of letting them compete with MCP, keeps the legacy hook available only as compatibility fallback when MCP provisioning fails, and extends self-heal to dashboard provisioning plus `ccs doctor --fix` so stale hook files and missing isolated MCP sync are repaired automatically. - **2026-04-07**: CLIProxy routing strategy is now a first-class CCS surface. Users can inspect and explicitly change `round-robin` vs `fill-first` from `ccs cliproxy routing` and from a native `/cliproxy` dashboard card. Local mode now persists the chosen startup default into CCS-managed CLIProxy config generation, while untouched installs remain on `round-robin`. CCS deliberately does not infer strategy from account composition. From 0c10cb6f47d23db33cbce17456d46aee1843b9ae Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 18:01:01 -0400 Subject: [PATCH 3/7] fix(cliproxy): harden routing prefix sync and UI pinning --- src/cliproxy/catalog-routing.ts | 7 -- src/cliproxy/managed-model-prefixes.ts | 32 ++++++-- src/commands/cliproxy/catalog-subcommand.ts | 22 ++++-- src/commands/cliproxy/variant-subcommand.ts | 25 ++++-- src/shared/cliproxy-model-routing.ts | 21 ++++- src/web-server/index.ts | 10 +++ src/web-server/routes/cliproxy-auth-routes.ts | 12 --- .../unit/cliproxy/model-routing-hints.test.ts | 3 + .../provider-editor/custom-preset-dialog.tsx | 25 +++++- .../provider-editor/model-config-section.tsx | 26 +++++-- .../cliproxy/provider-model-selector.tsx | 58 +++++++++++--- ui/src/lib/api-client.ts | 1 + .../model-config-section.test.tsx | 77 +++++++++++++++++++ 13 files changed, 260 insertions(+), 59 deletions(-) diff --git a/src/cliproxy/catalog-routing.ts b/src/cliproxy/catalog-routing.ts index fc373df8..7b0c5ea3 100644 --- a/src/cliproxy/catalog-routing.ts +++ b/src/cliproxy/catalog-routing.ts @@ -3,7 +3,6 @@ import { type CliproxyProviderRoutingHints, } from '../shared/cliproxy-model-routing'; import { fetchCliproxyModels } from './stats-fetcher'; -import { ensureManagedModelPrefixes } from './managed-model-prefixes'; import { getResolvedCatalogSnapshot, type CatalogSource, @@ -20,12 +19,6 @@ export interface CatalogRoutingSnapshot { } 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 ?? []); diff --git a/src/cliproxy/managed-model-prefixes.ts b/src/cliproxy/managed-model-prefixes.ts index 6215ec67..2ab33041 100644 --- a/src/cliproxy/managed-model-prefixes.ts +++ b/src/cliproxy/managed-model-prefixes.ts @@ -10,6 +10,11 @@ interface ManagementAuthFileRecord { type?: string; } +interface AuthFileMetadata { + prefix: string | null; + provider: CLIProxyProvider | null; +} + export interface ManagedPrefixSyncResult { checked: number; updated: number; @@ -49,7 +54,7 @@ async function patchAuthFilePrefix(name: string, prefix: string): Promise } } -async function readAuthFilePrefix(name: string): Promise { +async function readAuthFileMetadata(name: string): Promise { const target = getProxyTarget(); const url = buildProxyUrl( target, @@ -65,10 +70,19 @@ async function readAuthFilePrefix(name: string): Promise { const content = await response.text(); try { - const parsed = JSON.parse(content) as { prefix?: unknown }; - return typeof parsed.prefix === 'string' ? parsed.prefix.trim() : null; + const parsed = JSON.parse(content) as { prefix?: unknown; provider?: unknown; type?: unknown }; + const providerName = + typeof parsed.provider === 'string' + ? parsed.provider + : typeof parsed.type === 'string' + ? parsed.type + : ''; + return { + prefix: typeof parsed.prefix === 'string' ? parsed.prefix.trim() : null, + provider: providerName ? mapExternalProviderName(providerName) : null, + }; } catch { - return null; + return { prefix: null, provider: null }; } } @@ -101,10 +115,18 @@ export async function ensureManagedModelPrefixes( try { checked += 1; - const currentPrefix = await readAuthFilePrefix(record.name); + const { prefix: currentPrefix, provider: fileProvider } = await readAuthFileMetadata( + record.name + ); + if (fileProvider !== provider) { + continue; + } if (currentPrefix === prefix) { continue; } + if (currentPrefix && currentPrefix !== prefix) { + continue; + } await patchAuthFilePrefix(record.name, prefix); updated += 1; } catch { diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index a26d05fb..13374e65 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -7,6 +7,7 @@ import { refreshCatalogFromProxy, } from '../../cliproxy/catalog-cache'; import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; +import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; import type { CLIProxyProvider } from '../../cliproxy/types'; import type { RemoteModelInfo } from '../../cliproxy/management-api-types'; @@ -46,8 +47,17 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(header('Model Catalog')); console.log(''); - const routingSnapshot = await getCatalogRoutingSnapshot(); - const cacheAge = routingSnapshot.cacheAge ?? getCacheAge(); + let routingSnapshot: Awaited> | null = null; + if (verbose) { + try { + await ensureManagedModelPrefixes(); + routingSnapshot = await getCatalogRoutingSnapshot(); + } catch { + routingSnapshot = null; + } + } + + const cacheAge = routingSnapshot?.cacheAge ?? getCacheAge(); if (cacheAge) { console.log(` Cache: ${color('synced', 'success')} (${cacheAge})`); } else { @@ -58,10 +68,10 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(subheader('Providers:')); for (const provider of SYNCABLE_PROVIDERS) { - const catalog = routingSnapshot.catalogs[provider] ?? getResolvedCatalog(provider); + const catalog = routingSnapshot?.catalogs[provider] ?? getResolvedCatalog(provider); if (catalog) { const count = catalog.models.length; - const routing = routingSnapshot.routing[provider]; + const routing = routingSnapshot?.routing[provider]; const suffix = renderRoutingSummary(routing); console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models${suffix}`); if (verbose) { @@ -112,7 +122,9 @@ function renderVerboseRouting( continue; } - console.log(dim(` preferred: ${hint.recommendedModelId}`)); + console.log( + dim(` ${hint.pinnedAvailable ? 'preferred' : 'suggested'}: ${hint.recommendedModelId}`) + ); if (hint.unprefixedStatus === 'safe') { console.log(dim(` unprefixed: resolves to ${routing.displayName}`)); continue; diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 3a30533c..fd43ac93 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -10,9 +10,10 @@ import * as path from 'path'; import { getProviderAccounts } from '../../cliproxy/account-manager'; import { triggerOAuth } from '../../cliproxy/auth/oauth-handler'; import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector'; +import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog'; import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; -import { getManagedModelPrefix } from '../../shared/cliproxy-model-routing'; +import type { CliproxyProviderRoutingHints } 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'; @@ -117,9 +118,14 @@ 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 getSelectableModelId( + modelId: string, + routing: CliproxyProviderRoutingHints | undefined +): string { + const hint = routing?.models.find( + (entry) => entry.modelId.toLowerCase() === modelId.toLowerCase() + ); + return hint?.pinnedAvailable ? hint.recommendedModelId : modelId; } function getBackendLabel(backend: CLIProxyBackend): string { @@ -186,10 +192,11 @@ async function selectTierConfig( } catch { // Keep interactive model selection available even when prefix repair fails. } + const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider]; const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { const modelOptions = catalog.models.map((m) => ({ - id: getSelectableModelId(provider as CLIProxyProvider, m.id), + id: getSelectableModelId(m.id, routing), label: formatModelOption(m), })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); @@ -446,10 +453,11 @@ export async function handleCreate( } catch { // Keep variant creation available even when prefix repair fails. } + const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider]; const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { const modelOptions = catalog.models.map((m) => ({ - id: getSelectableModelId(provider as CLIProxyProvider, m.id), + id: getSelectableModelId(m.id, routing), label: formatModelOption(m), })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); @@ -695,10 +703,13 @@ export async function handleEdit( } catch { // Keep edit flow available even when prefix repair fails. } + const routing = (await getCatalogRoutingSnapshot()).routing[ + providerForModel as CLIProxyProvider + ]; const catalog = getProviderCatalog(providerForModel as CLIProxyProvider); if (catalog) { const modelOptions = catalog.models.map((m) => ({ - id: getSelectableModelId(providerForModel as CLIProxyProvider, m.id), + id: getSelectableModelId(m.id, routing), 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 index dcd06724..86c4e1ca 100644 --- a/src/shared/cliproxy-model-routing.ts +++ b/src/shared/cliproxy-model-routing.ts @@ -29,6 +29,7 @@ export interface CliproxyModelRoutingHint { prefix: string; pinnedModelId: string; recommendedModelId: string; + pinnedAvailable: boolean; unprefixedStatus: ModelRoutingStatus; effectiveProvider: string | null; effectiveDisplayName: string | null; @@ -108,9 +109,13 @@ function buildSummary( providerDisplayName: string, hint: Pick< CliproxyModelRoutingHint, - 'modelId' | 'pinnedModelId' | 'unprefixedStatus' | 'effectiveDisplayName' + 'modelId' | 'pinnedModelId' | 'pinnedAvailable' | 'unprefixedStatus' | 'effectiveDisplayName' > ): string { + if (!hint.pinnedAvailable) { + return `${hint.modelId} does not currently advertise a live pinned route for ${hint.pinnedModelId}. Reconnect or refresh managed prefixes before treating it as pinned.`; + } + if (hint.unprefixedStatus === 'safe') { return `${hint.modelId} currently resolves to ${providerDisplayName}. Use ${hint.pinnedModelId} to keep it pinned.`; } @@ -151,6 +156,15 @@ export function buildCliproxyRoutingHints( let prefixOnlyCount = 0; const models = catalog.models.map((model) => { + const pinnedCandidates = mergedModels + .filter((candidate) => normalize(candidate.id).endsWith(`/${normalize(model.id)}`)) + .filter((candidate) => inferProvider(candidate) === providerKey) + .map((candidate) => candidate.id) + .sort((left, right) => left.localeCompare(right)); + const managedPinnedId = `${prefix}/${model.id}`; + const recommendedModelId = pinnedCandidates.includes(managedPinnedId) + ? managedPinnedId + : (pinnedCandidates[0] ?? managedPinnedId); const mergedModel = mergedModelMap.get(normalize(model.id)); const effectiveProvider = mergedModel ? inferProvider(mergedModel) : null; const effectiveDisplayName = @@ -173,8 +187,9 @@ export function buildCliproxyRoutingHints( modelId: model.id, modelName: model.name?.trim() || model.id, prefix, - pinnedModelId: `${prefix}/${model.id}`, - recommendedModelId: `${prefix}/${model.id}`, + pinnedModelId: managedPinnedId, + recommendedModelId, + pinnedAvailable: pinnedCandidates.length > 0, unprefixedStatus, effectiveProvider, effectiveDisplayName, diff --git a/src/web-server/index.ts b/src/web-server/index.ts index fd2de5a6..9f5ff51f 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -13,6 +13,8 @@ import { WebSocketServer } from 'ws'; import { setupWebSocket } from './websocket'; import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware'; import { requestLoggingMiddleware } from './middleware/request-logging-middleware'; +import { ensureManagedModelPrefixes } from '../cliproxy/managed-model-prefixes'; +import { getProxyTarget } from '../cliproxy/proxy-target-resolver'; import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync'; import { shutdownUsageAggregator } from './usage/aggregator'; import { createLogger } from '../services/logging'; @@ -119,6 +121,14 @@ export async function startServer(options: ServerOptions): Promise { + logger.warn('cliproxy.prefix_sync_failed', 'Managed model prefix repair failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + // Combined cleanup function const cleanup = () => { wsCleanup(); diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 8cf2d4b0..686278df 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -316,12 +316,6 @@ 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) { @@ -393,12 +387,6 @@ 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) { diff --git a/tests/unit/cliproxy/model-routing-hints.test.ts b/tests/unit/cliproxy/model-routing-hints.test.ts index 5074621c..07734dcb 100644 --- a/tests/unit/cliproxy/model-routing-hints.test.ts +++ b/tests/unit/cliproxy/model-routing-hints.test.ts @@ -30,6 +30,7 @@ describe('cliproxy model routing hints', () => { expect(routing.gemini?.models[0]).toMatchObject({ recommendedModelId: 'gcli/gemini-3-flash-preview', + pinnedAvailable: false, unprefixedStatus: 'shadowed', effectiveProvider: 'agy', effectiveDisplayName: 'Antigravity', @@ -37,6 +38,7 @@ describe('cliproxy model routing hints', () => { expect(routing.agy?.models[0]).toMatchObject({ recommendedModelId: 'agy/gemini-3-flash', + pinnedAvailable: false, unprefixedStatus: 'safe', effectiveProvider: 'agy', }); @@ -57,6 +59,7 @@ describe('cliproxy model routing hints', () => { expect(routing.gemini?.prefixOnlyCount).toBe(1); expect(routing.gemini?.models[0]).toMatchObject({ recommendedModelId: 'gcli/gemini-3.1-pro-preview', + pinnedAvailable: false, 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 3b3671e2..2116a44d 100644 --- a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx +++ b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx @@ -20,6 +20,25 @@ import { FlexibleModelSelector } from '../provider-model-selector'; import type { CustomPresetDialogProps, ModelMappingValues } from './types'; import { useTranslation } from 'react-i18next'; +function normalizePresetValues( + values: ModelMappingValues, + routing: CustomPresetDialogProps['routing'] +): ModelMappingValues { + const toPreferredModelId = (modelId: string): string => { + const hint = routing?.models.find( + (entry) => entry.modelId.toLowerCase() === modelId.toLowerCase() + ); + return hint?.pinnedAvailable ? hint.recommendedModelId : modelId; + }; + + return { + default: toPreferredModelId(values.default), + opus: toPreferredModelId(values.opus), + sonnet: toPreferredModelId(values.sonnet), + haiku: toPreferredModelId(values.haiku), + }; +} + export function CustomPresetDialog({ open, onClose, @@ -32,13 +51,15 @@ export function CustomPresetDialog({ routing, }: CustomPresetDialogProps) { const { t } = useTranslation(); - const [values, setValues] = useState(currentValues); + const [values, setValues] = useState( + normalizePresetValues(currentValues, routing) + ); const [presetName, setPresetName] = useState(''); // Reset values when dialog opens with current values const handleOpenChange = (isOpen: boolean) => { if (isOpen) { - setValues(currentValues); + setValues(normalizePresetValues(currentValues, routing)); setPresetName(''); } else { onClose(); 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 52163244..eb62216e 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -53,13 +53,16 @@ export function ModelConfigSection({ onDeletePreset, isDeletePending, }: ModelConfigSectionProps) { + const pinningReady = (routing?.models ?? []).some((hint) => hint.pinnedAvailable); 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; + routingHintMap.get(modelId.toLowerCase())?.pinnedAvailable + ? (routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId) + : modelId; const extendedContextModels = useMemo(() => { if (!catalog) return []; @@ -160,10 +163,10 @@ export function ModelConfigSection({ className="text-xs h-7 gap-1 pr-6" onClick={() => { onApplyPreset({ - ANTHROPIC_MODEL: preset.default, - ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus, - ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet, - ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku, + ANTHROPIC_MODEL: toPreferredModelId(preset.default), + ANTHROPIC_DEFAULT_OPUS_MODEL: toPreferredModelId(preset.opus), + ANTHROPIC_DEFAULT_SONNET_MODEL: toPreferredModelId(preset.sonnet), + ANTHROPIC_DEFAULT_HAIKU_MODEL: toPreferredModelId(preset.haiku), }); }} > @@ -209,8 +212,17 @@ export function ModelConfigSection({

{routing ? (

- Preferred pinned model names use the {routing.prefix}/ prefix. Unprefixed - names can still resolve to a different backend when providers overlap. + {pinningReady ? ( + <> + Preferred pinned model names use the {routing.prefix}/ prefix. + Unprefixed names can still resolve to a different backend when providers overlap. + + ) : ( + <> + Managed pinning for {routing.prefix}/ is not currently advertised by + the proxy. Unprefixed names may still be ambiguous until prefix repair completes. + + )}

) : null} {provider === 'codex' && ( diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index d2924f50..0fa58b82 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -306,6 +306,16 @@ function normalizeModelValue( return value.startsWith(prefix) ? value.slice(prefix.length) : value; } +function getPreferredOptionValue( + modelId: string, + routingHint: CliproxyProviderRoutingHints['models'][number] | undefined +): string { + if (!routingHint?.pinnedAvailable) { + return modelId; + } + return routingHint.recommendedModelId; +} + export function FlexibleModelSelector({ label, description, @@ -334,16 +344,16 @@ export function FlexibleModelSelector({ ); const recommendedOptions = resolvedCatalogModels.map((model) => ({ - value: routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id, + value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), groupKey: 'recommended', searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`, keywords: [model.tier ?? '', catalog?.provider ?? ''], triggerContent: (
- {routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id} + {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - {routingHints.get(model.id.toLowerCase()) ? ( + {routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? ( {routingHints.get(model.id.toLowerCase())?.prefix} @@ -354,7 +364,7 @@ export function FlexibleModelSelector({ itemContent: (
- {routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id} + {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} {model.tier === 'paid' && } {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( @@ -375,19 +385,38 @@ export function FlexibleModelSelector({ const allModelOptions = allModels .filter((model) => !catalogModelIds.has(model.id)) .map((model) => ({ - value: model.id, + value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), groupKey: 'all', - searchText: model.id, + searchText: `${model.id} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`, keywords: [model.owned_by], triggerContent: (
- {model.id} + + {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} + + {routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? ( + + {routingHints.get(model.id.toLowerCase())?.prefix} + + ) : null} {isCodexProvider && }
), itemContent: (
- {model.id} + + {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} + + {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( + + Shadowed + + ) : null} + {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( + + Prefix only + + ) : null} {isCodexProvider && }
), @@ -479,14 +508,21 @@ export function FlexibleModelSelector({ )} >
- Preferred pinned model: {selectedRoutingHint.recommendedModelId} + {selectedRoutingHint.pinnedAvailable + ? 'Preferred pinned model:' + : 'Pinned route status:'}{' '} + + {selectedRoutingHint.pinnedAvailable + ? selectedRoutingHint.recommendedModelId + : selectedRoutingHint.pinnedModelId} +

{selectedRoutingHint.summary}

) : null} {value && !selectedRoutingHint && normalizeModelValue(value, routing) !== value ? ( -
- Using pinned model: {value} +
+ Pinned model is not currently advertised by the proxy: {value}
) : null}
diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 9b28b188..d8493a99 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -474,6 +474,7 @@ export interface CliproxyModelRoutingHint { prefix: string; pinnedModelId: string; recommendedModelId: string; + pinnedAvailable: boolean; unprefixedStatus: 'safe' | 'shadowed' | 'prefix-only'; effectiveProvider: string | null; effectiveDisplayName: string | null; 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 923e01e6..7fdabedb 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 @@ -137,6 +137,7 @@ describe('ModelConfigSection presets', () => { prefix: 'gcli', pinnedModelId: 'gcli/gemini-3.1-pro-preview', recommendedModelId: 'gcli/gemini-3.1-pro-preview', + pinnedAvailable: true, unprefixedStatus: 'shadowed', effectiveProvider: 'agy', effectiveDisplayName: 'Antigravity', @@ -149,6 +150,7 @@ describe('ModelConfigSection presets', () => { prefix: 'gcli', pinnedModelId: 'gcli/gemini-3-flash-preview', recommendedModelId: 'gcli/gemini-3-flash-preview', + pinnedAvailable: true, unprefixedStatus: 'shadowed', effectiveProvider: 'agy', effectiveDisplayName: 'Antigravity', @@ -175,4 +177,79 @@ describe('ModelConfigSection presets', () => { ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gcli/gemini-3-flash-preview', }); }); + + it('normalizes saved presets through preferred pinned model ids when live pinning is available', async () => { + const onApplyPreset = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'legacy' })); + + 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', + }); + }); }); From b6aa6fb0d4471b68fb795ef4ba2449f2a9f72788 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 18:41:14 -0400 Subject: [PATCH 4/7] fix(cliproxy): cover managed prefix review follow-up --- src/commands/cliproxy/help-subcommand.ts | 5 +- .../cliproxy/managed-model-prefixes.test.ts | 190 ++++++++++++++++++ 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 tests/unit/cliproxy/managed-model-prefixes.test.ts diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 9e1eae44..56a2863d 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -36,7 +36,7 @@ export async function showHelp(): Promise { [ 'Catalog Commands:', [ - ['catalog', 'Show catalog status (cached vs static)'], + ['catalog', 'Show catalog status, routing hints, and pinned short prefixes'], ['catalog refresh', 'Sync models from remote CLIProxy'], ['catalog reset', 'Clear cache, revert to static catalog'], ], @@ -85,7 +85,7 @@ export async function showHelp(): Promise { [ ['--backend ', 'Use specific backend: original | plus (default: from config)'], ['--target ', 'Default target for created/edited variants: claude | droid'], - ['--verbose, -v', 'Show detailed quota fetch diagnostics'], + ['--verbose, -v', 'Show detailed diagnostics including routing hints and quota fetches'], ], ], ]; @@ -100,6 +100,7 @@ export async function showHelp(): Promise { } console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.')); + console.log(dim(' Routing: use gcli/ or agy/ to keep overlapping models pinned.')); console.log(''); console.log(subheader('Notes:')); console.log(` Default fallback version: ${color(getFallbackVersion(), 'info')}`); diff --git a/tests/unit/cliproxy/managed-model-prefixes.test.ts b/tests/unit/cliproxy/managed-model-prefixes.test.ts new file mode 100644 index 00000000..b79f8678 --- /dev/null +++ b/tests/unit/cliproxy/managed-model-prefixes.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { ensureManagedModelPrefixes } from '../../../src/cliproxy/managed-model-prefixes'; + +const originalFetch = global.fetch; + +interface MockAuthFileRecord { + account_type?: string; + name: string; + provider?: string; + type?: string; +} + +interface DownloadResponse { + body: string; + status?: number; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function textResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function installFetchMock(options: { + files: MockAuthFileRecord[]; + downloads?: Record; + patchStatuses?: Record; +}) { + const requests: Array<{ url: string; method: string; body: string | undefined }> = []; + + global.fetch = mock((input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const method = init?.method ?? 'GET'; + const body = typeof init?.body === 'string' ? init.body : undefined; + + requests.push({ url, method, body }); + + if (url.endsWith('/v0/management/auth-files') && method === 'GET') { + return Promise.resolve(jsonResponse({ files: options.files })); + } + + if (url.includes('/v0/management/auth-files/download') && method === 'GET') { + const name = new URL(url).searchParams.get('name'); + if (!name) { + return Promise.reject(new Error(`Missing auth file name for ${url}`)); + } + + const response = options.downloads?.[name]; + if (response instanceof Error) { + return Promise.reject(response); + } + if (!response) { + return Promise.resolve(textResponse('{}', 404)); + } + + return Promise.resolve(textResponse(response.body, response.status ?? 200)); + } + + if (url.endsWith('/v0/management/auth-files/fields') && method === 'PATCH') { + const payload = JSON.parse(body ?? '{}') as { name?: string }; + const status = (payload.name && options.patchStatuses?.[payload.name]) ?? 200; + return Promise.resolve(jsonResponse({ ok: status < 400 }, status)); + } + + return Promise.reject(new Error(`Unexpected fetch ${method} ${url}`)); + }) as typeof fetch; + + return requests; +} + +afterEach(() => { + global.fetch = originalFetch; +}); + +describe('ensureManagedModelPrefixes', () => { + it('patches missing managed prefixes for matching oauth providers', async () => { + const requests = installFetchMock({ + files: [ + { account_type: 'oauth', name: 'gemini-main', provider: 'gemini' }, + { account_type: 'oauth', name: 'agy-main', provider: 'antigravity' }, + { account_type: 'apikey', name: 'gemini-key', provider: 'gemini' }, + ], + downloads: { + 'gemini-main': { body: JSON.stringify({ prefix: null, provider: 'gemini' }) }, + 'agy-main': { body: JSON.stringify({ prefix: null, provider: 'antigravity' }) }, + }, + }); + + const result = await ensureManagedModelPrefixes(['gemini']); + + expect(result).toEqual({ checked: 1, updated: 1 }); + + const patchRequest = requests.find( + (request) => + request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH' + ); + expect(patchRequest?.body).toBe(JSON.stringify({ name: 'gemini-main', prefix: 'gcli' })); + + const downloadedNames = requests + .filter((request) => request.url.includes('/v0/management/auth-files/download')) + .map((request) => new URL(request.url).searchParams.get('name')); + expect(downloadedNames).toEqual(['gemini-main']); + }); + + it('skips files that already have the managed prefix or a different custom prefix', async () => { + const requests = installFetchMock({ + files: [ + { account_type: 'oauth', name: 'gemini-managed', provider: 'gemini' }, + { account_type: 'oauth', name: 'gemini-custom', provider: 'gemini' }, + ], + downloads: { + 'gemini-managed': { body: JSON.stringify({ prefix: 'gcli', provider: 'gemini' }) }, + 'gemini-custom': { body: JSON.stringify({ prefix: 'team-a', provider: 'gemini' }) }, + }, + }); + + const result = await ensureManagedModelPrefixes(['gemini']); + + expect(result).toEqual({ checked: 2, updated: 0 }); + expect( + requests.some( + (request) => + request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH' + ) + ).toBe(false); + }); + + it('skips patching when the downloaded auth file belongs to a different provider', async () => { + const requests = installFetchMock({ + files: [{ account_type: 'oauth', name: 'gemini-shadowed', provider: 'gemini' }], + downloads: { + 'gemini-shadowed': { + body: JSON.stringify({ prefix: null, provider: 'antigravity' }), + }, + }, + }); + + const result = await ensureManagedModelPrefixes(['gemini']); + + expect(result).toEqual({ checked: 1, updated: 0 }); + expect( + requests.some( + (request) => + request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH' + ) + ).toBe(false); + }); + + it('swallows read and patch failures so later files can still be repaired', async () => { + const requests = installFetchMock({ + files: [ + { account_type: 'oauth', name: 'gemini-unreadable', provider: 'gemini' }, + { account_type: 'oauth', name: 'gemini-patch-fails', provider: 'gemini' }, + { account_type: 'oauth', name: 'gemini-success', provider: 'gemini' }, + ], + downloads: { + 'gemini-unreadable': new Error('network down'), + 'gemini-patch-fails': { body: JSON.stringify({ prefix: null, provider: 'gemini' }) }, + 'gemini-success': { body: JSON.stringify({ prefix: null, provider: 'gemini' }) }, + }, + patchStatuses: { + 'gemini-patch-fails': 500, + }, + }); + + const result = await ensureManagedModelPrefixes(['gemini']); + + expect(result).toEqual({ checked: 3, updated: 1 }); + + const patchPayloads = requests + .filter( + (request) => + request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH' + ) + .map((request) => request.body); + expect(patchPayloads).toEqual([ + JSON.stringify({ name: 'gemini-patch-fails', prefix: 'gcli' }), + JSON.stringify({ name: 'gemini-success', prefix: 'gcli' }), + ]); + }); +}); From 40c4816acf36b3b79f13bf32862f7c88ed7debf4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 19:01:48 -0400 Subject: [PATCH 5/7] fix(cliproxy): harden pinned routing follow-up --- src/cliproxy/catalog-routing.ts | 2 +- src/cliproxy/managed-model-prefixes.ts | 48 +++++++++++++------ src/commands/cliproxy/variant-subcommand.ts | 2 +- src/shared/cliproxy-model-routing.ts | 8 ++-- src/web-server/routes/cliproxy-auth-routes.ts | 11 ++++- .../cliproxy/managed-model-prefixes.test.ts | 10 ++++ .../unit/cliproxy/model-routing-hints.test.ts | 20 ++++++++ .../provider-editor/custom-preset-dialog.tsx | 2 +- .../provider-editor/model-config-section.tsx | 4 +- .../cliproxy/provider-model-selector.tsx | 20 ++++++-- ui/src/hooks/use-cliproxy-auth-flow.ts | 8 ++++ ui/src/hooks/use-cliproxy.ts | 38 ++++++++------- ui/src/pages/cliproxy.tsx | 1 + 13 files changed, 124 insertions(+), 50 deletions(-) diff --git a/src/cliproxy/catalog-routing.ts b/src/cliproxy/catalog-routing.ts index 7b0c5ea3..93973777 100644 --- a/src/cliproxy/catalog-routing.ts +++ b/src/cliproxy/catalog-routing.ts @@ -20,7 +20,7 @@ export interface CatalogRoutingSnapshot { export async function getCatalogRoutingSnapshot(): Promise { const snapshot: ResolvedCatalogSnapshot = await getResolvedCatalogSnapshot(); - const modelsResponse = await fetchCliproxyModels(); + const modelsResponse = snapshot.source === 'live' ? await fetchCliproxyModels() : null; const routing = buildCliproxyRoutingHints(snapshot.catalogs, modelsResponse?.models ?? []); return { diff --git a/src/cliproxy/managed-model-prefixes.ts b/src/cliproxy/managed-model-prefixes.ts index 2ab33041..54a97e6d 100644 --- a/src/cliproxy/managed-model-prefixes.ts +++ b/src/cliproxy/managed-model-prefixes.ts @@ -3,6 +3,8 @@ import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-t import { mapExternalProviderName } from './provider-capabilities'; import type { CLIProxyProvider } from './types'; +const MANAGED_PREFIX_REQUEST_TIMEOUT_MS = 3000; + interface ManagementAuthFileRecord { account_type?: string; name: string; @@ -25,11 +27,24 @@ function normalizeProvider(record: ManagementAuthFileRecord): CLIProxyProvider | return providerName ? mapExternalProviderName(providerName) : null; } -async function listAuthFiles(): Promise { +async function fetchManagementEndpoint(path: string, init: RequestInit = {}): Promise { const target = getProxyTarget(); - const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), { - headers: buildManagementHeaders(target), - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MANAGED_PREFIX_REQUEST_TIMEOUT_MS); + + try { + return await fetch(buildProxyUrl(target, path), { + ...init, + headers: buildManagementHeaders(target, init.headers as Record | undefined), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } +} + +async function listAuthFiles(): Promise { + const response = await fetchManagementEndpoint('/v0/management/auth-files'); if (!response.ok) { throw new Error(`auth file listing failed with status ${response.status}`); @@ -40,12 +55,11 @@ async function listAuthFiles(): Promise { } async function patchAuthFilePrefix(name: string, prefix: string): Promise { - const target = getProxyTarget(); - const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files/fields'), { + const response = await fetchManagementEndpoint('/v0/management/auth-files/fields', { method: 'PATCH', - headers: buildManagementHeaders(target, { + headers: { 'Content-Type': 'application/json', - }), + }, body: JSON.stringify({ name, prefix }), }); @@ -55,14 +69,9 @@ async function patchAuthFilePrefix(name: string, prefix: string): Promise } async function readAuthFileMetadata(name: string): Promise { - const target = getProxyTarget(); - const url = buildProxyUrl( - target, + const response = await fetchManagementEndpoint( `/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}`); @@ -89,8 +98,17 @@ async function readAuthFileMetadata(name: string): Promise { export async function ensureManagedModelPrefixes( providers?: CLIProxyProvider[] ): Promise { + const allowedProviders = new Set( + (providers ?? []) + .map((provider) => provider.trim()) + .filter((provider) => getManagedModelPrefix(provider)) + ); + + if (providers && allowedProviders.size === 0) { + return { checked: 0, updated: 0 }; + } + const files = await listAuthFiles(); - const allowedProviders = new Set((providers ?? []).map((provider) => provider.trim())); let checked = 0; let updated = 0; diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index fd43ac93..b83c3453 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -125,7 +125,7 @@ function getSelectableModelId( const hint = routing?.models.find( (entry) => entry.modelId.toLowerCase() === modelId.toLowerCase() ); - return hint?.pinnedAvailable ? hint.recommendedModelId : modelId; + return hint?.recommendedModelId ?? modelId; } function getBackendLabel(backend: CLIProxyBackend): string { diff --git a/src/shared/cliproxy-model-routing.ts b/src/shared/cliproxy-model-routing.ts index 86c4e1ca..b6194ac4 100644 --- a/src/shared/cliproxy-model-routing.ts +++ b/src/shared/cliproxy-model-routing.ts @@ -162,9 +162,7 @@ export function buildCliproxyRoutingHints( .map((candidate) => candidate.id) .sort((left, right) => left.localeCompare(right)); const managedPinnedId = `${prefix}/${model.id}`; - const recommendedModelId = pinnedCandidates.includes(managedPinnedId) - ? managedPinnedId - : (pinnedCandidates[0] ?? managedPinnedId); + const pinnedAvailable = pinnedCandidates.includes(managedPinnedId); const mergedModel = mergedModelMap.get(normalize(model.id)); const effectiveProvider = mergedModel ? inferProvider(mergedModel) : null; const effectiveDisplayName = @@ -188,8 +186,8 @@ export function buildCliproxyRoutingHints( modelName: model.name?.trim() || model.id, prefix, pinnedModelId: managedPinnedId, - recommendedModelId, - pinnedAvailable: pinnedCandidates.length > 0, + recommendedModelId: managedPinnedId, + pinnedAvailable, unprefixedStatus, effectiveProvider, effectiveDisplayName, diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 686278df..0b2bdf01 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -1017,7 +1017,6 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise { expect(downloadedNames).toEqual(['gemini-main']); }); + it('returns immediately when called for providers without managed prefixes', async () => { + const fetchMock = mock(() => Promise.reject(new Error('should not fetch'))); + global.fetch = fetchMock as typeof fetch; + + const result = await ensureManagedModelPrefixes(['codex']); + + expect(result).toEqual({ checked: 0, updated: 0 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('skips files that already have the managed prefix or a different custom prefix', async () => { const requests = installFetchMock({ files: [ diff --git a/tests/unit/cliproxy/model-routing-hints.test.ts b/tests/unit/cliproxy/model-routing-hints.test.ts index 07734dcb..2d1a21f3 100644 --- a/tests/unit/cliproxy/model-routing-hints.test.ts +++ b/tests/unit/cliproxy/model-routing-hints.test.ts @@ -64,4 +64,24 @@ describe('cliproxy model routing hints', () => { effectiveProvider: null, }); }); + + it('does not promote custom auth-file prefixes as managed pinned model ids', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }], + }, + }, + [{ id: 'team-a/gemini-3-flash-preview', owned_by: 'google', type: 'gemini-cli' }] + ); + + expect(routing.gemini?.models[0]).toMatchObject({ + pinnedModelId: 'gcli/gemini-3-flash-preview', + recommendedModelId: 'gcli/gemini-3-flash-preview', + pinnedAvailable: false, + unprefixedStatus: 'prefix-only', + }); + }); }); 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 2116a44d..ec21f4c4 100644 --- a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx +++ b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx @@ -28,7 +28,7 @@ function normalizePresetValues( const hint = routing?.models.find( (entry) => entry.modelId.toLowerCase() === modelId.toLowerCase() ); - return hint?.pinnedAvailable ? hint.recommendedModelId : modelId; + return hint?.recommendedModelId ?? modelId; }; return { 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 eb62216e..9549ab18 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -60,9 +60,7 @@ export function ModelConfigSection({ [routing] ); const toPreferredModelId = (modelId: string): string => - routingHintMap.get(modelId.toLowerCase())?.pinnedAvailable - ? (routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId) - : modelId; + routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId; const extendedContextModels = useMemo(() => { if (!catalog) return []; diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 0fa58b82..be7502bc 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -310,10 +310,7 @@ function getPreferredOptionValue( modelId: string, routingHint: CliproxyProviderRoutingHints['models'][number] | undefined ): string { - if (!routingHint?.pinnedAvailable) { - return modelId; - } - return routingHint.recommendedModelId; + return routingHint?.recommendedModelId ?? modelId; } export function FlexibleModelSelector({ @@ -338,6 +335,15 @@ export function FlexibleModelSelector({ new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)), [routing] ); + const recommendedOptionValues = useMemo( + () => + new Set( + resolvedCatalogModels.map((model) => + getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())) + ) + ), + [resolvedCatalogModels, routingHints] + ); const selectedRoutingHint = useMemo( () => routingHints.get(normalizeModelValue(value, routing).toLowerCase()), [routing, routingHints, value] @@ -384,6 +390,12 @@ export function FlexibleModelSelector({ const allModelOptions = allModels .filter((model) => !catalogModelIds.has(model.id)) + .filter( + (model) => + !recommendedOptionValues.has( + getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())) + ) + ) .map((model) => ({ value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), groupKey: 'all', diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index 1ac8199f..a49d0c25 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -47,6 +47,11 @@ const MAX_POLL_DURATION = 5 * 60 * 1000; /** Fail visibly after repeated poll transport errors instead of retrying forever */ const MAX_POLL_FAILURES = 3; +function invalidateCliproxyRoutingData(queryClient: ReturnType): void { + queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] }); +} + async function parseResponseBody(response: Response): Promise> { const text = await response.text(); if (!text) return {}; @@ -167,6 +172,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + invalidateCliproxyRoutingData(queryClient); toast.success(`${provider} authentication successful`); openedAuthUrlRef.current = false; setState(INITIAL_STATE); @@ -301,6 +307,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + invalidateCliproxyRoutingData(queryClient); // Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast // via deviceCodeCompleted WebSocket event to avoid duplicate toasts openedAuthUrlRef.current = false; @@ -467,6 +474,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + invalidateCliproxyRoutingData(queryClient); toast.success(`${currentProvider} authentication successful`); setState(INITIAL_STATE); } else { diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index d88e2b82..46d48a4e 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -14,6 +14,17 @@ import { } from '@/lib/api-client'; import { toast } from 'sonner'; +function invalidateCliproxyRoutingQueries(queryClient: ReturnType): void { + queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] }); +} + +function invalidateCliproxyAccountQueries(queryClient: ReturnType): void { + queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyRoutingQueries(queryClient); +} + export function useCliproxy() { return useQuery({ queryKey: ['cliproxy'], @@ -128,8 +139,7 @@ export function useSetDefaultAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.setDefault(provider, accountId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); toast.success('Default account updated'); }, onError: (error: Error) => { @@ -145,8 +155,7 @@ export function useRemoveAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.remove(provider, accountId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); toast.success('Account removed'); }, onError: (error: Error) => { @@ -162,8 +171,7 @@ export function usePauseAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.pause(provider, accountId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success('Account paused'); }, @@ -180,8 +188,7 @@ export function useResumeAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.resume(provider, accountId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success('Account resumed'); }, @@ -198,8 +205,7 @@ export function useSoloAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.solo(provider, accountId), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); const pausedCount = data.paused.length; toast.success( @@ -219,8 +225,7 @@ export function useBulkPauseAccounts() { mutationFn: ({ provider, accountIds }: { provider: string; accountIds: string[] }) => api.cliproxy.accounts.bulkPause(provider, accountIds), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success( `Paused ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}` @@ -244,8 +249,7 @@ export function useBulkResumeAccounts() { mutationFn: ({ provider, accountIds }: { provider: string; accountIds: string[] }) => api.cliproxy.accounts.bulkResume(provider, accountIds), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success( `Resumed ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}` @@ -270,8 +274,7 @@ export function useStartAuth() { mutationFn: ({ provider, nickname }: { provider: string; nickname?: string }) => api.cliproxy.auth.start(provider, nickname), onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); toast.success(`Account added for ${variables.provider}`); }, onError: (error: Error) => { @@ -297,8 +300,7 @@ export function useKiroImport() { return useMutation({ mutationFn: () => api.cliproxy.auth.kiroImport(), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); if (data.account) { toast.success(`Imported Kiro account: ${data.account.email || data.account.id}`); } else { diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 7c5b1e8f..6aba8344 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -306,6 +306,7 @@ export function CliproxyPage() { queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] }); }; const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => { From eaddfc467fa4380d5c642ad7063442b6edc22efc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 19:09:32 -0400 Subject: [PATCH 6/7] fix(cliproxy): dedupe variant prefix sync --- src/commands/cliproxy/variant-subcommand.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index b83c3453..bacc9c73 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -45,6 +45,17 @@ interface CliproxyProfileArgs { errors: string[]; } +const variantManagedPrefixProviders = new Set(); + +async function ensureVariantManagedModelPrefixes(provider: CLIProxyProvider): Promise { + if (variantManagedPrefixProviders.has(provider)) { + return; + } + + await ensureManagedModelPrefixes([provider]); + variantManagedPrefixProviders.add(provider); +} + function parseTargetValue(rawValue: string): TargetType | null { const normalized = rawValue.trim().toLowerCase(); if (isPersistedTargetType(normalized)) { @@ -188,7 +199,7 @@ async function selectTierConfig( let model: string | undefined; if (supportsModelConfig(provider as CLIProxyProvider)) { try { - await ensureManagedModelPrefixes([provider as CLIProxyProvider]); + await ensureVariantManagedModelPrefixes(provider as CLIProxyProvider); } catch { // Keep interactive model selection available even when prefix repair fails. } @@ -449,7 +460,7 @@ export async function handleCreate( if (!model) { if (supportsModelConfig(provider as CLIProxyProvider)) { try { - await ensureManagedModelPrefixes([provider as CLIProxyProvider]); + await ensureVariantManagedModelPrefixes(provider as CLIProxyProvider); } catch { // Keep variant creation available even when prefix repair fails. } @@ -699,7 +710,7 @@ export async function handleEdit( const providerForModel = newProvider || (variant.provider as CLIProxyProfileName); if (supportsModelConfig(providerForModel as CLIProxyProvider)) { try { - await ensureManagedModelPrefixes([providerForModel as CLIProxyProvider]); + await ensureVariantManagedModelPrefixes(providerForModel as CLIProxyProvider); } catch { // Keep edit flow available even when prefix repair fails. } From d87a126938d9d71b3bb0de163e4265111fa09944 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 01:15:10 -0400 Subject: [PATCH 7/7] test(cliproxy): cover managed pinned route hints --- .../unit/cliproxy/model-routing-hints.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/unit/cliproxy/model-routing-hints.test.ts b/tests/unit/cliproxy/model-routing-hints.test.ts index 2d1a21f3..a9033769 100644 --- a/tests/unit/cliproxy/model-routing-hints.test.ts +++ b/tests/unit/cliproxy/model-routing-hints.test.ts @@ -84,4 +84,28 @@ describe('cliproxy model routing hints', () => { unprefixedStatus: 'prefix-only', }); }); + + it('marks the managed pinned route as available when the live model list advertises it', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }], + }, + }, + [{ id: 'gcli/gemini-3-flash-preview', owned_by: 'google', type: 'gemini-cli' }] + ); + + expect(routing.gemini?.models[0]).toMatchObject({ + pinnedModelId: 'gcli/gemini-3-flash-preview', + recommendedModelId: 'gcli/gemini-3-flash-preview', + pinnedAvailable: true, + unprefixedStatus: 'prefix-only', + effectiveProvider: null, + }); + expect(routing.gemini?.models[0]?.summary).toContain( + 'Use gcli/gemini-3-flash-preview to target Gemini.' + ); + }); });