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',
+ });
+ });
});