mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-10 06:20:13 +00:00
fix(cliproxy): harden routing prefix sync and UI pinning
This commit is contained in:
@@ -3,7 +3,6 @@ import {
|
|||||||
type CliproxyProviderRoutingHints,
|
type CliproxyProviderRoutingHints,
|
||||||
} from '../shared/cliproxy-model-routing';
|
} from '../shared/cliproxy-model-routing';
|
||||||
import { fetchCliproxyModels } from './stats-fetcher';
|
import { fetchCliproxyModels } from './stats-fetcher';
|
||||||
import { ensureManagedModelPrefixes } from './managed-model-prefixes';
|
|
||||||
import {
|
import {
|
||||||
getResolvedCatalogSnapshot,
|
getResolvedCatalogSnapshot,
|
||||||
type CatalogSource,
|
type CatalogSource,
|
||||||
@@ -20,12 +19,6 @@ export interface CatalogRoutingSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCatalogRoutingSnapshot(): Promise<CatalogRoutingSnapshot> {
|
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 snapshot: ResolvedCatalogSnapshot = await getResolvedCatalogSnapshot();
|
||||||
const modelsResponse = await fetchCliproxyModels();
|
const modelsResponse = await fetchCliproxyModels();
|
||||||
const routing = buildCliproxyRoutingHints(snapshot.catalogs, modelsResponse?.models ?? []);
|
const routing = buildCliproxyRoutingHints(snapshot.catalogs, modelsResponse?.models ?? []);
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ interface ManagementAuthFileRecord {
|
|||||||
type?: string;
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AuthFileMetadata {
|
||||||
|
prefix: string | null;
|
||||||
|
provider: CLIProxyProvider | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ManagedPrefixSyncResult {
|
export interface ManagedPrefixSyncResult {
|
||||||
checked: number;
|
checked: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
@@ -49,7 +54,7 @@ async function patchAuthFilePrefix(name: string, prefix: string): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readAuthFilePrefix(name: string): Promise<string | null> {
|
async function readAuthFileMetadata(name: string): Promise<AuthFileMetadata> {
|
||||||
const target = getProxyTarget();
|
const target = getProxyTarget();
|
||||||
const url = buildProxyUrl(
|
const url = buildProxyUrl(
|
||||||
target,
|
target,
|
||||||
@@ -65,10 +70,19 @@ async function readAuthFilePrefix(name: string): Promise<string | null> {
|
|||||||
|
|
||||||
const content = await response.text();
|
const content = await response.text();
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content) as { prefix?: unknown };
|
const parsed = JSON.parse(content) as { prefix?: unknown; provider?: unknown; type?: unknown };
|
||||||
return typeof parsed.prefix === 'string' ? parsed.prefix.trim() : null;
|
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 {
|
} catch {
|
||||||
return null;
|
return { prefix: null, provider: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,10 +115,18 @@ export async function ensureManagedModelPrefixes(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
checked += 1;
|
checked += 1;
|
||||||
const currentPrefix = await readAuthFilePrefix(record.name);
|
const { prefix: currentPrefix, provider: fileProvider } = await readAuthFileMetadata(
|
||||||
|
record.name
|
||||||
|
);
|
||||||
|
if (fileProvider !== provider) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (currentPrefix === prefix) {
|
if (currentPrefix === prefix) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (currentPrefix && currentPrefix !== prefix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await patchAuthFilePrefix(record.name, prefix);
|
await patchAuthFilePrefix(record.name, prefix);
|
||||||
updated += 1;
|
updated += 1;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
refreshCatalogFromProxy,
|
refreshCatalogFromProxy,
|
||||||
} from '../../cliproxy/catalog-cache';
|
} from '../../cliproxy/catalog-cache';
|
||||||
import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing';
|
import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing';
|
||||||
|
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
|
||||||
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';
|
||||||
@@ -46,8 +47,17 @@ export async function handleCatalogStatus(verbose: boolean): Promise<void> {
|
|||||||
console.log(header('Model Catalog'));
|
console.log(header('Model Catalog'));
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
const routingSnapshot = await getCatalogRoutingSnapshot();
|
let routingSnapshot: Awaited<ReturnType<typeof getCatalogRoutingSnapshot>> | null = null;
|
||||||
const cacheAge = routingSnapshot.cacheAge ?? getCacheAge();
|
if (verbose) {
|
||||||
|
try {
|
||||||
|
await ensureManagedModelPrefixes();
|
||||||
|
routingSnapshot = await getCatalogRoutingSnapshot();
|
||||||
|
} catch {
|
||||||
|
routingSnapshot = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -58,10 +68,10 @@ 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 = routingSnapshot.catalogs[provider] ?? getResolvedCatalog(provider);
|
const catalog = routingSnapshot?.catalogs[provider] ?? getResolvedCatalog(provider);
|
||||||
if (catalog) {
|
if (catalog) {
|
||||||
const count = catalog.models.length;
|
const count = catalog.models.length;
|
||||||
const routing = routingSnapshot.routing[provider];
|
const routing = routingSnapshot?.routing[provider];
|
||||||
const suffix = renderRoutingSummary(routing);
|
const suffix = renderRoutingSummary(routing);
|
||||||
console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models${suffix}`);
|
console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models${suffix}`);
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
@@ -112,7 +122,9 @@ function renderVerboseRouting(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(dim(` preferred: ${hint.recommendedModelId}`));
|
console.log(
|
||||||
|
dim(` ${hint.pinnedAvailable ? 'preferred' : 'suggested'}: ${hint.recommendedModelId}`)
|
||||||
|
);
|
||||||
if (hint.unprefixedStatus === 'safe') {
|
if (hint.unprefixedStatus === 'safe') {
|
||||||
console.log(dim(` unprefixed: resolves to ${routing.displayName}`));
|
console.log(dim(` unprefixed: resolves to ${routing.displayName}`));
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import * as path from 'path';
|
|||||||
import { getProviderAccounts } from '../../cliproxy/account-manager';
|
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 { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing';
|
||||||
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog';
|
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog';
|
||||||
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
|
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
|
||||||
import { getManagedModelPrefix } from '../../shared/cliproxy-model-routing';
|
import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing';
|
||||||
import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types';
|
import { 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';
|
||||||
@@ -117,9 +118,14 @@ function formatModelOption(model: ModelEntry): string {
|
|||||||
return `${model.name}${tierBadge}`;
|
return `${model.name}${tierBadge}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectableModelId(provider: CLIProxyProvider, modelId: string): string {
|
function getSelectableModelId(
|
||||||
const prefix = getManagedModelPrefix(provider);
|
modelId: string,
|
||||||
return prefix ? `${prefix}/${modelId}` : modelId;
|
routing: CliproxyProviderRoutingHints | undefined
|
||||||
|
): string {
|
||||||
|
const hint = routing?.models.find(
|
||||||
|
(entry) => entry.modelId.toLowerCase() === modelId.toLowerCase()
|
||||||
|
);
|
||||||
|
return hint?.pinnedAvailable ? hint.recommendedModelId : modelId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBackendLabel(backend: CLIProxyBackend): string {
|
function getBackendLabel(backend: CLIProxyBackend): string {
|
||||||
@@ -186,10 +192,11 @@ async function selectTierConfig(
|
|||||||
} catch {
|
} catch {
|
||||||
// Keep interactive model selection available even when prefix repair fails.
|
// Keep interactive model selection available even when prefix repair fails.
|
||||||
}
|
}
|
||||||
|
const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider];
|
||||||
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
||||||
if (catalog) {
|
if (catalog) {
|
||||||
const modelOptions = catalog.models.map((m) => ({
|
const modelOptions = catalog.models.map((m) => ({
|
||||||
id: getSelectableModelId(provider as CLIProxyProvider, m.id),
|
id: getSelectableModelId(m.id, routing),
|
||||||
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);
|
||||||
@@ -446,10 +453,11 @@ export async function handleCreate(
|
|||||||
} catch {
|
} catch {
|
||||||
// Keep variant creation available even when prefix repair fails.
|
// Keep variant creation available even when prefix repair fails.
|
||||||
}
|
}
|
||||||
|
const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider];
|
||||||
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
||||||
if (catalog) {
|
if (catalog) {
|
||||||
const modelOptions = catalog.models.map((m) => ({
|
const modelOptions = catalog.models.map((m) => ({
|
||||||
id: getSelectableModelId(provider as CLIProxyProvider, m.id),
|
id: getSelectableModelId(m.id, routing),
|
||||||
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);
|
||||||
@@ -695,10 +703,13 @@ export async function handleEdit(
|
|||||||
} catch {
|
} catch {
|
||||||
// Keep edit flow available even when prefix repair fails.
|
// Keep edit flow available even when prefix repair fails.
|
||||||
}
|
}
|
||||||
|
const routing = (await getCatalogRoutingSnapshot()).routing[
|
||||||
|
providerForModel as CLIProxyProvider
|
||||||
|
];
|
||||||
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: getSelectableModelId(providerForModel as CLIProxyProvider, m.id),
|
id: getSelectableModelId(m.id, routing),
|
||||||
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);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface CliproxyModelRoutingHint {
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
pinnedModelId: string;
|
pinnedModelId: string;
|
||||||
recommendedModelId: string;
|
recommendedModelId: string;
|
||||||
|
pinnedAvailable: boolean;
|
||||||
unprefixedStatus: ModelRoutingStatus;
|
unprefixedStatus: ModelRoutingStatus;
|
||||||
effectiveProvider: string | null;
|
effectiveProvider: string | null;
|
||||||
effectiveDisplayName: string | null;
|
effectiveDisplayName: string | null;
|
||||||
@@ -108,9 +109,13 @@ function buildSummary(
|
|||||||
providerDisplayName: string,
|
providerDisplayName: string,
|
||||||
hint: Pick<
|
hint: Pick<
|
||||||
CliproxyModelRoutingHint,
|
CliproxyModelRoutingHint,
|
||||||
'modelId' | 'pinnedModelId' | 'unprefixedStatus' | 'effectiveDisplayName'
|
'modelId' | 'pinnedModelId' | 'pinnedAvailable' | 'unprefixedStatus' | 'effectiveDisplayName'
|
||||||
>
|
>
|
||||||
): string {
|
): 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') {
|
if (hint.unprefixedStatus === 'safe') {
|
||||||
return `${hint.modelId} currently resolves to ${providerDisplayName}. Use ${hint.pinnedModelId} to keep it pinned.`;
|
return `${hint.modelId} currently resolves to ${providerDisplayName}. Use ${hint.pinnedModelId} to keep it pinned.`;
|
||||||
}
|
}
|
||||||
@@ -151,6 +156,15 @@ export function buildCliproxyRoutingHints(
|
|||||||
let prefixOnlyCount = 0;
|
let prefixOnlyCount = 0;
|
||||||
|
|
||||||
const models = catalog.models.map((model) => {
|
const models = catalog.models.map((model) => {
|
||||||
|
const pinnedCandidates = mergedModels
|
||||||
|
.filter((candidate) => normalize(candidate.id).endsWith(`/${normalize(model.id)}`))
|
||||||
|
.filter((candidate) => inferProvider(candidate) === providerKey)
|
||||||
|
.map((candidate) => candidate.id)
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
const managedPinnedId = `${prefix}/${model.id}`;
|
||||||
|
const recommendedModelId = pinnedCandidates.includes(managedPinnedId)
|
||||||
|
? managedPinnedId
|
||||||
|
: (pinnedCandidates[0] ?? managedPinnedId);
|
||||||
const mergedModel = mergedModelMap.get(normalize(model.id));
|
const mergedModel = mergedModelMap.get(normalize(model.id));
|
||||||
const effectiveProvider = mergedModel ? inferProvider(mergedModel) : null;
|
const effectiveProvider = mergedModel ? inferProvider(mergedModel) : null;
|
||||||
const effectiveDisplayName =
|
const effectiveDisplayName =
|
||||||
@@ -173,8 +187,9 @@ export function buildCliproxyRoutingHints(
|
|||||||
modelId: model.id,
|
modelId: model.id,
|
||||||
modelName: model.name?.trim() || model.id,
|
modelName: model.name?.trim() || model.id,
|
||||||
prefix,
|
prefix,
|
||||||
pinnedModelId: `${prefix}/${model.id}`,
|
pinnedModelId: managedPinnedId,
|
||||||
recommendedModelId: `${prefix}/${model.id}`,
|
recommendedModelId,
|
||||||
|
pinnedAvailable: pinnedCandidates.length > 0,
|
||||||
unprefixedStatus,
|
unprefixedStatus,
|
||||||
effectiveProvider,
|
effectiveProvider,
|
||||||
effectiveDisplayName,
|
effectiveDisplayName,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { WebSocketServer } from 'ws';
|
|||||||
import { setupWebSocket } from './websocket';
|
import { setupWebSocket } from './websocket';
|
||||||
import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware';
|
import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware';
|
||||||
import { requestLoggingMiddleware } from './middleware/request-logging-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 { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
|
||||||
import { shutdownUsageAggregator } from './usage/aggregator';
|
import { shutdownUsageAggregator } from './usage/aggregator';
|
||||||
import { createLogger } from '../services/logging';
|
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)
|
// Start auto-sync watcher (if enabled in config)
|
||||||
startAutoSyncWatcher();
|
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
|
// Combined cleanup function
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
wsCleanup();
|
wsCleanup();
|
||||||
|
|||||||
@@ -316,12 +316,6 @@ 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) {
|
||||||
@@ -393,12 +387,6 @@ 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) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ describe('cliproxy model routing hints', () => {
|
|||||||
|
|
||||||
expect(routing.gemini?.models[0]).toMatchObject({
|
expect(routing.gemini?.models[0]).toMatchObject({
|
||||||
recommendedModelId: 'gcli/gemini-3-flash-preview',
|
recommendedModelId: 'gcli/gemini-3-flash-preview',
|
||||||
|
pinnedAvailable: false,
|
||||||
unprefixedStatus: 'shadowed',
|
unprefixedStatus: 'shadowed',
|
||||||
effectiveProvider: 'agy',
|
effectiveProvider: 'agy',
|
||||||
effectiveDisplayName: 'Antigravity',
|
effectiveDisplayName: 'Antigravity',
|
||||||
@@ -37,6 +38,7 @@ describe('cliproxy model routing hints', () => {
|
|||||||
|
|
||||||
expect(routing.agy?.models[0]).toMatchObject({
|
expect(routing.agy?.models[0]).toMatchObject({
|
||||||
recommendedModelId: 'agy/gemini-3-flash',
|
recommendedModelId: 'agy/gemini-3-flash',
|
||||||
|
pinnedAvailable: false,
|
||||||
unprefixedStatus: 'safe',
|
unprefixedStatus: 'safe',
|
||||||
effectiveProvider: 'agy',
|
effectiveProvider: 'agy',
|
||||||
});
|
});
|
||||||
@@ -57,6 +59,7 @@ describe('cliproxy model routing hints', () => {
|
|||||||
expect(routing.gemini?.prefixOnlyCount).toBe(1);
|
expect(routing.gemini?.prefixOnlyCount).toBe(1);
|
||||||
expect(routing.gemini?.models[0]).toMatchObject({
|
expect(routing.gemini?.models[0]).toMatchObject({
|
||||||
recommendedModelId: 'gcli/gemini-3.1-pro-preview',
|
recommendedModelId: 'gcli/gemini-3.1-pro-preview',
|
||||||
|
pinnedAvailable: false,
|
||||||
unprefixedStatus: 'prefix-only',
|
unprefixedStatus: 'prefix-only',
|
||||||
effectiveProvider: null,
|
effectiveProvider: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,25 @@ import { FlexibleModelSelector } from '../provider-model-selector';
|
|||||||
import type { CustomPresetDialogProps, ModelMappingValues } from './types';
|
import type { CustomPresetDialogProps, ModelMappingValues } from './types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
function normalizePresetValues(
|
||||||
|
values: ModelMappingValues,
|
||||||
|
routing: CustomPresetDialogProps['routing']
|
||||||
|
): ModelMappingValues {
|
||||||
|
const toPreferredModelId = (modelId: string): string => {
|
||||||
|
const hint = routing?.models.find(
|
||||||
|
(entry) => entry.modelId.toLowerCase() === modelId.toLowerCase()
|
||||||
|
);
|
||||||
|
return hint?.pinnedAvailable ? hint.recommendedModelId : modelId;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: toPreferredModelId(values.default),
|
||||||
|
opus: toPreferredModelId(values.opus),
|
||||||
|
sonnet: toPreferredModelId(values.sonnet),
|
||||||
|
haiku: toPreferredModelId(values.haiku),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function CustomPresetDialog({
|
export function CustomPresetDialog({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -32,13 +51,15 @@ export function CustomPresetDialog({
|
|||||||
routing,
|
routing,
|
||||||
}: CustomPresetDialogProps) {
|
}: CustomPresetDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [values, setValues] = useState<ModelMappingValues>(currentValues);
|
const [values, setValues] = useState<ModelMappingValues>(
|
||||||
|
normalizePresetValues(currentValues, routing)
|
||||||
|
);
|
||||||
const [presetName, setPresetName] = useState('');
|
const [presetName, setPresetName] = useState('');
|
||||||
|
|
||||||
// Reset values when dialog opens with current values
|
// Reset values when dialog opens with current values
|
||||||
const handleOpenChange = (isOpen: boolean) => {
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setValues(currentValues);
|
setValues(normalizePresetValues(currentValues, routing));
|
||||||
setPresetName('');
|
setPresetName('');
|
||||||
} else {
|
} else {
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -53,13 +53,16 @@ export function ModelConfigSection({
|
|||||||
onDeletePreset,
|
onDeletePreset,
|
||||||
isDeletePending,
|
isDeletePending,
|
||||||
}: ModelConfigSectionProps) {
|
}: ModelConfigSectionProps) {
|
||||||
|
const pinningReady = (routing?.models ?? []).some((hint) => hint.pinnedAvailable);
|
||||||
const routingHintMap = useMemo(
|
const routingHintMap = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)),
|
new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)),
|
||||||
[routing]
|
[routing]
|
||||||
);
|
);
|
||||||
const toPreferredModelId = (modelId: string): string =>
|
const toPreferredModelId = (modelId: string): string =>
|
||||||
routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId;
|
routingHintMap.get(modelId.toLowerCase())?.pinnedAvailable
|
||||||
|
? (routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId)
|
||||||
|
: modelId;
|
||||||
|
|
||||||
const extendedContextModels = useMemo(() => {
|
const extendedContextModels = useMemo(() => {
|
||||||
if (!catalog) return [];
|
if (!catalog) return [];
|
||||||
@@ -160,10 +163,10 @@ export function ModelConfigSection({
|
|||||||
className="text-xs h-7 gap-1 pr-6"
|
className="text-xs h-7 gap-1 pr-6"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onApplyPreset({
|
onApplyPreset({
|
||||||
ANTHROPIC_MODEL: preset.default,
|
ANTHROPIC_MODEL: toPreferredModelId(preset.default),
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
|
ANTHROPIC_DEFAULT_OPUS_MODEL: toPreferredModelId(preset.opus),
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
|
ANTHROPIC_DEFAULT_SONNET_MODEL: toPreferredModelId(preset.sonnet),
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: toPreferredModelId(preset.haiku),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -209,8 +212,17 @@ export function ModelConfigSection({
|
|||||||
</p>
|
</p>
|
||||||
{routing ? (
|
{routing ? (
|
||||||
<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">
|
||||||
Preferred pinned model names use the <code>{routing.prefix}/</code> prefix. Unprefixed
|
{pinningReady ? (
|
||||||
names can still resolve to a different backend when providers overlap.
|
<>
|
||||||
|
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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{provider === 'codex' && (
|
{provider === 'codex' && (
|
||||||
|
|||||||
@@ -306,6 +306,16 @@ function normalizeModelValue(
|
|||||||
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPreferredOptionValue(
|
||||||
|
modelId: string,
|
||||||
|
routingHint: CliproxyProviderRoutingHints['models'][number] | undefined
|
||||||
|
): string {
|
||||||
|
if (!routingHint?.pinnedAvailable) {
|
||||||
|
return modelId;
|
||||||
|
}
|
||||||
|
return routingHint.recommendedModelId;
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexibleModelSelector({
|
export function FlexibleModelSelector({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
@@ -334,16 +344,16 @@ export function FlexibleModelSelector({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const recommendedOptions = resolvedCatalogModels.map((model) => ({
|
const recommendedOptions = resolvedCatalogModels.map((model) => ({
|
||||||
value: routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id,
|
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||||
groupKey: 'recommended',
|
groupKey: 'recommended',
|
||||||
searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
|
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">
|
<span className="truncate font-mono text-xs">
|
||||||
{routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id}
|
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||||
</span>
|
</span>
|
||||||
{routingHints.get(model.id.toLowerCase()) ? (
|
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
|
||||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||||
{routingHints.get(model.id.toLowerCase())?.prefix}
|
{routingHints.get(model.id.toLowerCase())?.prefix}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -354,7 +364,7 @@ export function FlexibleModelSelector({
|
|||||||
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">
|
<span className="truncate font-mono text-xs">
|
||||||
{routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id}
|
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||||
</span>
|
</span>
|
||||||
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
|
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
|
||||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
|
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
|
||||||
@@ -375,19 +385,38 @@ export function FlexibleModelSelector({
|
|||||||
const allModelOptions = allModels
|
const allModelOptions = allModels
|
||||||
.filter((model) => !catalogModelIds.has(model.id))
|
.filter((model) => !catalogModelIds.has(model.id))
|
||||||
.map((model) => ({
|
.map((model) => ({
|
||||||
value: model.id,
|
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||||
groupKey: 'all',
|
groupKey: 'all',
|
||||||
searchText: model.id,
|
searchText: `${model.id} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
|
||||||
keywords: [model.owned_by],
|
keywords: [model.owned_by],
|
||||||
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">
|
||||||
|
{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} />}
|
{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">
|
||||||
|
{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} />}
|
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -479,14 +508,21 @@ export function FlexibleModelSelector({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
Preferred pinned model: <code>{selectedRoutingHint.recommendedModelId}</code>
|
{selectedRoutingHint.pinnedAvailable
|
||||||
|
? 'Preferred pinned model:'
|
||||||
|
: 'Pinned route status:'}{' '}
|
||||||
|
<code>
|
||||||
|
{selectedRoutingHint.pinnedAvailable
|
||||||
|
? selectedRoutingHint.recommendedModelId
|
||||||
|
: selectedRoutingHint.pinnedModelId}
|
||||||
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 leading-5">{selectedRoutingHint.summary}</p>
|
<p className="mt-1 leading-5">{selectedRoutingHint.summary}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{value && !selectedRoutingHint && normalizeModelValue(value, routing) !== value ? (
|
{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">
|
<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">
|
||||||
Using pinned model: <code>{value}</code>
|
Pinned model is not currently advertised by the proxy: <code>{value}</code>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ export interface CliproxyModelRoutingHint {
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
pinnedModelId: string;
|
pinnedModelId: string;
|
||||||
recommendedModelId: string;
|
recommendedModelId: string;
|
||||||
|
pinnedAvailable: boolean;
|
||||||
unprefixedStatus: 'safe' | 'shadowed' | 'prefix-only';
|
unprefixedStatus: 'safe' | 'shadowed' | 'prefix-only';
|
||||||
effectiveProvider: string | null;
|
effectiveProvider: string | null;
|
||||||
effectiveDisplayName: string | null;
|
effectiveDisplayName: string | null;
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ describe('ModelConfigSection presets', () => {
|
|||||||
prefix: 'gcli',
|
prefix: 'gcli',
|
||||||
pinnedModelId: 'gcli/gemini-3.1-pro-preview',
|
pinnedModelId: 'gcli/gemini-3.1-pro-preview',
|
||||||
recommendedModelId: 'gcli/gemini-3.1-pro-preview',
|
recommendedModelId: 'gcli/gemini-3.1-pro-preview',
|
||||||
|
pinnedAvailable: true,
|
||||||
unprefixedStatus: 'shadowed',
|
unprefixedStatus: 'shadowed',
|
||||||
effectiveProvider: 'agy',
|
effectiveProvider: 'agy',
|
||||||
effectiveDisplayName: 'Antigravity',
|
effectiveDisplayName: 'Antigravity',
|
||||||
@@ -149,6 +150,7 @@ describe('ModelConfigSection presets', () => {
|
|||||||
prefix: 'gcli',
|
prefix: 'gcli',
|
||||||
pinnedModelId: 'gcli/gemini-3-flash-preview',
|
pinnedModelId: 'gcli/gemini-3-flash-preview',
|
||||||
recommendedModelId: 'gcli/gemini-3-flash-preview',
|
recommendedModelId: 'gcli/gemini-3-flash-preview',
|
||||||
|
pinnedAvailable: true,
|
||||||
unprefixedStatus: 'shadowed',
|
unprefixedStatus: 'shadowed',
|
||||||
effectiveProvider: 'agy',
|
effectiveProvider: 'agy',
|
||||||
effectiveDisplayName: 'Antigravity',
|
effectiveDisplayName: 'Antigravity',
|
||||||
@@ -175,4 +177,79 @@ describe('ModelConfigSection presets', () => {
|
|||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gcli/gemini-3-flash-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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user