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. diff --git a/src/cliproxy/catalog-routing.ts b/src/cliproxy/catalog-routing.ts new file mode 100644 index 00000000..93973777 --- /dev/null +++ b/src/cliproxy/catalog-routing.ts @@ -0,0 +1,32 @@ +import { + buildCliproxyRoutingHints, + type CliproxyProviderRoutingHints, +} from '../shared/cliproxy-model-routing'; +import { fetchCliproxyModels } from './stats-fetcher'; +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 { + const snapshot: ResolvedCatalogSnapshot = await getResolvedCatalogSnapshot(); + const modelsResponse = snapshot.source === 'live' ? await fetchCliproxyModels() : null; + 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..54a97e6d --- /dev/null +++ b/src/cliproxy/managed-model-prefixes.ts @@ -0,0 +1,156 @@ +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'; + +const MANAGED_PREFIX_REQUEST_TIMEOUT_MS = 3000; + +interface ManagementAuthFileRecord { + account_type?: string; + name: string; + provider?: string; + type?: string; +} + +interface AuthFileMetadata { + prefix: string | null; + provider: CLIProxyProvider | null; +} + +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 fetchManagementEndpoint(path: string, init: RequestInit = {}): Promise { + const target = getProxyTarget(); + 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}`); + } + + const data = (await response.json()) as { files?: ManagementAuthFileRecord[] }; + return Array.isArray(data.files) ? data.files : []; +} + +async function patchAuthFilePrefix(name: string, prefix: string): Promise { + const response = await fetchManagementEndpoint('/v0/management/auth-files/fields', { + method: 'PATCH', + headers: { + '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 readAuthFileMetadata(name: string): Promise { + const response = await fetchManagementEndpoint( + `/v0/management/auth-files/download?name=${encodeURIComponent(name)}` + ); + + 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; 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 { prefix: null, provider: null }; + } +} + +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(); + 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 { 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 { + // 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..13374e65 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -6,9 +6,12 @@ import { getResolvedCatalog, 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'; +import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing'; /** Fetch model definitions from CLIProxyAPI for all syncable providers */ async function fetchRemoteCatalogs( @@ -44,7 +47,17 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(header('Model Catalog')); console.log(''); - const 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 { @@ -55,14 +68,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 +87,62 @@ 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(` ${hint.pinnedAvailable ? 'preferred' : 'suggested'}: ${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/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/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index be9cae13..bacc9c73 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -10,7 +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 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'; @@ -42,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)) { @@ -115,6 +129,16 @@ function formatModelOption(model: ModelEntry): string { return `${model.name}${tierBadge}`; } +function getSelectableModelId( + modelId: string, + routing: CliproxyProviderRoutingHints | undefined +): string { + const hint = routing?.models.find( + (entry) => entry.modelId.toLowerCase() === modelId.toLowerCase() + ); + return hint?.recommendedModelId ?? modelId; +} + function getBackendLabel(backend: CLIProxyBackend): string { return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; } @@ -174,9 +198,18 @@ async function selectTierConfig( // Select model let model: string | undefined; if (supportsModelConfig(provider as CLIProxyProvider)) { + try { + await ensureVariantManagedModelPrefixes(provider as CLIProxyProvider); + } 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: m.id, label: formatModelOption(m) })); + const modelOptions = catalog.models.map((m) => ({ + id: getSelectableModelId(m.id, routing), + 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 +459,18 @@ export async function handleCreate( let model = parsedArgs.model; if (!model) { if (supportsModelConfig(provider as CLIProxyProvider)) { + try { + await ensureVariantManagedModelPrefixes(provider as CLIProxyProvider); + } 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: m.id, label: formatModelOption(m) })); + const modelOptions = catalog.models.map((m) => ({ + id: getSelectableModelId(m.id, routing), + 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 +709,18 @@ export async function handleEdit( if (changeModel) { const providerForModel = newProvider || (variant.provider as CLIProxyProfileName); if (supportsModelConfig(providerForModel as CLIProxyProvider)) { + try { + await ensureVariantManagedModelPrefixes(providerForModel as CLIProxyProvider); + } 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: 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 new file mode 100644 index 00000000..b6194ac4 --- /dev/null +++ b/src/shared/cliproxy-model-routing.ts @@ -0,0 +1,218 @@ +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; + pinnedAvailable: boolean; + 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' | '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.`; + } + + 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 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 pinnedAvailable = pinnedCandidates.includes(managedPinnedId); + 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: managedPinnedId, + recommendedModelId: managedPinnedId, + pinnedAvailable, + 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/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/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..0b2bdf01 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 { @@ -677,6 +678,12 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise; + 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('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: [ + { 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' }), + ]); + }); +}); diff --git a/tests/unit/cliproxy/model-routing-hints.test.ts b/tests/unit/cliproxy/model-routing-hints.test.ts new file mode 100644 index 00000000..a9033769 --- /dev/null +++ b/tests/unit/cliproxy/model-routing-hints.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'bun:test'; +import { + buildCliproxyRoutingHints, + getManagedModelPrefix, +} from '../../../src/shared/cliproxy-model-routing'; + +describe('cliproxy model routing hints', () => { + 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', + pinnedAvailable: false, + unprefixedStatus: 'shadowed', + effectiveProvider: 'agy', + effectiveDisplayName: 'Antigravity', + }); + + expect(routing.agy?.models[0]).toMatchObject({ + recommendedModelId: 'agy/gemini-3-flash', + pinnedAvailable: false, + 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', + pinnedAvailable: false, + unprefixedStatus: 'prefix-only', + 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', + }); + }); + + 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.' + ); + }); +}); 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..ec21f4c4 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?.recommendedModelId ?? modelId; + }; + + return { + default: toPreferredModelId(values.default), + opus: toPreferredModelId(values.opus), + sonnet: toPreferredModelId(values.sonnet), + haiku: toPreferredModelId(values.haiku), + }; +} + export function CustomPresetDialog({ open, onClose, @@ -29,15 +48,18 @@ export function CustomPresetDialog({ isSaving, catalog, allModels, + 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(); @@ -78,6 +100,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..9549ab18 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,15 @@ 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; + const extendedContextModels = useMemo(() => { if (!catalog) return []; @@ -126,7 +139,7 @@ export function ModelConfigSection({ variant="outline" size="sm" className="text-xs h-7 gap-1" - onClick={() => onApplyPreset(getPresetUpdates(model))} + onClick={() => onApplyPreset(getPresetUpdates(model, toPreferredModelId))} > { 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), }); }} > @@ -195,6 +208,21 @@ export function ModelConfigSection({

Configure which models to use for each tier

+ {routing ? ( +

+ {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' && (

Codex tip: suffixes -medium, -high, and -xhigh{' '} @@ -209,6 +237,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 +255,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..be7502bc 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,27 @@ 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; +} + +function getPreferredOptionValue( + modelId: string, + routingHint: CliproxyProviderRoutingHints['models'][number] | undefined +): string { + return routingHint?.recommendedModelId ?? modelId; +} + export function FlexibleModelSelector({ label, description, @@ -301,6 +320,7 @@ export function FlexibleModelSelector({ onChange, catalog, allModels, + routing, disabled, }: FlexibleModelSelectorProps) { const { t } = useTranslation(); @@ -310,22 +330,59 @@ 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 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] + ); const recommendedOptions = resolvedCatalogModels.map((model) => ({ - value: model.id, + value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), 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} + + {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()))} + {model.tier === 'paid' && } + {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( + + Shadowed + + ) : null} + {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( + + Prefix only + + ) : null} {isCodexProvider && }
), @@ -333,24 +390,76 @@ 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: 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 && }
), })); + 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 +481,14 @@ export function FlexibleModelSelector({ } triggerClassName="h-9" groups={[ + ...(selectedValueMissing && legacySelectedOption + ? [ + { + key: 'current', + label: Current value, + }, + ] + : []), { key: 'recommended', label: ( @@ -387,8 +504,39 @@ export function FlexibleModelSelector({ ), }, ]} - options={[...recommendedOptions, ...allModelOptions]} + options={[ + ...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []), + ...recommendedOptions, + ...allModelOptions, + ]} /> + {selectedRoutingHint ? ( +
+
+ {selectedRoutingHint.pinnedAvailable + ? 'Preferred pinned model:' + : 'Pinned route status:'}{' '} + + {selectedRoutingHint.pinnedAvailable + ? selectedRoutingHint.recommendedModelId + : selectedRoutingHint.pinnedModelId} + +
+

{selectedRoutingHint.summary}

+
+ ) : null} + {value && !selectedRoutingHint && normalizeModelValue(value, routing) !== value ? ( +
+ Pinned model is not currently advertised by the proxy: {value} +
+ ) : null} ); } 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/lib/api-client.ts b/ui/src/lib/api-client.ts index 54c491c0..d8493a99 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -468,6 +468,30 @@ export interface CliproxyCatalogModel { presetMapping?: CliproxyCatalogPresetMapping; } +export interface CliproxyModelRoutingHint { + modelId: string; + modelName: string; + prefix: string; + pinnedModelId: string; + recommendedModelId: string; + pinnedAvailable: boolean; + 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 +501,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..6aba8344 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 @@ -305,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) => { @@ -459,6 +461,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 +515,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..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 @@ -110,4 +110,146 @@ 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', + }); + }); + + 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', + }); + }); });