diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c6de20c..3672a883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ### Bug Fixes diff --git a/src/cliproxy/catalog-cache.ts b/src/cliproxy/catalog-cache.ts new file mode 100644 index 00000000..3045bc6a --- /dev/null +++ b/src/cliproxy/catalog-cache.ts @@ -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; + fetchedAt: number; +} + +/** Channel name → CCS provider mapping */ +const CHANNEL_TO_PROVIDER: Record = { + antigravity: 'agy', + claude: 'claude', + gemini: 'gemini', + codex: 'codex', + qwen: 'qwen', + iflow: 'iflow', +}; + +/** CCS provider → channel name mapping (reverse) */ +export const PROVIDER_TO_CHANNEL: Record = 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): 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(); + 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(); + 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> { + const result: Partial> = {}; + const cached = getCachedCatalog(); + + // Get all known providers from both static and cache + const providers = new Set(); + 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; +} diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index a504a0f8..0975e3cb 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -176,9 +176,22 @@ export type { ManagementApiErrorCode, ClaudeKeyPatch, SyncStatus, + RemoteModelInfo, + RemoteThinkingSupport, } from './management-api-types'; 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) export type { SyncableProfile, SyncPreviewItem } from './sync'; export { diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts index 52e89a1b..26f9a6ae 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -13,6 +13,8 @@ import type { ClaudeKey, GetClaudeKeysResponse, ClaudeKeyPatch, + RemoteModelInfo, + GetModelDefinitionsResponse, } from './management-api-types'; /** 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}`); } + /** + * Get model definitions for a channel from CLIProxyAPI. + * GET /v0/management/model-definitions/:channel + */ + async getModelDefinitions(channel: string): Promise { + const encodedChannel = encodeURIComponent(channel); + const response = await this.request( + 'GET', + `/v0/management/model-definitions/${encodedChannel}` + ); + return response.data?.models ?? []; + } + /** * Make an HTTP request to the Management API. */ diff --git a/src/cliproxy/management-api-types.ts b/src/cliproxy/management-api-types.ts index f0a9092a..aa8423e5 100644 --- a/src/cliproxy/management-api-types.ts +++ b/src/cliproxy/management-api-types.ts @@ -121,3 +121,36 @@ export interface SyncStatus { /** Remote CLIProxy URL */ 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[]; +} diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts new file mode 100644 index 00000000..8ff99159 --- /dev/null +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -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 | 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 = {}; + + 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 { + 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 { + 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 { + 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(''); +} diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 74e0a597..1c970232 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -30,6 +30,14 @@ export async function showHelp(): Promise { ['remove ', '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:', [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 176901c3..dc87d32d 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -23,6 +23,11 @@ import { handleCreate, handleRemove } from './variant-subcommand'; import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand'; import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand'; import { showHelp } from './help-subcommand'; +import { + handleCatalogStatus, + handleCatalogRefresh, + handleCatalogReset, +} from './catalog-subcommand'; /** * Parse --backend flag from args @@ -166,6 +171,21 @@ export async function handleCliproxyCommand(args: string[]): Promise { 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 if (command === 'sync') { await handleSync(remainingArgs.slice(1)); diff --git a/src/web-server/routes/catalog-routes.ts b/src/web-server/routes/catalog-routes.ts new file mode 100644 index 00000000..4ff2d9af --- /dev/null +++ b/src/web-server/routes/catalog-routes.ts @@ -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; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 6152aafb..c488a9c9 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -24,6 +24,7 @@ import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; import persistRoutes from './persist-routes'; +import catalogRoutes from './catalog-routes'; // Create the main API router export const apiRoutes = Router(); @@ -53,6 +54,7 @@ apiRoutes.use('/cliproxy', variantRoutes); apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes); apiRoutes.use('/cliproxy', cliproxyStatsRoutes); apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes); +apiRoutes.use('/cliproxy/catalog', catalogRoutes); apiRoutes.use('/cliproxy/openai-compat', providerRoutes); // ==================== WebSearch ====================