From 7bbf32ae9a40b7a38ee7dc09e47feb94527c5ec0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 17:22:27 -0400 Subject: [PATCH] feat(cliproxy): source model pickers from upstream catalogs - fetch live provider model definitions through /api/cliproxy/catalog - overlay CCS preset metadata without keeping UI dropdowns as the source of truth - wire /cliproxy and quick setup to the upstream-backed catalog path --- src/cliproxy/catalog-cache.ts | 175 +++++++++++++++--- src/commands/cliproxy/catalog-subcommand.ts | 57 ++---- src/web-server/routes/catalog-routes.ts | 16 +- .../cliproxy/model-catalog-compat.test.ts | 24 +++ .../cliproxy/provider-editor/index.tsx | 2 +- .../provider-editor/model-config-section.tsx | 2 +- .../provider-editor/use-provider-editor.ts | 10 +- ui/src/components/setup/wizard/index.tsx | 5 + .../setup/wizard/steps/variant-step.tsx | 8 +- ui/src/components/setup/wizard/types.ts | 3 +- ui/src/hooks/use-cliproxy.ts | 9 + ui/src/lib/api-client.ts | 37 ++++ ui/src/lib/model-catalogs.ts | 96 +++++++++- ui/src/lib/preset-utils.ts | 19 +- ui/src/pages/cliproxy.tsx | 10 +- ui/tests/unit/ui/lib/preset-utils.test.ts | 44 ++++- 16 files changed, 425 insertions(+), 92 deletions(-) diff --git a/src/cliproxy/catalog-cache.ts b/src/cliproxy/catalog-cache.ts index 13038f2c..10436887 100644 --- a/src/cliproxy/catalog-cache.ts +++ b/src/cliproxy/catalog-cache.ts @@ -4,11 +4,17 @@ 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'; +import type { + GetModelDefinitionsResponse, + RemoteModelInfo, + RemoteThinkingSupport, +} from './management-api-types'; import { getDeniedModelIdReasonForProvider } from './model-id-normalizer'; +import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver'; const CACHE_FILE_NAME = 'model-catalog-cache.json'; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const LIVE_FETCH_TIMEOUT_MS = 3000; /** Cache structure stored on disk */ interface CatalogCacheData { @@ -25,6 +31,8 @@ const CHANNEL_TO_PROVIDER: Record = { qwen: 'qwen', iflow: 'iflow', kimi: 'kimi', + kiro: 'kiro', + 'github-copilot': 'ghcp', }; /** CCS provider → channel name mapping (reverse) */ @@ -33,7 +41,17 @@ export const PROVIDER_TO_CHANNEL: Record = Object.fromEntries( ); /** Providers to sync from CLIProxyAPI */ -export const SYNCABLE_PROVIDERS: CLIProxyProvider[] = ['agy', 'gemini', 'codex', 'claude', 'kimi']; +export const SYNCABLE_PROVIDERS: CLIProxyProvider[] = [ + ...new Set(Object.values(CHANNEL_TO_PROVIDER)), +] as CLIProxyProvider[]; + +export type CatalogSource = 'live' | 'cache' | 'static'; + +export interface ResolvedCatalogSnapshot { + catalogs: Partial>; + source: CatalogSource; + cacheAge: string | null; +} function getCacheFilePath(): string { return path.join(getCcsDir(), CACHE_FILE_NAME); @@ -94,6 +112,77 @@ export function getCacheAge(): string | null { } } +async function fetchProviderCatalog( + provider: CLIProxyProvider +): Promise<[CLIProxyProvider, RemoteModelInfo[] | null]> { + const channel = PROVIDER_TO_CHANNEL[provider]; + if (!channel) { + return [provider, null]; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), LIVE_FETCH_TIMEOUT_MS); + + try { + const target = getProxyTarget(); + const response = await fetch( + buildProxyUrl(target, `/v0/management/model-definitions/${channel}`), + { + signal: controller.signal, + headers: buildManagementHeaders(target), + } + ); + + if (!response.ok) { + return [provider, null]; + } + + const data = (await response.json()) as GetModelDefinitionsResponse; + return [provider, Array.isArray(data.models) ? data.models : null]; + } catch { + return [provider, null]; + } finally { + clearTimeout(timeoutId); + } +} + +async function isProxyCatalogReachable(): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1000); + + try { + const target = getProxyTarget(); + const response = await fetch(buildProxyUrl(target, '/'), { + signal: controller.signal, + }); + return response.ok; + } catch { + return false; + } finally { + clearTimeout(timeoutId); + } +} + +export async function refreshCatalogFromProxy(): Promise | null> { + if (!(await isProxyCatalogReachable())) { + return null; + } + + const settled = await Promise.all( + SYNCABLE_PROVIDERS.map((provider) => fetchProviderCatalog(provider)) + ); + const providers = Object.fromEntries( + settled.filter(([, models]) => Array.isArray(models) && models.length > 0) + ) as Record; + + if (Object.keys(providers).length === 0) { + return null; + } + + setCachedCatalog(providers); + return providers; +} + /** Map remote thinking support to CCS ThinkingSupport */ function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefined { if (!remote) return undefined; @@ -137,8 +226,8 @@ function mapRemoteToModelEntry(remote: RemoteModelInfo): ModelEntry { * 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. + * Models removed upstream stay hidden; UI falls back to static only when live data is unavailable. */ export function mergeCatalog( provider: CLIProxyProvider, @@ -190,18 +279,6 @@ export function mergeCatalog( } } - // Add static-only models not in remote - if (staticCatalog) { - for (const model of staticCatalog.models) { - if (getDeniedModelIdReasonForProvider(model.id, provider)) { - continue; - } - if (!mergedIds.has(model.id.toLowerCase())) { - mergedModels.push(model); - } - } - } - return { provider, displayName, @@ -210,6 +287,42 @@ export function mergeCatalog( }; } +function getResolvedCatalogFromProviders( + provider: CLIProxyProvider, + providers?: Record +): ProviderCatalog | undefined { + if (providers?.[provider]) { + return mergeCatalog(provider, providers[provider]); + } + return MODEL_CATALOG[provider]; +} + +function getAllResolvedCatalogsFromProviders( + providers?: Record +): Partial> { + const result: Partial> = {}; + const providerIds = new Set(); + + for (const provider of Object.keys(MODEL_CATALOG) as CLIProxyProvider[]) { + providerIds.add(provider); + } + + if (providers) { + for (const provider of Object.keys(providers) as CLIProxyProvider[]) { + providerIds.add(provider); + } + } + + for (const provider of providerIds) { + const catalog = getResolvedCatalogFromProviders(provider, providers); + if (catalog) { + result[provider] = catalog; + } + } + + return result; +} + /** * Get resolved catalog for a provider. * Uses cached remote data if available, falls back to static. @@ -226,20 +339,32 @@ export function getResolvedCatalog(provider: CLIProxyProvider): ProviderCatalog * Get all resolved catalogs (for Dashboard). */ export function getAllResolvedCatalogs(): Partial> { - const result: Partial> = {}; const cached = getCachedCatalog(); + return getAllResolvedCatalogsFromProviders(cached?.providers); +} - // 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); +export async function getResolvedCatalogSnapshot(): Promise { + const liveProviders = await refreshCatalogFromProxy(); + if (liveProviders) { + return { + catalogs: getAllResolvedCatalogsFromProviders(liveProviders), + source: 'live', + cacheAge: getCacheAge(), + }; } - for (const provider of providers) { - const catalog = getResolvedCatalog(provider); - if (catalog) result[provider] = catalog; + const cached = getCachedCatalog(); + if (cached?.providers) { + return { + catalogs: getAllResolvedCatalogsFromProviders(cached.providers), + source: 'cache', + cacheAge: getCacheAge(), + }; } - return result; + return { + catalogs: getAllResolvedCatalogsFromProviders(), + source: 'static', + cacheAge: null, + }; } diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 8ff99159..8bee3556 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -1,14 +1,12 @@ 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, + refreshCatalogFromProxy, } from '../../cliproxy/catalog-cache'; +import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; import type { CLIProxyProvider } from '../../cliproxy/types'; import type { RemoteModelInfo } from '../../cliproxy/management-api-types'; @@ -16,46 +14,27 @@ import type { RemoteModelInfo } from '../../cliproxy/management-api-types'; 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; - } + const target = getProxyTarget(); if (verbose) { - console.log(dim(` Connected to ${client.getBaseUrl()}`)); - if (health.version) console.log(dim(` CLIProxy version: ${health.version}`)); + console.log( + dim( + ` Connected to ${target.protocol}://${target.host}:${target.port} (${target.isRemote ? 'remote' : 'local'})` + ) + ); } - 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`)); + const result = await refreshCatalogFromProxy(); + if (verbose && result) { + for (const provider of SYNCABLE_PROVIDERS) { + const models = result[provider]; + if (models?.length) { + console.log(dim(` ${provider}: ${models.length} models`)); } - } catch { - if (verbose) console.log(dim(` ${provider}: fetch failed (skipped)`)); } } - return Object.keys(result).length > 0 ? result : null; + return result; } /** Show catalog status */ @@ -104,20 +83,18 @@ export async function handleCatalogRefresh(verbose: boolean): Promise { const result = await fetchRemoteCatalogs(verbose); if (!result) { - console.log(' Failed to fetch remote catalogs. Static catalog unchanged.'); + console.log(' Failed to fetch live 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` + ` ${color(provider.padEnd(12), 'command')} ${models.length} live -> ${mergedCount} merged` ); totalModels += mergedCount; } diff --git a/src/web-server/routes/catalog-routes.ts b/src/web-server/routes/catalog-routes.ts index 4ff2d9af..c2cfba08 100644 --- a/src/web-server/routes/catalog-routes.ts +++ b/src/web-server/routes/catalog-routes.ts @@ -1,21 +1,21 @@ import { Router, Request, Response } from 'express'; -import { getAllResolvedCatalogs, getCacheAge } from '../../cliproxy/catalog-cache'; +import { getResolvedCatalogSnapshot } from '../../cliproxy/catalog-cache'; const router = Router(); /** * GET /api/cliproxy/catalog - Get merged model catalogs - * Returns resolved catalogs (cached + static merged) + * Returns resolved catalogs with live -> cache -> static fallback ordering. */ -router.get('/', (_req: Request, res: Response): void => { +router.get('/', async (_req: Request, res: Response): Promise => { try { - const catalogs = getAllResolvedCatalogs(); - const cacheAge = getCacheAge(); + const snapshot = await getResolvedCatalogSnapshot(); res.json({ - catalogs, + catalogs: snapshot.catalogs, + source: snapshot.source, cache: { - synced: cacheAge !== null, - age: cacheAge, + synced: snapshot.source !== 'static' || snapshot.cacheAge !== null, + age: snapshot.cacheAge, }, }); } catch (error) { diff --git a/tests/unit/cliproxy/model-catalog-compat.test.ts b/tests/unit/cliproxy/model-catalog-compat.test.ts index af51012f..b6fc21cc 100644 --- a/tests/unit/cliproxy/model-catalog-compat.test.ts +++ b/tests/unit/cliproxy/model-catalog-compat.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'bun:test'; import { findModel, supportsThinking } from '../../../src/cliproxy/model-catalog'; +import { + PROVIDER_TO_CHANNEL, + SYNCABLE_PROVIDERS, + mergeCatalog, +} from '../../../src/cliproxy/catalog-cache'; describe('model-catalog compatibility lookups', () => { it('finds agy Claude models using dotted major.minor IDs', () => { @@ -34,4 +39,23 @@ describe('model-catalog compatibility lookups', () => { expect(dottedLegacy?.id).toBe('claude-sonnet-4-6'); expect(hyphenLegacy?.id).toBe('claude-sonnet-4-6'); }); + + it('maps all dashboard providers to upstream catalog channels', () => { + expect(SYNCABLE_PROVIDERS).toContain('qwen'); + expect(SYNCABLE_PROVIDERS).toContain('iflow'); + expect(SYNCABLE_PROVIDERS).toContain('kiro'); + expect(SYNCABLE_PROVIDERS).toContain('ghcp'); + expect(PROVIDER_TO_CHANNEL.ghcp).toBe('github-copilot'); + }); + + it('does not re-add stale static-only models when live catalog data is present', () => { + const catalog = mergeCatalog('gemini', [ + { + id: 'gemini-2.5-pro', + display_name: 'Gemini 2.5 Pro', + }, + ]); + + expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']); + }); }); diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index e602662d..bf0bac22 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -122,7 +122,7 @@ export function ProviderEditor({ conflictDialog, handleConflictResolve, missingRequiredFields, - } = useProviderEditor(provider); + } = useProviderEditor(provider, catalog); // Defensive normalization: remote/legacy payloads may omit account.provider. // Fallback to current editor provider to avoid runtime crashes in account UI. diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index 7b010bbd..0f9deffb 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -58,7 +58,7 @@ export function ModelConfigSection({ const uniqueIds = [...new Set(selectedModels)]; return uniqueIds - .map((modelId) => findCatalogModel(catalog.provider, modelId)) + .map((modelId) => findCatalogModel(catalog.provider, modelId, catalog)) .filter((model): model is NonNullable => Boolean(model?.extendedContext)); }, [catalog, currentModel, opusModel, sonnetModel, haikuModel]); diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index b2073d18..1166dfbd 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -7,6 +7,7 @@ import { useState, useMemo, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import type { SettingsResponse, UseProviderEditorReturn } from './types'; +import type { ProviderCatalog } from '../provider-model-selector'; import { applyExtendedContextPreferenceToAnthropicModels, hasAnthropicExtendedContextEnabled, @@ -23,7 +24,10 @@ function checkMissingFields(settings: { env?: Record }): string[ return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim()); } -export function useProviderEditor(provider: string): UseProviderEditorReturn { +export function useProviderEditor( + provider: string, + catalog?: ProviderCatalog +): UseProviderEditorReturn { const [rawJsonEdits, setRawJsonEdits] = useState(null); const [conflictDialog, setConflictDialog] = useState(false); const queryClient = useQueryClient(); @@ -82,9 +86,9 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn { const applySavedLongContextIntent = useCallback( (env: Record, enabled: boolean) => applyExtendedContextPreferenceToAnthropicModels(env, enabled, { - supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId), + supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId, catalog), }), - [provider] + [catalog, provider] ); // Update a single setting value diff --git a/ui/src/components/setup/wizard/index.tsx b/ui/src/components/setup/wizard/index.tsx index 16576155..98fdfdc6 100644 --- a/ui/src/components/setup/wizard/index.tsx +++ b/ui/src/components/setup/wizard/index.tsx @@ -17,6 +17,7 @@ import { import { Sparkles } from 'lucide-react'; import { useCliproxyAuth, + useCliproxyCatalog, useCreateVariant, useStartAuth, useCancelAuth, @@ -24,6 +25,7 @@ import { import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import type { CLIProxyProvider } from '@/lib/provider-config'; import { applyDefaultPreset } from '@/lib/preset-utils'; +import { buildUiCatalogs } from '@/lib/model-catalogs'; import i18n from '@/lib/i18n'; import { usePrivacy } from '@/contexts/privacy-context'; import { toast } from 'sonner'; @@ -47,6 +49,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { const [isAddingNewAccount, setIsAddingNewAccount] = useState(false); const { data: authData, refetch } = useCliproxyAuth(); + const { data: catalogData } = useCliproxyCatalog(); const createMutation = useCreateVariant(); const startAuthMutation = useStartAuth(); const cancelAuthMutation = useCancelAuth(); @@ -57,6 +60,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { (s: AuthStatus) => s.provider === selectedProvider ); const accounts = useMemo(() => providerAuth?.accounts || [], [providerAuth?.accounts]); + const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); // Reset on close useEffect(() => { @@ -228,6 +232,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { {step === 'variant' && ( m.id === modelName); const [showCustomInput, setShowCustomInput] = useState(isCustomModel); const deniedCustomModel = @@ -171,9 +173,7 @@ export function VariantStep({ {showCustomInput ? t('setupVariant.enterAnyModel') : t('setupVariant.defaultModel', { - model: - MODEL_CATALOGS[selectedProvider]?.defaultModel || - t('setupVariant.providerDefault'), + model: resolvedCatalog?.defaultModel || t('setupVariant.providerDefault'), })} diff --git a/ui/src/components/setup/wizard/types.ts b/ui/src/components/setup/wizard/types.ts index cccbf332..7df586e1 100644 --- a/ui/src/components/setup/wizard/types.ts +++ b/ui/src/components/setup/wizard/types.ts @@ -2,7 +2,7 @@ * Types for Quick Setup Wizard */ -import type { OAuthAccount } from '@/lib/api-client'; +import type { CliproxyProviderCatalog, OAuthAccount } from '@/lib/api-client'; export type WizardStep = 'provider' | 'auth' | 'account' | 'variant' | 'success'; @@ -43,6 +43,7 @@ export interface AccountStepProps { export interface VariantStepProps { selectedProvider: string; + catalog?: CliproxyProviderCatalog; selectedAccount: OAuthAccount | null; variantName: string; modelName: string; diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index b6ee9e65..d88e2b82 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -28,6 +28,15 @@ export function useCliproxyAuth() { }); } +export function useCliproxyCatalog() { + return useQuery({ + queryKey: ['cliproxy-catalog'], + queryFn: () => api.cliproxy.catalog(), + staleTime: 30000, + retry: 1, + }); +} + export function useCliproxyRoutingStrategy() { return useQuery({ queryKey: ['cliproxy-routing'], diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 12c483de..54c491c0 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -448,6 +448,42 @@ export interface CliproxyModelsResponse { totalCount: number; } +export interface CliproxyCatalogPresetMapping { + default: string; + opus: string; + sonnet: string; + haiku: string; +} + +export interface CliproxyCatalogModel { + id: string; + name: string; + tier?: 'free' | 'paid'; + description?: string; + broken?: boolean; + issueUrl?: string; + deprecated?: boolean; + deprecationReason?: string; + extendedContext?: boolean; + presetMapping?: CliproxyCatalogPresetMapping; +} + +export interface CliproxyProviderCatalog { + provider: string; + displayName: string; + models: CliproxyCatalogModel[]; + defaultModel: string; +} + +export interface CliproxyCatalogResponse { + catalogs: Partial>; + source: 'live' | 'cache' | 'static'; + cache: { + synced: boolean; + age: string | null; + }; +} + /** Individual model quota info from Google Cloud Code API */ export interface ModelQuota { /** Model name, e.g., "gemini-3-pro-high" */ @@ -1070,6 +1106,7 @@ export const api = { // Stats and models for Overview tab stats: () => request<{ usage: Record }>('/cliproxy/usage'), + catalog: () => request('/cliproxy/catalog'), models: () => request('/cliproxy/models'), updateModel: (provider: string, model: string) => request(`/cliproxy/models/${provider}`, { diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index d1df019a..0401b405 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -680,8 +680,7 @@ export const MODEL_CATALOGS: Record = { }, }; -export function findCatalogModel(provider: string, modelId: string) { - const catalog = MODEL_CATALOGS[provider.toLowerCase()]; +function findCatalogModelInCatalog(catalog: ProviderCatalog | undefined, modelId: string) { if (!catalog) return undefined; const normalizedModelId = normalizeModelId(modelId); @@ -711,6 +710,91 @@ export function findCatalogModel(provider: string, modelId: string) { .sort((left, right) => compareGeminiVersions(right.info.version, left.info.version))[0]?.model; } +function normalizeCatalogTier(tier: unknown): ModelEntry['tier'] { + if (tier === 'free') return 'free'; + if (typeof tier === 'string' && tier.trim().length > 0) return 'paid'; + return undefined; +} + +export function buildUiCatalog( + provider: string, + liveCatalog: ProviderCatalog | undefined +): ProviderCatalog | undefined { + const staticCatalog = MODEL_CATALOGS[provider.toLowerCase()]; + if (!liveCatalog || liveCatalog.models.length === 0) { + return staticCatalog; + } + + const availableModels = liveCatalog.models.map((model) => ({ + id: model.id, + owned_by: liveCatalog.provider, + })); + + const models = liveCatalog.models.map((model) => { + const staticModel = findCatalogModelInCatalog(staticCatalog, model.id); + return { + ...model, + name: model.name || staticModel?.name || model.id, + tier: staticModel?.tier ?? normalizeCatalogTier(model.tier), + description: model.description ?? staticModel?.description, + broken: staticModel?.broken, + issueUrl: staticModel?.issueUrl, + deprecated: staticModel?.deprecated, + deprecationReason: staticModel?.deprecationReason, + extendedContext: model.extendedContext ?? staticModel?.extendedContext, + presetMapping: staticModel?.presetMapping, + }; + }); + + const fallbackDefaultModel = staticCatalog?.defaultModel + ? resolveCatalogModelId(staticCatalog.defaultModel, availableModels) + : undefined; + const hasFallbackDefaultModel = + typeof fallbackDefaultModel === 'string' && + availableModels.some( + (model) => normalizeModelId(model.id) === normalizeModelId(fallbackDefaultModel) + ); + + return { + provider: liveCatalog.provider, + displayName: liveCatalog.displayName || staticCatalog?.displayName || provider, + defaultModel: hasFallbackDefaultModel ? fallbackDefaultModel : liveCatalog.defaultModel, + models, + }; +} + +export function buildUiCatalogs( + liveCatalogs: Partial> | undefined +): Partial> { + const catalogs: Partial> = {}; + const providers = new Set([ + ...Object.keys(MODEL_CATALOGS), + ...Object.keys(liveCatalogs ?? {}), + ]); + + for (const provider of providers) { + const catalog = buildUiCatalog(provider, liveCatalogs?.[provider]); + if (catalog) { + catalogs[provider] = catalog; + } + } + + return catalogs; +} + +export function findCatalogModel( + provider: string, + modelId: string, + catalogOverride?: ProviderCatalog +) { + const overrideMatch = findCatalogModelInCatalog(catalogOverride, modelId); + if (overrideMatch) { + return overrideMatch; + } + + return findCatalogModelInCatalog(MODEL_CATALOGS[provider.toLowerCase()], modelId); +} + export function resolveCatalogModelId( modelId: string, availableModels: CatalogAvailableModel[] = [] @@ -773,6 +857,10 @@ export function getResolvedCatalogModels( }); } -export function supportsExtendedContext(provider: string, modelId: string): boolean { - return findCatalogModel(provider, modelId)?.extendedContext === true; +export function supportsExtendedContext( + provider: string, + modelId: string, + catalogOverride?: ProviderCatalog +): boolean { + return findCatalogModel(provider, modelId, catalogOverride)?.extendedContext === true; } diff --git a/ui/src/lib/preset-utils.ts b/ui/src/lib/preset-utils.ts index dec9ba6a..fb61145d 100644 --- a/ui/src/lib/preset-utils.ts +++ b/ui/src/lib/preset-utils.ts @@ -4,6 +4,7 @@ */ import { MODEL_CATALOGS } from './model-catalogs'; +import { buildUiCatalogs } from './model-catalogs'; import { CLIPROXY_DEFAULT_PORT } from './default-ports'; export { CLIPROXY_DEFAULT_PORT } from './default-ports'; @@ -25,6 +26,22 @@ async function fetchEffectiveApiKey(): Promise { } } +async function fetchProviderCatalog(provider: string) { + try { + const response = await fetch('/api/cliproxy/catalog'); + if (!response.ok) { + return MODEL_CATALOGS[provider]; + } + + const data = (await response.json()) as { + catalogs?: Partial>; + }; + return buildUiCatalogs(data.catalogs)[provider] ?? MODEL_CATALOGS[provider]; + } catch { + return MODEL_CATALOGS[provider]; + } +} + /** * Apply default preset for a provider to its settings * Uses the catalog default model's preset mapping or falls back to using defaultModel for all tiers @@ -37,7 +54,7 @@ export async function applyDefaultPreset( provider: string, port?: number ): Promise<{ success: boolean; presetName?: string }> { - const catalog = MODEL_CATALOGS[provider]; + const catalog = await fetchProviderCatalog(provider); if (!catalog) return { success: false }; const defaultModelEntry = diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 39bc6124..95cca80b 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -20,6 +20,7 @@ import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; import { useCliproxy, useCliproxyAuth, + useCliproxyCatalog, useCliproxyUpdateCheck, useSetDefaultAccount, useRemoveAccount, @@ -31,7 +32,7 @@ import { useDeleteVariant, } from '@/hooks/use-cliproxy'; import type { AuthStatus, Variant } from '@/lib/api-client'; -import { MODEL_CATALOGS } from '@/lib/model-catalogs'; +import { buildUiCatalogs } from '@/lib/model-catalogs'; import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; @@ -209,6 +210,7 @@ export function CliproxyPage() { const queryClient = useQueryClient(); const { data: authData, isLoading: authLoading } = useCliproxyAuth(); const { data: variantsData, isFetching } = useCliproxy(); + const { data: catalogData } = useCliproxyCatalog(); const { data: updateCheck } = useCliproxyUpdateCheck(); const setDefaultMutation = useSetDefaultAccount(); const removeMutation = useRemoveAccount(); @@ -261,6 +263,7 @@ export function CliproxyPage() { const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]); const isRemoteMode = authData?.source === 'remote'; const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); + const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); // Wrapper to persist provider selection to localStorage const setSelectedProvider = (provider: string | null) => { @@ -300,6 +303,7 @@ export function CliproxyPage() { const handleRefresh = () => { queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); }; const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => { @@ -453,7 +457,7 @@ export function CliproxyPage() { provider: selectedVariantData.provider, })} authStatus={parentAuthForVariant} - catalog={MODEL_CATALOGS[selectedVariantData.provider]} + catalog={catalogs[selectedVariantData.provider]} logoProvider={selectedVariantData.provider} baseProvider={selectedVariantData.provider} defaultTarget={selectedVariantData.target} @@ -506,7 +510,7 @@ export function CliproxyPage() { provider={selectedStatus.provider} displayName={selectedStatus.displayName} authStatus={selectedStatus} - catalog={MODEL_CATALOGS[selectedStatus.provider]} + catalog={catalogs[selectedStatus.provider]} isRemoteMode={isRemoteMode} topNotice={ showAccountSafetyWarning ? ( diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index e4d0c618..9fc4e890 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + buildUiCatalogs, MODEL_CATALOGS, findCatalogModel, getResolvedCatalogModels, @@ -24,6 +25,16 @@ describe('claude preset utils', () => { it('applies the default claude preset from the catalog default model mapping', async () => { const fetchMock = vi .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + catalogs: { + claude: MODEL_CATALOGS.claude, + }, + source: 'live', + cache: { synced: true, age: '0m ago' }, + }), + }) .mockResolvedValueOnce({ ok: true, json: async () => ({ apiKey: { value: 'managed-key' } }), @@ -36,7 +47,7 @@ describe('claude preset utils', () => { expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 4.6' }); - const [, requestInit] = fetchMock.mock.calls[1] ?? []; + const [, requestInit] = fetchMock.mock.calls[2] ?? []; const body = JSON.parse(String(requestInit?.body)); expect(body.settings.env).toMatchObject({ @@ -47,6 +58,37 @@ describe('claude preset utils', () => { }); }); + it('builds UI catalogs from upstream provider models without requiring static dropdown edits', () => { + const liveCatalogs = { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-3.9-pro-preview', + models: [ + { id: 'gemini-3.9-pro-preview', name: 'Gemini 3.9 Pro Preview' }, + { id: 'gemini-3-9-flash-preview', name: 'Gemini 3.9 Flash Preview' }, + ], + }, + }; + + const catalogs = buildUiCatalogs(liveCatalogs); + const resolvedGeminiModels = getResolvedCatalogModels(catalogs.gemini, [ + { id: 'gemini-3.9-pro-preview', owned_by: 'google' }, + { id: 'gemini-3-9-flash-preview', owned_by: 'google' }, + ]); + + expect(catalogs.gemini?.defaultModel).toBe('gemini-3.9-pro-preview'); + expect(resolvedGeminiModels.find((model) => model.id === 'gemini-3.9-pro-preview')).toEqual( + expect.objectContaining({ + name: 'Gemini 3.9 Pro Preview', + presetMapping: expect.objectContaining({ + default: 'gemini-3.9-pro-preview', + haiku: 'gemini-3-9-flash-preview', + }), + }) + ); + }); + it('keeps Gemini presets on 3.1 Pro while resolving 3/3.1 alias variants', () => { const geminiCatalog = MODEL_CATALOGS.gemini; const latestPro = geminiCatalog.models.find((model) => model.id === 'gemini-3.1-pro-preview');