feat(cliproxy): add hybrid catalog sync with CLIProxyAPI (#485)

* chore(release): 7.38.0 [skip ci]

## [7.38.0](https://github.com/kaitranntt/ccs/compare/v7.37.1...v7.38.0) (2026-02-07)

### Features

* **release:** v7.38.0 - Extended Context, Qwen Models, Bug Fixes ([#480](https://github.com/kaitranntt/ccs/issues/480)) ([b454834](https://github.com/kaitranntt/ccs/commit/b4548341750804339c87c48259dd8741425d2acf)), closes [#472](https://github.com/kaitranntt/ccs/issues/472) [#474](https://github.com/kaitranntt/ccs/issues/474) [#103](https://github.com/kaitranntt/ccs/issues/103) [#478](https://github.com/kaitranntt/ccs/issues/478) [#482](https://github.com/kaitranntt/ccs/issues/482)

* feat(cliproxy): add hybrid catalog sync with CLIProxyAPI

Add `ccs cliproxy catalog` command family for syncing model catalogs
from CLIProxyAPI's model-definitions endpoint. Cached locally with
24h TTL, merges remote models with static catalog preserving
broken/deprecated flags. Dashboard API endpoint at /api/cliproxy/catalog.

Closes #477

---------

Co-authored-by: semantic-release-bot <semantic-release-bot@martynus.net>
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-06 23:16:54 -05:00
committed by GitHub
co-authored by semantic-release-bot
parent 9e22018dee
commit c8a099509a
10 changed files with 498 additions and 0 deletions
+6
View File
@@ -1,3 +1,9 @@
## [7.38.0](https://github.com/kaitranntt/ccs/compare/v7.37.1...v7.38.0) (2026-02-07)
### Features
* **release:** v7.38.0 - Extended Context, Qwen Models, Bug Fixes ([#480](https://github.com/kaitranntt/ccs/issues/480)) ([b454834](https://github.com/kaitranntt/ccs/commit/b4548341750804339c87c48259dd8741425d2acf)), closes [#472](https://github.com/kaitranntt/ccs/issues/472) [#474](https://github.com/kaitranntt/ccs/issues/474) [#103](https://github.com/kaitranntt/ccs/issues/103) [#478](https://github.com/kaitranntt/ccs/issues/478) [#482](https://github.com/kaitranntt/ccs/issues/482)
## [7.37.1](https://github.com/kaitranntt/ccs/compare/v7.37.0...v7.37.1) (2026-02-05) ## [7.37.1](https://github.com/kaitranntt/ccs/compare/v7.37.0...v7.37.1) (2026-02-05)
### Bug Fixes ### Bug Fixes
+233
View File
@@ -0,0 +1,233 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
import type { CLIProxyProvider } from './types';
import type { ModelEntry, ProviderCatalog, ThinkingSupport } from './model-catalog';
import { MODEL_CATALOG } from './model-catalog';
import type { RemoteModelInfo, RemoteThinkingSupport } from './management-api-types';
const CACHE_FILE_NAME = 'model-catalog-cache.json';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
/** Cache structure stored on disk */
interface CatalogCacheData {
providers: Record<string, RemoteModelInfo[]>;
fetchedAt: number;
}
/** Channel name → CCS provider mapping */
const CHANNEL_TO_PROVIDER: Record<string, CLIProxyProvider> = {
antigravity: 'agy',
claude: 'claude',
gemini: 'gemini',
codex: 'codex',
qwen: 'qwen',
iflow: 'iflow',
};
/** CCS provider → channel name mapping (reverse) */
export const PROVIDER_TO_CHANNEL: Record<string, string> = Object.fromEntries(
Object.entries(CHANNEL_TO_PROVIDER).map(([k, v]) => [v, k])
);
/** Providers to sync from CLIProxyAPI */
export const SYNCABLE_PROVIDERS: CLIProxyProvider[] = ['agy', 'gemini', 'codex', 'claude'];
function getCacheFilePath(): string {
return path.join(getCcsDir(), CACHE_FILE_NAME);
}
/** Read cached catalog data, null if expired or missing */
export function getCachedCatalog(): CatalogCacheData | null {
try {
const filePath = getCacheFilePath();
if (!fs.existsSync(filePath)) return null;
const data = JSON.parse(fs.readFileSync(filePath, 'utf8')) as CatalogCacheData;
if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null;
return data;
} catch {
return null;
}
}
/** Save catalog data to cache */
export function setCachedCatalog(providers: Record<string, RemoteModelInfo[]>): void {
try {
const filePath = getCacheFilePath();
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ providers, fetchedAt: Date.now() }));
} catch {
// Ignore cache write errors
}
}
/** Delete cache file */
export function clearCatalogCache(): boolean {
try {
const filePath = getCacheFilePath();
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
return true;
}
return false;
} catch {
return false;
}
}
/** Get cache age in human-readable format, or null if no cache */
export function getCacheAge(): string | null {
try {
const filePath = getCacheFilePath();
if (!fs.existsSync(filePath)) return null;
const data = JSON.parse(fs.readFileSync(filePath, 'utf8')) as CatalogCacheData;
const ageMs = Date.now() - data.fetchedAt;
const hours = Math.floor(ageMs / (60 * 60 * 1000));
const minutes = Math.floor((ageMs % (60 * 60 * 1000)) / (60 * 1000));
if (hours > 0) return `${hours}h ${minutes}m ago`;
return `${minutes}m ago`;
} catch {
return null;
}
}
/** Map remote thinking support to CCS ThinkingSupport */
function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefined {
if (!remote) return undefined;
// If levels are provided, it's a levels-type thinking
if (remote.levels && remote.levels.length > 0) {
return {
type: 'levels',
levels: remote.levels,
dynamicAllowed: remote.dynamic_allowed,
};
}
// If min/max budget are provided, it's budget-type
if (remote.min !== undefined || remote.max !== undefined) {
return {
type: 'budget',
min: remote.min,
max: remote.max,
zeroAllowed: remote.zero_allowed,
dynamicAllowed: remote.dynamic_allowed,
};
}
return { type: 'none' };
}
/** Map RemoteModelInfo to ModelEntry */
function mapRemoteToModelEntry(remote: RemoteModelInfo): ModelEntry {
const entry: ModelEntry = {
id: remote.id,
name: remote.display_name || remote.id,
};
if (remote.description) entry.description = remote.description;
if (remote.context_length && remote.context_length >= 1_000_000) {
entry.extendedContext = true;
}
const thinking = mapThinking(remote.thinking);
if (thinking) entry.thinking = thinking;
return entry;
}
/**
* Merge remote models with static catalog for a provider.
* Remote fields override static where present.
* Static-only fields preserved: broken, deprecated, deprecationReason, issueUrl, tier.
* Models in static but not in remote kept.
* Models in remote but not in static added.
*/
export function mergeCatalog(
provider: CLIProxyProvider,
remoteModels: RemoteModelInfo[]
): ProviderCatalog | undefined {
const staticCatalog = MODEL_CATALOG[provider];
if (!staticCatalog && remoteModels.length === 0) return undefined;
const displayName = staticCatalog?.displayName || provider;
const defaultModel = staticCatalog?.defaultModel || (remoteModels[0]?.id ?? '');
// Build map of static models by lowercase ID for fast lookup
const staticMap = new Map<string, ModelEntry>();
if (staticCatalog) {
for (const model of staticCatalog.models) {
staticMap.set(model.id.toLowerCase(), model);
}
}
// Process remote models: merge with static entries
const mergedIds = new Set<string>();
const mergedModels: ModelEntry[] = [];
for (const remote of remoteModels) {
const remoteEntry = mapRemoteToModelEntry(remote);
const staticEntry = staticMap.get(remote.id.toLowerCase());
mergedIds.add(remote.id.toLowerCase());
if (staticEntry) {
// Merge: remote overrides, static fills gaps
mergedModels.push({
...remoteEntry,
// Preserve static-only fields
tier: staticEntry.tier,
broken: staticEntry.broken,
issueUrl: staticEntry.issueUrl,
deprecated: staticEntry.deprecated,
deprecationReason: staticEntry.deprecationReason,
});
} else {
mergedModels.push(remoteEntry);
}
}
// Add static-only models not in remote
if (staticCatalog) {
for (const model of staticCatalog.models) {
if (!mergedIds.has(model.id.toLowerCase())) {
mergedModels.push(model);
}
}
}
return {
provider,
displayName,
defaultModel,
models: mergedModels,
};
}
/**
* Get resolved catalog for a provider.
* Uses cached remote data if available, falls back to static.
*/
export function getResolvedCatalog(provider: CLIProxyProvider): ProviderCatalog | undefined {
const cached = getCachedCatalog();
if (cached && cached.providers[provider]) {
return mergeCatalog(provider, cached.providers[provider]);
}
return MODEL_CATALOG[provider];
}
/**
* Get all resolved catalogs (for Dashboard).
*/
export function getAllResolvedCatalogs(): Partial<Record<CLIProxyProvider, ProviderCatalog>> {
const result: Partial<Record<CLIProxyProvider, ProviderCatalog>> = {};
const cached = getCachedCatalog();
// Get all known providers from both static and cache
const providers = new Set<CLIProxyProvider>();
for (const p of Object.keys(MODEL_CATALOG) as CLIProxyProvider[]) providers.add(p);
if (cached) {
for (const p of Object.keys(cached.providers) as CLIProxyProvider[]) providers.add(p);
}
for (const provider of providers) {
const catalog = getResolvedCatalog(provider);
if (catalog) result[provider] = catalog;
}
return result;
}
+13
View File
@@ -176,9 +176,22 @@ export type {
ManagementApiErrorCode, ManagementApiErrorCode,
ClaudeKeyPatch, ClaudeKeyPatch,
SyncStatus, SyncStatus,
RemoteModelInfo,
RemoteThinkingSupport,
} from './management-api-types'; } from './management-api-types';
export { ManagementApiClient, createManagementClient } from './management-api-client'; export { ManagementApiClient, createManagementClient } from './management-api-client';
// Catalog cache (model catalog sync with CLIProxyAPI)
export {
getResolvedCatalog,
getAllResolvedCatalogs,
getCacheAge,
clearCatalogCache,
setCachedCatalog,
SYNCABLE_PROVIDERS,
PROVIDER_TO_CHANNEL,
} from './catalog-cache';
// Sync module (profile sync to remote CLIProxy) // Sync module (profile sync to remote CLIProxy)
export type { SyncableProfile, SyncPreviewItem } from './sync'; export type { SyncableProfile, SyncPreviewItem } from './sync';
export { export {
+15
View File
@@ -13,6 +13,8 @@ import type {
ClaudeKey, ClaudeKey,
GetClaudeKeysResponse, GetClaudeKeysResponse,
ClaudeKeyPatch, ClaudeKeyPatch,
RemoteModelInfo,
GetModelDefinitionsResponse,
} from './management-api-types'; } from './management-api-types';
/** Default timeout for management operations (longer than health check) */ /** Default timeout for management operations (longer than health check) */
@@ -200,6 +202,19 @@ export class ManagementApiClient {
await this.request('DELETE', `/v0/management/claude-api-key?api-key=${encodedKey}`); await this.request('DELETE', `/v0/management/claude-api-key?api-key=${encodedKey}`);
} }
/**
* Get model definitions for a channel from CLIProxyAPI.
* GET /v0/management/model-definitions/:channel
*/
async getModelDefinitions(channel: string): Promise<RemoteModelInfo[]> {
const encodedChannel = encodeURIComponent(channel);
const response = await this.request<GetModelDefinitionsResponse>(
'GET',
`/v0/management/model-definitions/${encodedChannel}`
);
return response.data?.models ?? [];
}
/** /**
* Make an HTTP request to the Management API. * Make an HTTP request to the Management API.
*/ */
+33
View File
@@ -121,3 +121,36 @@ export interface SyncStatus {
/** Remote CLIProxy URL */ /** Remote CLIProxy URL */
remoteUrl?: string; remoteUrl?: string;
} }
/**
* Remote model thinking support from CLIProxyAPI model-definitions endpoint
*/
export interface RemoteThinkingSupport {
min?: number;
max?: number;
zero_allowed?: boolean;
dynamic_allowed?: boolean;
levels?: string[];
}
/**
* Remote model info from CLIProxyAPI model-definitions endpoint
*/
export interface RemoteModelInfo {
id: string;
display_name?: string;
description?: string;
context_length?: number;
max_completion_tokens?: number;
thinking?: RemoteThinkingSupport;
owned_by?: string;
type?: string;
}
/**
* Response from GET /v0/management/model-definitions/:channel
*/
export interface GetModelDefinitionsResponse {
channel: string;
models: RemoteModelInfo[];
}
+142
View File
@@ -0,0 +1,142 @@
import { initUI, header, subheader, color, dim } from '../../utils/ui';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { createManagementClient } from '../../cliproxy/management-api-client';
import {
getCacheAge,
setCachedCatalog,
clearCatalogCache,
SYNCABLE_PROVIDERS,
PROVIDER_TO_CHANNEL,
getResolvedCatalog,
} from '../../cliproxy/catalog-cache';
import type { CLIProxyProvider } from '../../cliproxy/types';
import type { RemoteModelInfo } from '../../cliproxy/management-api-types';
/** Fetch model definitions from CLIProxyAPI for all syncable providers */
async function fetchRemoteCatalogs(
verbose: boolean
): Promise<Record<string, RemoteModelInfo[]> | null> {
const config = loadOrCreateUnifiedConfig();
const remote = config.cliproxy_server?.remote;
if (!remote?.host) {
if (verbose) console.log(dim(' No remote CLIProxy configured'));
return null;
}
const client = createManagementClient(remote);
// Check health first
const health = await client.health();
if (!health.healthy) {
console.log(color(` [!] CLIProxy unreachable: ${health.error || 'unknown error'}`, 'warning'));
return null;
}
if (verbose) {
console.log(dim(` Connected to ${client.getBaseUrl()}`));
if (health.version) console.log(dim(` CLIProxy version: ${health.version}`));
}
const result: Record<string, RemoteModelInfo[]> = {};
for (const provider of SYNCABLE_PROVIDERS) {
const channel = PROVIDER_TO_CHANNEL[provider];
if (!channel) continue;
try {
const response = await client.getModelDefinitions(channel);
if (response && response.length > 0) {
result[provider] = response;
if (verbose) console.log(dim(` ${provider}: ${response.length} models`));
}
} catch {
if (verbose) console.log(dim(` ${provider}: fetch failed (skipped)`));
}
}
return Object.keys(result).length > 0 ? result : null;
}
/** Show catalog status */
export async function handleCatalogStatus(verbose: boolean): Promise<void> {
await initUI();
console.log('');
console.log(header('Model Catalog'));
console.log('');
const cacheAge = getCacheAge();
if (cacheAge) {
console.log(` Cache: ${color('synced', 'success')} (${cacheAge})`);
} else {
console.log(` Cache: ${color('static only', 'warning')} (no sync)`);
}
console.log('');
console.log(subheader('Providers:'));
for (const provider of SYNCABLE_PROVIDERS) {
const catalog = getResolvedCatalog(provider);
if (catalog) {
const count = catalog.models.length;
console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models`);
if (verbose) {
for (const model of catalog.models) {
console.log(dim(` - ${model.id} (${model.name})`));
}
}
}
}
console.log('');
if (!cacheAge) {
console.log(dim(' Run "ccs cliproxy catalog refresh" to sync from CLIProxy'));
}
console.log('');
}
/** Refresh catalog from CLIProxyAPI */
export async function handleCatalogRefresh(verbose: boolean): Promise<void> {
await initUI();
console.log('');
console.log(header('Catalog Refresh'));
console.log('');
const result = await fetchRemoteCatalogs(verbose);
if (!result) {
console.log(' Failed to fetch remote catalogs. Static catalog unchanged.');
console.log('');
return;
}
setCachedCatalog(result);
// Show summary
let totalModels = 0;
for (const [provider, models] of Object.entries(result)) {
const merged = getResolvedCatalog(provider as CLIProxyProvider);
const mergedCount = merged?.models.length ?? 0;
console.log(
` ${color(provider.padEnd(12), 'command')} ${models.length} remote -> ${mergedCount} merged`
);
totalModels += mergedCount;
}
console.log('');
console.log(` ${color('[OK]', 'success')} Catalog synced (${totalModels} total models)`);
console.log('');
}
/** Reset catalog cache */
export async function handleCatalogReset(): Promise<void> {
await initUI();
console.log('');
const cleared = clearCatalogCache();
if (cleared) {
console.log(` ${color('[OK]', 'success')} Catalog cache cleared. Using static catalog.`);
} else {
console.log(' No cache to clear.');
}
console.log('');
}
+8
View File
@@ -30,6 +30,14 @@ export async function showHelp(): Promise<void> {
['remove <name>', 'Remove a CLIProxy variant profile'], ['remove <name>', 'Remove a CLIProxy variant profile'],
], ],
], ],
[
'Catalog Commands:',
[
['catalog', 'Show catalog status (cached vs static)'],
['catalog refresh', 'Sync models from remote CLIProxy'],
['catalog reset', 'Clear cache, revert to static catalog'],
],
],
[ [
'Local Sync:', 'Local Sync:',
[ [
+20
View File
@@ -23,6 +23,11 @@ import { handleCreate, handleRemove } from './variant-subcommand';
import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand'; import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand';
import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand'; import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand';
import { showHelp } from './help-subcommand'; import { showHelp } from './help-subcommand';
import {
handleCatalogStatus,
handleCatalogRefresh,
handleCatalogReset,
} from './catalog-subcommand';
/** /**
* Parse --backend flag from args * Parse --backend flag from args
@@ -166,6 +171,21 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return; return;
} }
// Catalog commands
if (command === 'catalog') {
const subcommand = remainingArgs[1];
if (subcommand === 'refresh') {
await handleCatalogRefresh(verbose);
return;
}
if (subcommand === 'reset') {
await handleCatalogReset();
return;
}
await handleCatalogStatus(verbose);
return;
}
// Sync command // Sync command
if (command === 'sync') { if (command === 'sync') {
await handleSync(remainingArgs.slice(1)); await handleSync(remainingArgs.slice(1));
+26
View File
@@ -0,0 +1,26 @@
import { Router, Request, Response } from 'express';
import { getAllResolvedCatalogs, getCacheAge } from '../../cliproxy/catalog-cache';
const router = Router();
/**
* GET /api/cliproxy/catalog - Get merged model catalogs
* Returns resolved catalogs (cached + static merged)
*/
router.get('/', (_req: Request, res: Response): void => {
try {
const catalogs = getAllResolvedCatalogs();
const cacheAge = getCacheAge();
res.json({
catalogs,
cache: {
synced: cacheAge !== null,
age: cacheAge,
},
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
+2
View File
@@ -24,6 +24,7 @@ import miscRoutes from './misc-routes';
import cliproxyServerRoutes from './proxy-routes'; import cliproxyServerRoutes from './proxy-routes';
import authRoutes from './auth-routes'; import authRoutes from './auth-routes';
import persistRoutes from './persist-routes'; import persistRoutes from './persist-routes';
import catalogRoutes from './catalog-routes';
// Create the main API router // Create the main API router
export const apiRoutes = Router(); export const apiRoutes = Router();
@@ -53,6 +54,7 @@ apiRoutes.use('/cliproxy', variantRoutes);
apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes); apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes);
apiRoutes.use('/cliproxy', cliproxyStatsRoutes); apiRoutes.use('/cliproxy', cliproxyStatsRoutes);
apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes); apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes);
apiRoutes.use('/cliproxy/catalog', catalogRoutes);
apiRoutes.use('/cliproxy/openai-compat', providerRoutes); apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
// ==================== WebSearch ==================== // ==================== WebSearch ====================