Merge pull request #939 from kaitranntt/kai/feat/938-cliproxy-routing-prefixes

feat(cliproxy): clarify backend-pinned model routing
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-10 02:58:41 -04:00
committed by GitHub
23 changed files with 1326 additions and 57 deletions
+2 -1
View File
@@ -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.
+32
View File
@@ -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<Record<CLIProxyProvider, ProviderCatalog>>;
source: CatalogSource;
cacheAge: string | null;
routing: Partial<Record<string, CliproxyProviderRoutingHints>>;
}
export async function getCatalogRoutingSnapshot(): Promise<CatalogRoutingSnapshot> {
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,
};
}
+156
View File
@@ -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<Response> {
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<string, string> | undefined),
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
}
async function listAuthFiles(): Promise<ManagementAuthFileRecord[]> {
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<void> {
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<AuthFileMetadata> {
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<ManagedPrefixSyncResult> {
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 };
}
+75 -6
View File
@@ -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<void> {
console.log(header('Model Catalog'));
console.log('');
const cacheAge = getCacheAge();
let routingSnapshot: Awaited<ReturnType<typeof getCatalogRoutingSnapshot>> | 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<void> {
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<void> {
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<void> {
await initUI();
+3 -2
View File
@@ -36,7 +36,7 @@ export async function showHelp(): Promise<void> {
[
'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<void> {
[
['--backend <type>', 'Use specific backend: original | plus (default: from config)'],
['--target <cli>', '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<void> {
}
console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.'));
console.log(dim(' Routing: use gcli/<model> or agy/<model> to keep overlapping models pinned.'));
console.log('');
console.log(subheader('Notes:'));
console.log(` Default fallback version: ${color(getFallbackVersion(), 'info')}`);
+53 -3
View File
@@ -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<CLIProxyProvider>();
async function ensureVariantManagedModelPrefixes(provider: CLIProxyProvider): Promise<void> {
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);
+218
View File
@@ -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<string, string[]> = {
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<Record<string, CatalogLikeProvider>>
): 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<string, string> = {
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<Record<string, CatalogLikeProvider>>,
mergedModels: MergedModelLike[]
): Partial<Record<string, CliproxyProviderRoutingHints>> {
const mergedModelMap = new Map<string, MergedModelLike>();
for (const model of mergedModels) {
const key = normalize(model.id);
if (key && !mergedModelMap.has(key)) {
mergedModelMap.set(key, model);
}
}
const result: Partial<Record<string, CliproxyProviderRoutingHints>> = {};
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);
}
+10
View File
@@ -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<ServerInstanc
// Start auto-sync watcher (if enabled in config)
startAutoSyncWatcher();
if (!getProxyTarget().isRemote) {
void ensureManagedModelPrefixes().catch((error) => {
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();
+3 -2
View File
@@ -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<void> => {
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,
+21 -2
View File
@@ -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<voi
});
if (account) {
try {
await ensureManagedModelPrefixes([account.provider]);
} catch {
// Keep OAuth success path non-fatal when prefix repair cannot run.
}
res.json({
success: true,
account: {
@@ -1010,7 +1017,11 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
return;
}
pendingManualAuthState.delete(state);
try {
await ensureManagedModelPrefixes([account.provider]);
} catch {
// Keep manual callback success path non-fatal when prefix repair cannot run.
}
res.json({
status: 'ok',
account: {
@@ -1021,6 +1032,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
isDefault: account.isDefault,
},
});
pendingManualAuthState.delete(state);
return;
}
@@ -1155,7 +1167,11 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
}
if (parsed.state) {
pendingManualAuthState.delete(parsed.state);
try {
await ensureManagedModelPrefixes([account.provider]);
} catch {
// Keep manual callback success path non-fatal when prefix repair cannot run.
}
}
res.json({
@@ -1168,6 +1184,9 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
isDefault: account.isDefault,
},
});
if (parsed.state) {
pendingManualAuthState.delete(parsed.state);
}
return;
}
@@ -0,0 +1,200 @@
import { afterEach, describe, expect, it, mock } from 'bun:test';
import { ensureManagedModelPrefixes } from '../../../src/cliproxy/managed-model-prefixes';
const originalFetch = global.fetch;
interface MockAuthFileRecord {
account_type?: string;
name: string;
provider?: string;
type?: string;
}
interface DownloadResponse {
body: string;
status?: number;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function textResponse(body: string, status = 200): Response {
return new Response(body, {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function installFetchMock(options: {
files: MockAuthFileRecord[];
downloads?: Record<string, DownloadResponse | Error>;
patchStatuses?: Record<string, number>;
}) {
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' }),
]);
});
});
@@ -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.'
);
});
});
@@ -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<ModelMappingValues>(currentValues);
const [values, setValues] = useState<ModelMappingValues>(
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}
/>
<FlexibleModelSelector
label={t('customPresetDialog.opusModel')}
@@ -86,6 +109,7 @@ export function CustomPresetDialog({
onChange={(model) => setValues({ ...values, opus: model })}
catalog={catalog}
allModels={allModels}
routing={routing}
/>
<FlexibleModelSelector
label={t('customPresetDialog.sonnetModel')}
@@ -94,6 +118,7 @@ export function CustomPresetDialog({
onChange={(model) => setValues({ ...values, sonnet: model })}
catalog={catalog}
allModels={allModels}
routing={routing}
/>
<FlexibleModelSelector
label={t('customPresetDialog.haikuModel')}
@@ -102,6 +127,7 @@ export function CustomPresetDialog({
onChange={(model) => setValues({ ...values, haiku: model })}
catalog={catalog}
allModels={allModels}
routing={routing}
/>
</div>
<DialogFooter className="gap-2 sm:gap-0">
@@ -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}
/>
</div>
);
@@ -16,7 +16,10 @@ import type { ModelConfigSectionProps } from './types';
type CatalogPresetModel = NonNullable<ModelConfigSectionProps['catalog']>['models'][number];
function getPresetUpdates(model: CatalogPresetModel): Record<string, string> {
function getPresetUpdates(
model: CatalogPresetModel,
toPreferredModelId: (modelId: string) => string
): Record<string, string> {
const mapping = model.presetMapping || {
default: model.id,
opus: model.id,
@@ -25,10 +28,10 @@ function getPresetUpdates(model: CatalogPresetModel): Record<string, string> {
};
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))}
>
<Zap
className={`w-3 h-3 ${'iconClassName' in group ? group.iconClassName : ''}`}
@@ -148,10 +161,10 @@ export function ModelConfigSection({
className="text-xs h-7 gap-1 pr-6"
onClick={() => {
onApplyPreset({
ANTHROPIC_MODEL: preset.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
ANTHROPIC_MODEL: toPreferredModelId(preset.default),
ANTHROPIC_DEFAULT_OPUS_MODEL: toPreferredModelId(preset.opus),
ANTHROPIC_DEFAULT_SONNET_MODEL: toPreferredModelId(preset.sonnet),
ANTHROPIC_DEFAULT_HAIKU_MODEL: toPreferredModelId(preset.haiku),
});
}}
>
@@ -195,6 +208,21 @@ export function ModelConfigSection({
<p className="text-xs text-muted-foreground mb-4">
Configure which models to use for each tier
</p>
{routing ? (
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
{pinningReady ? (
<>
Preferred pinned model names use the <code>{routing.prefix}/</code> prefix.
Unprefixed names can still resolve to a different backend when providers overlap.
</>
) : (
<>
Managed pinning for <code>{routing.prefix}/</code> is not currently advertised by
the proxy. Unprefixed names may still be ambiguous until prefix repair completes.
</>
)}
</p>
) : null}
{provider === 'codex' && (
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
Codex tip: suffixes <code>-medium</code>, <code>-high</code>, and <code>-xhigh</code>{' '}
@@ -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}
/>
<FlexibleModelSelector
label="Sonnet (Balanced)"
@@ -234,6 +264,7 @@ export function ModelConfigSection({
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)}
catalog={catalog}
allModels={providerModels}
routing={routing}
/>
<FlexibleModelSelector
label="Haiku (Fast)"
@@ -242,6 +273,7 @@ export function ModelConfigSection({
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)}
catalog={catalog}
allModels={providerModels}
routing={routing}
/>
</div>
</div>
@@ -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}
@@ -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 */
@@ -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: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
<span className="truncate font-mono text-xs">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
{routingHints.get(model.id.toLowerCase())?.prefix}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
<span className="truncate font-mono text-xs">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
Shadowed
</Badge>
) : null}
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
Prefix only
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
@@ -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: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
<span className="truncate font-mono text-xs">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
{routingHints.get(model.id.toLowerCase())?.prefix}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
<span className="truncate font-mono text-xs">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
Shadowed
</Badge>
) : null}
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
Prefix only
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
}));
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: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1">
Current
</Badge>
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
<Badge variant="outline" className="text-[9px] h-4 px-1">
Current
</Badge>
</div>
),
}
: 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: <span className="text-xs text-muted-foreground">Current value</span>,
},
]
: []),
{
key: 'recommended',
label: (
@@ -387,8 +504,39 @@ export function FlexibleModelSelector({
),
},
]}
options={[...recommendedOptions, ...allModelOptions]}
options={[
...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
...recommendedOptions,
...allModelOptions,
]}
/>
{selectedRoutingHint ? (
<div
className={cn(
'rounded-md border px-2.5 py-2 text-[11px]',
selectedRoutingHint.unprefixedStatus === 'safe'
? 'border-border/70 bg-muted/25 text-muted-foreground'
: 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/25 dark:text-amber-100'
)}
>
<div className="font-medium">
{selectedRoutingHint.pinnedAvailable
? 'Preferred pinned model:'
: 'Pinned route status:'}{' '}
<code>
{selectedRoutingHint.pinnedAvailable
? selectedRoutingHint.recommendedModelId
: selectedRoutingHint.pinnedModelId}
</code>
</div>
<p className="mt-1 leading-5">{selectedRoutingHint.summary}</p>
</div>
) : null}
{value && !selectedRoutingHint && normalizeModelValue(value, routing) !== value ? (
<div className="rounded-md border border-amber-300/60 bg-amber-50 px-2.5 py-2 text-[11px] text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/25 dark:text-amber-100">
Pinned model is not currently advertised by the proxy: <code>{value}</code>
</div>
) : null}
</div>
);
}
+8
View File
@@ -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<typeof useQueryClient>): void {
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
}
async function parseResponseBody(response: Response): Promise<Record<string, unknown>> {
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 {
+20 -18
View File
@@ -14,6 +14,17 @@ import {
} from '@/lib/api-client';
import { toast } from 'sonner';
function invalidateCliproxyRoutingQueries(queryClient: ReturnType<typeof useQueryClient>): void {
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
}
function invalidateCliproxyAccountQueries(queryClient: ReturnType<typeof useQueryClient>): 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 {
+25
View File
@@ -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<Record<string, CliproxyProviderCatalog>>;
routing?: Partial<Record<string, CliproxyProviderRoutingHints>>;
source: 'live' | 'cache' | 'static';
cache: {
synced: boolean;
+4
View File
@@ -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 ? (
@@ -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(
<ModelConfigSection
catalog={MODEL_CATALOGS.gemini}
savedPresets={[]}
currentModel="gemini-3-flash-preview"
opusModel="gemini-3.1-pro-preview"
sonnetModel="gemini-3.1-pro-preview"
haikuModel="gemini-3-flash-preview"
providerModels={[]}
routing={{
provider: 'gemini',
displayName: 'Gemini',
prefix: 'gcli',
safeCount: 0,
shadowedCount: 2,
prefixOnlyCount: 0,
models: [
{
modelId: 'gemini-3.1-pro-preview',
modelName: 'Gemini Pro',
prefix: 'gcli',
pinnedModelId: 'gcli/gemini-3.1-pro-preview',
recommendedModelId: 'gcli/gemini-3.1-pro-preview',
pinnedAvailable: true,
unprefixedStatus: 'shadowed',
effectiveProvider: 'agy',
effectiveDisplayName: 'Antigravity',
effectiveOwnedBy: 'antigravity',
summary: 'shadowed by Antigravity',
},
{
modelId: 'gemini-3-flash-preview',
modelName: 'Gemini Flash',
prefix: 'gcli',
pinnedModelId: 'gcli/gemini-3-flash-preview',
recommendedModelId: 'gcli/gemini-3-flash-preview',
pinnedAvailable: true,
unprefixedStatus: 'shadowed',
effectiveProvider: 'agy',
effectiveDisplayName: 'Antigravity',
effectiveOwnedBy: 'antigravity',
summary: 'shadowed by Antigravity',
},
],
}}
provider="gemini"
onExtendedContextToggle={vi.fn()}
onApplyPreset={onApplyPreset}
onUpdateEnvValue={vi.fn()}
onOpenCustomPreset={vi.fn()}
onDeletePreset={vi.fn()}
/>
);
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(
<ModelConfigSection
catalog={MODEL_CATALOGS.gemini}
savedPresets={[
{
name: 'legacy',
default: 'gemini-3-flash-preview',
opus: 'gemini-3.1-pro-preview',
sonnet: 'gemini-3.1-pro-preview',
haiku: 'gemini-3-flash-preview',
},
]}
currentModel="gemini-3-flash-preview"
opusModel="gemini-3.1-pro-preview"
sonnetModel="gemini-3.1-pro-preview"
haikuModel="gemini-3-flash-preview"
providerModels={[]}
routing={{
provider: 'gemini',
displayName: 'Gemini',
prefix: 'gcli',
safeCount: 0,
shadowedCount: 2,
prefixOnlyCount: 0,
models: [
{
modelId: 'gemini-3.1-pro-preview',
modelName: 'Gemini Pro',
prefix: 'gcli',
pinnedModelId: 'gcli/gemini-3.1-pro-preview',
recommendedModelId: 'gcli/gemini-3.1-pro-preview',
pinnedAvailable: true,
unprefixedStatus: 'shadowed',
effectiveProvider: 'agy',
effectiveDisplayName: 'Antigravity',
effectiveOwnedBy: 'antigravity',
summary: 'shadowed by Antigravity',
},
{
modelId: 'gemini-3-flash-preview',
modelName: 'Gemini Flash',
prefix: 'gcli',
pinnedModelId: 'gcli/gemini-3-flash-preview',
recommendedModelId: 'gcli/gemini-3-flash-preview',
pinnedAvailable: true,
unprefixedStatus: 'shadowed',
effectiveProvider: 'agy',
effectiveDisplayName: 'Antigravity',
effectiveOwnedBy: 'antigravity',
summary: 'shadowed by Antigravity',
},
],
}}
provider="gemini"
onExtendedContextToggle={vi.fn()}
onApplyPreset={onApplyPreset}
onUpdateEnvValue={vi.fn()}
onOpenCustomPreset={vi.fn()}
onDeletePreset={vi.fn()}
/>
);
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',
});
});
});