mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-20 05:11:48 +00:00
fix(cliproxy): harden routing prefix sync and UI pinning
This commit is contained in:
1 parent
b8a8f84153
commit
0c10cb6f47
13 files changed
+260
-59
No files matched your search
@@ -3,7 +3,6 @@ import {
|
||||
type CliproxyProviderRoutingHints,
|
||||
} from '../shared/cliproxy-model-routing';
|
||||
import { fetchCliproxyModels } from './stats-fetcher';
|
||||
import { ensureManagedModelPrefixes } from './managed-model-prefixes';
|
||||
import {
|
||||
getResolvedCatalogSnapshot,
|
||||
type CatalogSource,
|
||||
@@ -20,12 +19,6 @@ export interface 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 modelsResponse = await fetchCliproxyModels();
|
||||
const routing = buildCliproxyRoutingHints(snapshot.catalogs, modelsResponse?.models ?? []);
|
||||
|
||||
@@ -10,6 +10,11 @@ interface ManagementAuthFileRecord {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface AuthFileMetadata {
|
||||
prefix: string | null;
|
||||
provider: CLIProxyProvider | null;
|
||||
}
|
||||
|
||||
export interface ManagedPrefixSyncResult {
|
||||
checked: 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 url = buildProxyUrl(
|
||||
target,
|
||||
@@ -65,10 +70,19 @@ async function readAuthFilePrefix(name: string): Promise<string | null> {
|
||||
|
||||
const content = await response.text();
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { prefix?: unknown };
|
||||
return typeof parsed.prefix === 'string' ? parsed.prefix.trim() : null;
|
||||
const parsed = JSON.parse(content) as { prefix?: unknown; provider?: unknown; type?: unknown };
|
||||
const providerName =
|
||||
typeof parsed.provider === 'string'
|
||||
? parsed.provider
|
||||
: typeof parsed.type === 'string'
|
||||
? parsed.type
|
||||
: '';
|
||||
return {
|
||||
prefix: typeof parsed.prefix === 'string' ? parsed.prefix.trim() : null,
|
||||
provider: providerName ? mapExternalProviderName(providerName) : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
return { prefix: null, provider: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +115,18 @@ export async function ensureManagedModelPrefixes(
|
||||
|
||||
try {
|
||||
checked += 1;
|
||||
const currentPrefix = await readAuthFilePrefix(record.name);
|
||||
const { prefix: currentPrefix, provider: fileProvider } = await readAuthFileMetadata(
|
||||
record.name
|
||||
);
|
||||
if (fileProvider !== provider) {
|
||||
continue;
|
||||
}
|
||||
if (currentPrefix === prefix) {
|
||||
continue;
|
||||
}
|
||||
if (currentPrefix && currentPrefix !== prefix) {
|
||||
continue;
|
||||
}
|
||||
await patchAuthFilePrefix(record.name, prefix);
|
||||
updated += 1;
|
||||
} catch {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
refreshCatalogFromProxy,
|
||||
} from '../../cliproxy/catalog-cache';
|
||||
import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing';
|
||||
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
|
||||
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import type { RemoteModelInfo } from '../../cliproxy/management-api-types';
|
||||
@@ -46,8 +47,17 @@ export async function handleCatalogStatus(verbose: boolean): Promise<void> {
|
||||
console.log(header('Model Catalog'));
|
||||
console.log('');
|
||||
|
||||
const routingSnapshot = await getCatalogRoutingSnapshot();
|
||||
const cacheAge = routingSnapshot.cacheAge ?? getCacheAge();
|
||||
let routingSnapshot: Awaited<ReturnType<typeof getCatalogRoutingSnapshot>> | null = null;
|
||||
if (verbose) {
|
||||
try {
|
||||
await ensureManagedModelPrefixes();
|
||||
routingSnapshot = await getCatalogRoutingSnapshot();
|
||||
} catch {
|
||||
routingSnapshot = null;
|
||||
}
|
||||
}
|
||||
|
||||
const cacheAge = routingSnapshot?.cacheAge ?? getCacheAge();
|
||||
if (cacheAge) {
|
||||
console.log(` Cache: ${color('synced', 'success')} (${cacheAge})`);
|
||||
} else {
|
||||
@@ -58,10 +68,10 @@ export async function handleCatalogStatus(verbose: boolean): Promise<void> {
|
||||
console.log(subheader('Providers:'));
|
||||
|
||||
for (const provider of SYNCABLE_PROVIDERS) {
|
||||
const catalog = routingSnapshot.catalogs[provider] ?? getResolvedCatalog(provider);
|
||||
const catalog = routingSnapshot?.catalogs[provider] ?? getResolvedCatalog(provider);
|
||||
if (catalog) {
|
||||
const count = catalog.models.length;
|
||||
const routing = routingSnapshot.routing[provider];
|
||||
const routing = routingSnapshot?.routing[provider];
|
||||
const suffix = renderRoutingSummary(routing);
|
||||
console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models${suffix}`);
|
||||
if (verbose) {
|
||||
@@ -112,7 +122,9 @@ function renderVerboseRouting(
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(dim(` preferred: ${hint.recommendedModelId}`));
|
||||
console.log(
|
||||
dim(` ${hint.pinnedAvailable ? 'preferred' : 'suggested'}: ${hint.recommendedModelId}`)
|
||||
);
|
||||
if (hint.unprefixedStatus === 'safe') {
|
||||
console.log(dim(` unprefixed: resolves to ${routing.displayName}`));
|
||||
continue;
|
||||
|
||||
@@ -10,9 +10,10 @@ import * as path from 'path';
|
||||
import { getProviderAccounts } from '../../cliproxy/account-manager';
|
||||
import { triggerOAuth } from '../../cliproxy/auth/oauth-handler';
|
||||
import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing';
|
||||
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog';
|
||||
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
|
||||
import { getManagedModelPrefix } from '../../shared/cliproxy-model-routing';
|
||||
import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing';
|
||||
import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
|
||||
@@ -117,9 +118,14 @@ function formatModelOption(model: ModelEntry): string {
|
||||
return `${model.name}${tierBadge}`;
|
||||
}
|
||||
|
||||
function getSelectableModelId(provider: CLIProxyProvider, modelId: string): string {
|
||||
const prefix = getManagedModelPrefix(provider);
|
||||
return prefix ? `${prefix}/${modelId}` : modelId;
|
||||
function getSelectableModelId(
|
||||
modelId: string,
|
||||
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 {
|
||||
@@ -186,10 +192,11 @@ async function selectTierConfig(
|
||||
} catch {
|
||||
// Keep interactive model selection available even when prefix repair fails.
|
||||
}
|
||||
const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider];
|
||||
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
||||
if (catalog) {
|
||||
const modelOptions = catalog.models.map((m) => ({
|
||||
id: getSelectableModelId(provider as CLIProxyProvider, m.id),
|
||||
id: getSelectableModelId(m.id, routing),
|
||||
label: formatModelOption(m),
|
||||
}));
|
||||
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
||||
@@ -446,10 +453,11 @@ export async function handleCreate(
|
||||
} catch {
|
||||
// Keep variant creation available even when prefix repair fails.
|
||||
}
|
||||
const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider];
|
||||
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
||||
if (catalog) {
|
||||
const modelOptions = catalog.models.map((m) => ({
|
||||
id: getSelectableModelId(provider as CLIProxyProvider, m.id),
|
||||
id: getSelectableModelId(m.id, routing),
|
||||
label: formatModelOption(m),
|
||||
}));
|
||||
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
||||
@@ -695,10 +703,13 @@ export async function handleEdit(
|
||||
} catch {
|
||||
// Keep edit flow available even when prefix repair fails.
|
||||
}
|
||||
const routing = (await getCatalogRoutingSnapshot()).routing[
|
||||
providerForModel as CLIProxyProvider
|
||||
];
|
||||
const catalog = getProviderCatalog(providerForModel as CLIProxyProvider);
|
||||
if (catalog) {
|
||||
const modelOptions = catalog.models.map((m) => ({
|
||||
id: getSelectableModelId(providerForModel as CLIProxyProvider, m.id),
|
||||
id: getSelectableModelId(m.id, routing),
|
||||
label: formatModelOption(m),
|
||||
}));
|
||||
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface CliproxyModelRoutingHint {
|
||||
prefix: string;
|
||||
pinnedModelId: string;
|
||||
recommendedModelId: string;
|
||||
pinnedAvailable: boolean;
|
||||
unprefixedStatus: ModelRoutingStatus;
|
||||
effectiveProvider: string | null;
|
||||
effectiveDisplayName: string | null;
|
||||
@@ -108,9 +109,13 @@ function buildSummary(
|
||||
providerDisplayName: string,
|
||||
hint: Pick<
|
||||
CliproxyModelRoutingHint,
|
||||
'modelId' | 'pinnedModelId' | 'unprefixedStatus' | 'effectiveDisplayName'
|
||||
'modelId' | 'pinnedModelId' | 'pinnedAvailable' | 'unprefixedStatus' | 'effectiveDisplayName'
|
||||
>
|
||||
): string {
|
||||
if (!hint.pinnedAvailable) {
|
||||
return `${hint.modelId} does not currently advertise a live pinned route for ${hint.pinnedModelId}. Reconnect or refresh managed prefixes before treating it as pinned.`;
|
||||
}
|
||||
|
||||
if (hint.unprefixedStatus === 'safe') {
|
||||
return `${hint.modelId} currently resolves to ${providerDisplayName}. Use ${hint.pinnedModelId} to keep it pinned.`;
|
||||
}
|
||||
@@ -151,6 +156,15 @@ export function buildCliproxyRoutingHints(
|
||||
let prefixOnlyCount = 0;
|
||||
|
||||
const models = catalog.models.map((model) => {
|
||||
const pinnedCandidates = mergedModels
|
||||
.filter((candidate) => normalize(candidate.id).endsWith(`/${normalize(model.id)}`))
|
||||
.filter((candidate) => inferProvider(candidate) === providerKey)
|
||||
.map((candidate) => candidate.id)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
const managedPinnedId = `${prefix}/${model.id}`;
|
||||
const recommendedModelId = pinnedCandidates.includes(managedPinnedId)
|
||||
? managedPinnedId
|
||||
: (pinnedCandidates[0] ?? managedPinnedId);
|
||||
const mergedModel = mergedModelMap.get(normalize(model.id));
|
||||
const effectiveProvider = mergedModel ? inferProvider(mergedModel) : null;
|
||||
const effectiveDisplayName =
|
||||
@@ -173,8 +187,9 @@ export function buildCliproxyRoutingHints(
|
||||
modelId: model.id,
|
||||
modelName: model.name?.trim() || model.id,
|
||||
prefix,
|
||||
pinnedModelId: `${prefix}/${model.id}`,
|
||||
recommendedModelId: `${prefix}/${model.id}`,
|
||||
pinnedModelId: managedPinnedId,
|
||||
recommendedModelId,
|
||||
pinnedAvailable: pinnedCandidates.length > 0,
|
||||
unprefixedStatus,
|
||||
effectiveProvider,
|
||||
effectiveDisplayName,
|
||||
|
||||
@@ -13,6 +13,8 @@ import { WebSocketServer } from 'ws';
|
||||
import { setupWebSocket } from './websocket';
|
||||
import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware';
|
||||
import { requestLoggingMiddleware } from './middleware/request-logging-middleware';
|
||||
import { ensureManagedModelPrefixes } from '../cliproxy/managed-model-prefixes';
|
||||
import { getProxyTarget } from '../cliproxy/proxy-target-resolver';
|
||||
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
|
||||
import { shutdownUsageAggregator } from './usage/aggregator';
|
||||
import { createLogger } from '../services/logging';
|
||||
@@ -119,6 +121,14 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
||||
// Start auto-sync watcher (if enabled in config)
|
||||
startAutoSyncWatcher();
|
||||
|
||||
if (!getProxyTarget().isRemote) {
|
||||
void ensureManagedModelPrefixes().catch((error) => {
|
||||
logger.warn('cliproxy.prefix_sync_failed', 'Managed model prefix repair failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Combined cleanup function
|
||||
const cleanup = () => {
|
||||
wsCleanup();
|
||||
|
||||
@@ -316,12 +316,6 @@ export function getStartAuthNicknameError(
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
try {
|
||||
await ensureManagedModelPrefixes();
|
||||
} catch {
|
||||
// Keep auth status available even when prefix repair cannot run.
|
||||
}
|
||||
|
||||
// Check if remote mode is enabled
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
@@ -393,12 +387,6 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
*/
|
||||
router.get('/accounts', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
try {
|
||||
await ensureManagedModelPrefixes();
|
||||
} catch {
|
||||
// Non-fatal: account listing should still work without prefix repair.
|
||||
}
|
||||
|
||||
// Check if remote mode is enabled
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
|
||||
Reference in new issue
Block a user