mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 08:19:59 +00:00
feat(cliproxy): add pinned model routing prefixes
This commit is contained in:
@@ -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<Record<CLIProxyProvider, ProviderCatalog>>;
|
||||||
|
source: CatalogSource;
|
||||||
|
cacheAge: string | null;
|
||||||
|
routing: Partial<Record<string, CliproxyProviderRoutingHints>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCatalogRoutingSnapshot(): Promise<CatalogRoutingSnapshot> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<ManagementAuthFileRecord[]> {
|
||||||
|
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<void> {
|
||||||
|
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<string | null> {
|
||||||
|
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<ManagedPrefixSyncResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
getResolvedCatalog,
|
getResolvedCatalog,
|
||||||
refreshCatalogFromProxy,
|
refreshCatalogFromProxy,
|
||||||
} from '../../cliproxy/catalog-cache';
|
} from '../../cliproxy/catalog-cache';
|
||||||
|
import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing';
|
||||||
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
import type { RemoteModelInfo } from '../../cliproxy/management-api-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 */
|
/** Fetch model definitions from CLIProxyAPI for all syncable providers */
|
||||||
async function fetchRemoteCatalogs(
|
async function fetchRemoteCatalogs(
|
||||||
@@ -44,7 +46,8 @@ export async function handleCatalogStatus(verbose: boolean): Promise<void> {
|
|||||||
console.log(header('Model Catalog'));
|
console.log(header('Model Catalog'));
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
const cacheAge = getCacheAge();
|
const routingSnapshot = await getCatalogRoutingSnapshot();
|
||||||
|
const cacheAge = routingSnapshot.cacheAge ?? getCacheAge();
|
||||||
if (cacheAge) {
|
if (cacheAge) {
|
||||||
console.log(` Cache: ${color('synced', 'success')} (${cacheAge})`);
|
console.log(` Cache: ${color('synced', 'success')} (${cacheAge})`);
|
||||||
} else {
|
} else {
|
||||||
@@ -55,14 +58,14 @@ export async function handleCatalogStatus(verbose: boolean): Promise<void> {
|
|||||||
console.log(subheader('Providers:'));
|
console.log(subheader('Providers:'));
|
||||||
|
|
||||||
for (const provider of SYNCABLE_PROVIDERS) {
|
for (const provider of SYNCABLE_PROVIDERS) {
|
||||||
const catalog = getResolvedCatalog(provider);
|
const catalog = routingSnapshot.catalogs[provider] ?? getResolvedCatalog(provider);
|
||||||
if (catalog) {
|
if (catalog) {
|
||||||
const count = catalog.models.length;
|
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) {
|
if (verbose) {
|
||||||
for (const model of catalog.models) {
|
renderVerboseRouting(provider, catalog.models, routing);
|
||||||
console.log(dim(` - ${model.id} (${model.name})`));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +77,60 @@ export async function handleCatalogStatus(verbose: boolean): Promise<void> {
|
|||||||
console.log('');
|
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 */
|
/** Refresh catalog from CLIProxyAPI */
|
||||||
export async function handleCatalogRefresh(verbose: boolean): Promise<void> {
|
export async function handleCatalogRefresh(verbose: boolean): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { getProviderAccounts } from '../../cliproxy/account-manager';
|
|||||||
import { triggerOAuth } from '../../cliproxy/auth/oauth-handler';
|
import { triggerOAuth } from '../../cliproxy/auth/oauth-handler';
|
||||||
import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||||
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog';
|
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 { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types';
|
||||||
import type { TargetType } from '../../targets/target-adapter';
|
import type { TargetType } from '../../targets/target-adapter';
|
||||||
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
|
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
|
||||||
@@ -115,6 +117,11 @@ function formatModelOption(model: ModelEntry): string {
|
|||||||
return `${model.name}${tierBadge}`;
|
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 {
|
function getBackendLabel(backend: CLIProxyBackend): string {
|
||||||
return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||||
}
|
}
|
||||||
@@ -174,9 +181,17 @@ async function selectTierConfig(
|
|||||||
// Select model
|
// Select model
|
||||||
let model: string | undefined;
|
let model: string | undefined;
|
||||||
if (supportsModelConfig(provider as CLIProxyProvider)) {
|
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);
|
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
||||||
if (catalog) {
|
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);
|
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
||||||
model = await InteractivePrompt.selectFromList(`Model for ${tierName}:`, modelOptions, {
|
model = await InteractivePrompt.selectFromList(`Model for ${tierName}:`, modelOptions, {
|
||||||
defaultIndex: defaultIdx >= 0 ? defaultIdx : 0,
|
defaultIndex: defaultIdx >= 0 ? defaultIdx : 0,
|
||||||
@@ -426,9 +441,17 @@ export async function handleCreate(
|
|||||||
let model = parsedArgs.model;
|
let model = parsedArgs.model;
|
||||||
if (!model) {
|
if (!model) {
|
||||||
if (supportsModelConfig(provider as CLIProxyProvider)) {
|
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);
|
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
||||||
if (catalog) {
|
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);
|
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
||||||
model = await InteractivePrompt.selectFromList('Select model:', modelOptions, {
|
model = await InteractivePrompt.selectFromList('Select model:', modelOptions, {
|
||||||
defaultIndex: defaultIdx >= 0 ? defaultIdx : 0,
|
defaultIndex: defaultIdx >= 0 ? defaultIdx : 0,
|
||||||
@@ -667,10 +690,15 @@ export async function handleEdit(
|
|||||||
if (changeModel) {
|
if (changeModel) {
|
||||||
const providerForModel = newProvider || (variant.provider as CLIProxyProfileName);
|
const providerForModel = newProvider || (variant.provider as CLIProxyProfileName);
|
||||||
if (supportsModelConfig(providerForModel as CLIProxyProvider)) {
|
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);
|
const catalog = getProviderCatalog(providerForModel as CLIProxyProvider);
|
||||||
if (catalog) {
|
if (catalog) {
|
||||||
const modelOptions = catalog.models.map((m) => ({
|
const modelOptions = catalog.models.map((m) => ({
|
||||||
id: m.id,
|
id: getSelectableModelId(providerForModel as CLIProxyProvider, m.id),
|
||||||
label: formatModelOption(m),
|
label: formatModelOption(m),
|
||||||
}));
|
}));
|
||||||
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
||||||
|
|||||||
@@ -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<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' | '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<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 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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { getResolvedCatalogSnapshot } from '../../cliproxy/catalog-cache';
|
import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -9,9 +9,10 @@ const router = Router();
|
|||||||
*/
|
*/
|
||||||
router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const snapshot = await getResolvedCatalogSnapshot();
|
const snapshot = await getCatalogRoutingSnapshot();
|
||||||
res.json({
|
res.json({
|
||||||
catalogs: snapshot.catalogs,
|
catalogs: snapshot.catalogs,
|
||||||
|
routing: snapshot.routing,
|
||||||
source: snapshot.source,
|
source: snapshot.source,
|
||||||
cache: {
|
cache: {
|
||||||
synced: snapshot.source !== 'static' || snapshot.cacheAge !== null,
|
synced: snapshot.source !== 'static' || snapshot.cacheAge !== null,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
buildManagementHeaders,
|
buildManagementHeaders,
|
||||||
} from '../../cliproxy/proxy-target-resolver';
|
} from '../../cliproxy/proxy-target-resolver';
|
||||||
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||||
|
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
|
||||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
|
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
|
||||||
import {
|
import {
|
||||||
@@ -315,6 +316,12 @@ export function getStartAuthNicknameError(
|
|||||||
*/
|
*/
|
||||||
router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
try {
|
||||||
|
await ensureManagedModelPrefixes();
|
||||||
|
} catch {
|
||||||
|
// Keep auth status available even when prefix repair cannot run.
|
||||||
|
}
|
||||||
|
|
||||||
// Check if remote mode is enabled
|
// Check if remote mode is enabled
|
||||||
const target = getProxyTarget();
|
const target = getProxyTarget();
|
||||||
if (target.isRemote) {
|
if (target.isRemote) {
|
||||||
@@ -386,6 +393,12 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
|||||||
*/
|
*/
|
||||||
router.get('/accounts', async (_req: Request, res: Response): Promise<void> => {
|
router.get('/accounts', async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
try {
|
||||||
|
await ensureManagedModelPrefixes();
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: account listing should still work without prefix repair.
|
||||||
|
}
|
||||||
|
|
||||||
// Check if remote mode is enabled
|
// Check if remote mode is enabled
|
||||||
const target = getProxyTarget();
|
const target = getProxyTarget();
|
||||||
if (target.isRemote) {
|
if (target.isRemote) {
|
||||||
@@ -677,6 +690,12 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
|
try {
|
||||||
|
await ensureManagedModelPrefixes([account.provider]);
|
||||||
|
} catch {
|
||||||
|
// Keep OAuth success path non-fatal when prefix repair cannot run.
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
account: {
|
account: {
|
||||||
@@ -1011,6 +1030,11 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingManualAuthState.delete(state);
|
pendingManualAuthState.delete(state);
|
||||||
|
try {
|
||||||
|
await ensureManagedModelPrefixes([account.provider]);
|
||||||
|
} catch {
|
||||||
|
// Keep manual callback success path non-fatal when prefix repair cannot run.
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
account: {
|
account: {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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',
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ export function CustomPresetDialog({
|
|||||||
isSaving,
|
isSaving,
|
||||||
catalog,
|
catalog,
|
||||||
allModels,
|
allModels,
|
||||||
|
routing,
|
||||||
}: CustomPresetDialogProps) {
|
}: CustomPresetDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [values, setValues] = useState<ModelMappingValues>(currentValues);
|
const [values, setValues] = useState<ModelMappingValues>(currentValues);
|
||||||
@@ -78,6 +79,7 @@ export function CustomPresetDialog({
|
|||||||
onChange={(model) => setValues({ ...values, default: model })}
|
onChange={(model) => setValues({ ...values, default: model })}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
allModels={allModels}
|
allModels={allModels}
|
||||||
|
routing={routing}
|
||||||
/>
|
/>
|
||||||
<FlexibleModelSelector
|
<FlexibleModelSelector
|
||||||
label={t('customPresetDialog.opusModel')}
|
label={t('customPresetDialog.opusModel')}
|
||||||
@@ -86,6 +88,7 @@ export function CustomPresetDialog({
|
|||||||
onChange={(model) => setValues({ ...values, opus: model })}
|
onChange={(model) => setValues({ ...values, opus: model })}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
allModels={allModels}
|
allModels={allModels}
|
||||||
|
routing={routing}
|
||||||
/>
|
/>
|
||||||
<FlexibleModelSelector
|
<FlexibleModelSelector
|
||||||
label={t('customPresetDialog.sonnetModel')}
|
label={t('customPresetDialog.sonnetModel')}
|
||||||
@@ -94,6 +97,7 @@ export function CustomPresetDialog({
|
|||||||
onChange={(model) => setValues({ ...values, sonnet: model })}
|
onChange={(model) => setValues({ ...values, sonnet: model })}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
allModels={allModels}
|
allModels={allModels}
|
||||||
|
routing={routing}
|
||||||
/>
|
/>
|
||||||
<FlexibleModelSelector
|
<FlexibleModelSelector
|
||||||
label={t('customPresetDialog.haikuModel')}
|
label={t('customPresetDialog.haikuModel')}
|
||||||
@@ -102,6 +106,7 @@ export function CustomPresetDialog({
|
|||||||
onChange={(model) => setValues({ ...values, haiku: model })}
|
onChange={(model) => setValues({ ...values, haiku: model })}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
allModels={allModels}
|
allModels={allModels}
|
||||||
|
routing={routing}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function ProviderEditor({
|
|||||||
displayName,
|
displayName,
|
||||||
authStatus,
|
authStatus,
|
||||||
catalog,
|
catalog,
|
||||||
|
routing,
|
||||||
logoProvider,
|
logoProvider,
|
||||||
baseProvider,
|
baseProvider,
|
||||||
isRemoteMode,
|
isRemoteMode,
|
||||||
@@ -263,6 +264,7 @@ export function ProviderEditor({
|
|||||||
sonnetModel={sonnetModel}
|
sonnetModel={sonnetModel}
|
||||||
haikuModel={haikuModel}
|
haikuModel={haikuModel}
|
||||||
providerModels={providerModels}
|
providerModels={providerModels}
|
||||||
|
routing={routing}
|
||||||
extendedContextEnabled={extendedContextEnabled}
|
extendedContextEnabled={extendedContextEnabled}
|
||||||
onExtendedContextToggle={toggleExtendedContext}
|
onExtendedContextToggle={toggleExtendedContext}
|
||||||
onApplyPreset={handleApplyPreset}
|
onApplyPreset={handleApplyPreset}
|
||||||
@@ -349,6 +351,7 @@ export function ProviderEditor({
|
|||||||
isSaving={createPresetMutation.isPending}
|
isSaving={createPresetMutation.isPending}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
allModels={providerModels}
|
allModels={providerModels}
|
||||||
|
routing={routing}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import type { ModelConfigSectionProps } from './types';
|
|||||||
|
|
||||||
type CatalogPresetModel = NonNullable<ModelConfigSectionProps['catalog']>['models'][number];
|
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 || {
|
const mapping = model.presetMapping || {
|
||||||
default: model.id,
|
default: model.id,
|
||||||
opus: model.id,
|
opus: model.id,
|
||||||
@@ -25,10 +28,10 @@ function getPresetUpdates(model: CatalogPresetModel): Record<string, string> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ANTHROPIC_MODEL: mapping.default,
|
ANTHROPIC_MODEL: toPreferredModelId(mapping.default),
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
|
ANTHROPIC_DEFAULT_OPUS_MODEL: toPreferredModelId(mapping.opus),
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
|
ANTHROPIC_DEFAULT_SONNET_MODEL: toPreferredModelId(mapping.sonnet),
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: toPreferredModelId(mapping.haiku),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +43,7 @@ export function ModelConfigSection({
|
|||||||
sonnetModel,
|
sonnetModel,
|
||||||
haikuModel,
|
haikuModel,
|
||||||
providerModels,
|
providerModels,
|
||||||
|
routing,
|
||||||
provider,
|
provider,
|
||||||
extendedContextEnabled,
|
extendedContextEnabled,
|
||||||
onExtendedContextToggle,
|
onExtendedContextToggle,
|
||||||
@@ -49,6 +53,14 @@ export function ModelConfigSection({
|
|||||||
onDeletePreset,
|
onDeletePreset,
|
||||||
isDeletePending,
|
isDeletePending,
|
||||||
}: ModelConfigSectionProps) {
|
}: 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(() => {
|
const extendedContextModels = useMemo(() => {
|
||||||
if (!catalog) return [];
|
if (!catalog) return [];
|
||||||
|
|
||||||
@@ -126,7 +138,7 @@ export function ModelConfigSection({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs h-7 gap-1"
|
className="text-xs h-7 gap-1"
|
||||||
onClick={() => onApplyPreset(getPresetUpdates(model))}
|
onClick={() => onApplyPreset(getPresetUpdates(model, toPreferredModelId))}
|
||||||
>
|
>
|
||||||
<Zap
|
<Zap
|
||||||
className={`w-3 h-3 ${'iconClassName' in group ? group.iconClassName : ''}`}
|
className={`w-3 h-3 ${'iconClassName' in group ? group.iconClassName : ''}`}
|
||||||
@@ -195,6 +207,12 @@ export function ModelConfigSection({
|
|||||||
<p className="text-xs text-muted-foreground mb-4">
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
Configure which models to use for each tier
|
Configure which models to use for each tier
|
||||||
</p>
|
</p>
|
||||||
|
{routing ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
|
||||||
|
Preferred pinned model names use the <code>{routing.prefix}/</code> prefix. Unprefixed
|
||||||
|
names can still resolve to a different backend when providers overlap.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{provider === 'codex' && (
|
{provider === 'codex' && (
|
||||||
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
|
<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>{' '}
|
Codex tip: suffixes <code>-medium</code>, <code>-high</code>, and <code>-xhigh</code>{' '}
|
||||||
@@ -209,6 +227,7 @@ export function ModelConfigSection({
|
|||||||
onChange={(model) => onUpdateEnvValue('ANTHROPIC_MODEL', model)}
|
onChange={(model) => onUpdateEnvValue('ANTHROPIC_MODEL', model)}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
allModels={providerModels}
|
allModels={providerModels}
|
||||||
|
routing={routing}
|
||||||
/>
|
/>
|
||||||
{/* Extended Context Toggle - shows when any saved mapping supports it */}
|
{/* Extended Context Toggle - shows when any saved mapping supports it */}
|
||||||
{extendedContextModels.length > 0 && onExtendedContextToggle && (
|
{extendedContextModels.length > 0 && onExtendedContextToggle && (
|
||||||
@@ -226,6 +245,7 @@ export function ModelConfigSection({
|
|||||||
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)}
|
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
allModels={providerModels}
|
allModels={providerModels}
|
||||||
|
routing={routing}
|
||||||
/>
|
/>
|
||||||
<FlexibleModelSelector
|
<FlexibleModelSelector
|
||||||
label="Sonnet (Balanced)"
|
label="Sonnet (Balanced)"
|
||||||
@@ -234,6 +254,7 @@ export function ModelConfigSection({
|
|||||||
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)}
|
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
allModels={providerModels}
|
allModels={providerModels}
|
||||||
|
routing={routing}
|
||||||
/>
|
/>
|
||||||
<FlexibleModelSelector
|
<FlexibleModelSelector
|
||||||
label="Haiku (Fast)"
|
label="Haiku (Fast)"
|
||||||
@@ -242,6 +263,7 @@ export function ModelConfigSection({
|
|||||||
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)}
|
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
allModels={providerModels}
|
allModels={providerModels}
|
||||||
|
routing={routing}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ModelConfigSection } from './model-config-section';
|
|||||||
import { AccountsSection } from './accounts-section';
|
import { AccountsSection } from './accounts-section';
|
||||||
import { api } from '@/lib/api-client';
|
import { api } from '@/lib/api-client';
|
||||||
import type { ProviderCatalog } from '../provider-model-selector';
|
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';
|
import { QUOTA_SUPPORTED_PROVIDERS, type QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
|
||||||
|
|
||||||
interface ModelConfigTabProps {
|
interface ModelConfigTabProps {
|
||||||
@@ -28,6 +28,7 @@ interface ModelConfigTabProps {
|
|||||||
sonnetModel?: string;
|
sonnetModel?: string;
|
||||||
haikuModel?: string;
|
haikuModel?: string;
|
||||||
providerModels: Array<{ id: string; owned_by: string }>;
|
providerModels: Array<{ id: string; owned_by: string }>;
|
||||||
|
routing?: CliproxyProviderRoutingHints;
|
||||||
/** Whether extended context (1M tokens) is enabled */
|
/** Whether extended context (1M tokens) is enabled */
|
||||||
extendedContextEnabled?: boolean;
|
extendedContextEnabled?: boolean;
|
||||||
/** Callback when extended context toggle changes */
|
/** Callback when extended context toggle changes */
|
||||||
@@ -71,6 +72,7 @@ export function ModelConfigTab({
|
|||||||
sonnetModel,
|
sonnetModel,
|
||||||
haikuModel,
|
haikuModel,
|
||||||
providerModels,
|
providerModels,
|
||||||
|
routing,
|
||||||
extendedContextEnabled,
|
extendedContextEnabled,
|
||||||
onExtendedContextToggle,
|
onExtendedContextToggle,
|
||||||
onApplyPreset,
|
onApplyPreset,
|
||||||
@@ -160,6 +162,7 @@ export function ModelConfigTab({
|
|||||||
sonnetModel={sonnetModel}
|
sonnetModel={sonnetModel}
|
||||||
haikuModel={haikuModel}
|
haikuModel={haikuModel}
|
||||||
providerModels={providerModels}
|
providerModels={providerModels}
|
||||||
|
routing={routing}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
extendedContextEnabled={extendedContextEnabled}
|
extendedContextEnabled={extendedContextEnabled}
|
||||||
onExtendedContextToggle={onExtendedContextToggle}
|
onExtendedContextToggle={onExtendedContextToggle}
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
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';
|
import type { ProviderCatalog } from '../provider-model-selector';
|
||||||
|
|
||||||
export interface SettingsResponse {
|
export interface SettingsResponse {
|
||||||
@@ -20,6 +25,7 @@ export interface ProviderEditorProps {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
authStatus: AuthStatus;
|
authStatus: AuthStatus;
|
||||||
catalog?: ProviderCatalog;
|
catalog?: ProviderCatalog;
|
||||||
|
routing?: CliproxyProviderRoutingHints;
|
||||||
/** Provider type for logo display (defaults to provider) */
|
/** Provider type for logo display (defaults to provider) */
|
||||||
logoProvider?: string;
|
logoProvider?: string;
|
||||||
/** Base provider for model filtering (defaults to provider). For variants, this is the parent provider. */
|
/** Base provider for model filtering (defaults to provider). For variants, this is the parent provider. */
|
||||||
@@ -92,6 +98,7 @@ export interface CustomPresetDialogProps {
|
|||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
catalog?: ProviderCatalog;
|
catalog?: ProviderCatalog;
|
||||||
allModels: { id: string; owned_by: string }[];
|
allModels: { id: string; owned_by: string }[];
|
||||||
|
routing?: CliproxyProviderRoutingHints;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RawEditorSectionProps {
|
export interface RawEditorSectionProps {
|
||||||
@@ -118,6 +125,7 @@ export interface ModelConfigSectionProps {
|
|||||||
sonnetModel?: string;
|
sonnetModel?: string;
|
||||||
haikuModel?: string;
|
haikuModel?: string;
|
||||||
providerModels: Array<{ id: string; owned_by: string }>;
|
providerModels: Array<{ id: string; owned_by: string }>;
|
||||||
|
routing?: CliproxyProviderRoutingHints;
|
||||||
/** Provider name for display */
|
/** Provider name for display */
|
||||||
provider: string;
|
provider: string;
|
||||||
/** Whether extended context (1M tokens) is enabled */
|
/** Whether extended context (1M tokens) is enabled */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import type { CliproxyProviderRoutingHints } from '@/lib/api-client';
|
||||||
import { getCodexEffortDisplay } from '@/lib/codex-effort';
|
import { getCodexEffortDisplay } from '@/lib/codex-effort';
|
||||||
import { getResolvedCatalogModels } from '@/lib/model-catalogs';
|
import { getResolvedCatalogModels } from '@/lib/model-catalogs';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -291,9 +292,20 @@ interface FlexibleModelSelectorProps {
|
|||||||
onChange: (model: string) => void;
|
onChange: (model: string) => void;
|
||||||
catalog?: ProviderCatalog;
|
catalog?: ProviderCatalog;
|
||||||
allModels: { id: string; owned_by: string }[];
|
allModels: { id: string; owned_by: string }[];
|
||||||
|
routing?: CliproxyProviderRoutingHints;
|
||||||
disabled?: boolean;
|
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({
|
export function FlexibleModelSelector({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
@@ -301,6 +313,7 @@ export function FlexibleModelSelector({
|
|||||||
onChange,
|
onChange,
|
||||||
catalog,
|
catalog,
|
||||||
allModels,
|
allModels,
|
||||||
|
routing,
|
||||||
disabled,
|
disabled,
|
||||||
}: FlexibleModelSelectorProps) {
|
}: FlexibleModelSelectorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -310,22 +323,50 @@ export function FlexibleModelSelector({
|
|||||||
[allModels, catalog]
|
[allModels, catalog]
|
||||||
);
|
);
|
||||||
const catalogModelIds = new Set(resolvedCatalogModels.map((model) => model.id));
|
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) => ({
|
const recommendedOptions = resolvedCatalogModels.map((model) => ({
|
||||||
value: model.id,
|
value: routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id,
|
||||||
groupKey: 'recommended',
|
groupKey: 'recommended',
|
||||||
searchText: `${model.id} ${model.name}`,
|
searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
|
||||||
keywords: [model.tier ?? '', catalog?.provider ?? ''],
|
keywords: [model.tier ?? '', catalog?.provider ?? ''],
|
||||||
triggerContent: (
|
triggerContent: (
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<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">
|
||||||
|
{routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id}
|
||||||
|
</span>
|
||||||
|
{routingHints.get(model.id.toLowerCase()) ? (
|
||||||
|
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||||
|
{routingHints.get(model.id.toLowerCase())?.prefix}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
itemContent: (
|
itemContent: (
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<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">
|
||||||
|
{routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id}
|
||||||
|
</span>
|
||||||
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
|
{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} />}
|
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -351,6 +392,33 @@ export function FlexibleModelSelector({
|
|||||||
</div>
|
</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;
|
const hasAvailableModels = recommendedOptions.length + allModelOptions.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -372,6 +440,14 @@ export function FlexibleModelSelector({
|
|||||||
}
|
}
|
||||||
triggerClassName="h-9"
|
triggerClassName="h-9"
|
||||||
groups={[
|
groups={[
|
||||||
|
...(selectedValueMissing && legacySelectedOption
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'current',
|
||||||
|
label: <span className="text-xs text-muted-foreground">Current value</span>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
key: 'recommended',
|
key: 'recommended',
|
||||||
label: (
|
label: (
|
||||||
@@ -387,8 +463,32 @@ 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">
|
||||||
|
Preferred pinned model: <code>{selectedRoutingHint.recommendedModelId}</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-border/70 bg-muted/25 px-2.5 py-2 text-[11px] text-muted-foreground">
|
||||||
|
Using pinned model: <code>{value}</code>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,6 +468,29 @@ export interface CliproxyCatalogModel {
|
|||||||
presetMapping?: CliproxyCatalogPresetMapping;
|
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 {
|
export interface CliproxyProviderCatalog {
|
||||||
provider: string;
|
provider: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -477,6 +500,7 @@ export interface CliproxyProviderCatalog {
|
|||||||
|
|
||||||
export interface CliproxyCatalogResponse {
|
export interface CliproxyCatalogResponse {
|
||||||
catalogs: Partial<Record<string, CliproxyProviderCatalog>>;
|
catalogs: Partial<Record<string, CliproxyProviderCatalog>>;
|
||||||
|
routing?: Partial<Record<string, CliproxyProviderRoutingHints>>;
|
||||||
source: 'live' | 'cache' | 'static';
|
source: 'live' | 'cache' | 'static';
|
||||||
cache: {
|
cache: {
|
||||||
synced: boolean;
|
synced: boolean;
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ export function CliproxyPage() {
|
|||||||
const isRemoteMode = authData?.source === 'remote';
|
const isRemoteMode = authData?.source === 'remote';
|
||||||
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
|
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
|
||||||
const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]);
|
const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]);
|
||||||
|
const routingHints = catalogData?.routing ?? {};
|
||||||
const fetchedCatalogsReady = Boolean(catalogData);
|
const fetchedCatalogsReady = Boolean(catalogData);
|
||||||
|
|
||||||
// Wrapper to persist provider selection to localStorage
|
// Wrapper to persist provider selection to localStorage
|
||||||
@@ -459,6 +460,7 @@ export function CliproxyPage() {
|
|||||||
})}
|
})}
|
||||||
authStatus={parentAuthForVariant}
|
authStatus={parentAuthForVariant}
|
||||||
catalog={catalogs[selectedVariantData.provider]}
|
catalog={catalogs[selectedVariantData.provider]}
|
||||||
|
routing={routingHints[selectedVariantData.provider]}
|
||||||
logoProvider={selectedVariantData.provider}
|
logoProvider={selectedVariantData.provider}
|
||||||
baseProvider={selectedVariantData.provider}
|
baseProvider={selectedVariantData.provider}
|
||||||
defaultTarget={selectedVariantData.target}
|
defaultTarget={selectedVariantData.target}
|
||||||
@@ -512,6 +514,7 @@ export function CliproxyPage() {
|
|||||||
displayName={selectedStatus.displayName}
|
displayName={selectedStatus.displayName}
|
||||||
authStatus={selectedStatus}
|
authStatus={selectedStatus}
|
||||||
catalog={catalogs[selectedStatus.provider]}
|
catalog={catalogs[selectedStatus.provider]}
|
||||||
|
routing={routingHints[selectedStatus.provider]}
|
||||||
isRemoteMode={isRemoteMode}
|
isRemoteMode={isRemoteMode}
|
||||||
topNotice={
|
topNotice={
|
||||||
showAccountSafetyWarning ? (
|
showAccountSafetyWarning ? (
|
||||||
|
|||||||
@@ -110,4 +110,69 @@ describe('ModelConfigSection presets', () => {
|
|||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-9-flash-preview',
|
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',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user