From 7bbf32ae9a40b7a38ee7dc09e47feb94527c5ec0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 17:22:27 -0400 Subject: [PATCH 1/5] 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'); From 002b7feb1e129947150aa11dbd10d5b7206c543d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 17:22:57 -0400 Subject: [PATCH 2/5] docs(roadmap): record upstream cliproxy picker sync --- docs/project-roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 7a0a0e0f..8ef5aee2 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank. - **2026-04-08**: **#929** Image Analysis hardening now makes the managed `ccs-image-analysis` MCP path authoritative on healthy Claude-target launches, suppresses stale CCS-managed image `Read` hooks instead of letting them compete with MCP, keeps the legacy hook available only as compatibility fallback when MCP provisioning fails, and extends self-heal to dashboard provisioning plus `ccs doctor --fix` so stale hook files and missing isolated MCP sync are repaired automatically. - **2026-04-07**: CLIProxy routing strategy is now a first-class CCS surface. Users can inspect and explicitly change `round-robin` vs `fill-first` from `ccs cliproxy routing` and from a native `/cliproxy` dashboard card. Local mode now persists the chosen startup default into CCS-managed CLIProxy config generation, while untouched installs remain on `round-robin`. CCS deliberately does not infer strategy from account composition. - **2026-04-07**: **#926** CCS now has a first-class structured logging layer under `src/services/logging/`, a bounded top-level `logging` config section in `~/.ccs/config.yaml`, automatic rotation/retention for CCS-owned logs under `~/.ccs/logs/`, native `/api/logs` dashboard endpoints, request tracing for the dashboard backend, and a dedicated `System -> Logs` dashboard route for browsing recent entries and editing retention settings. Legacy CLIProxy error files remain available as a labeled legacy source instead of acting as the primary logging model. From 564ed3def716d837e648feec82c340c1442c2878 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 17:52:45 -0400 Subject: [PATCH 3/5] fix(ui): reuse cached cliproxy catalogs for presets - let applyDefaultPreset accept a caller-provided provider catalog - pass cached catalog data from cliproxy and quick-setup flows - add regression coverage for skipping the extra catalog fetch --- .../components/account/add-account-dialog.tsx | 7 +++-- ui/src/components/setup/wizard/index.tsx | 6 ++++- ui/src/lib/preset-utils.ts | 27 ++++++++++++------- ui/src/pages/cliproxy.tsx | 1 + ui/tests/unit/ui/lib/preset-utils.test.ts | 25 +++++++++++++++++ 5 files changed, 53 insertions(+), 13 deletions(-) diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 646a862a..294d5d76 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -29,6 +29,7 @@ import { Loader2, ExternalLink, User, Download, Copy, Check, ShieldAlert } from import { useKiroImport } from '@/hooks/use-cliproxy'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { applyDefaultPreset } from '@/lib/preset-utils'; +import type { CliproxyProviderCatalog } from '@/lib/api-client'; import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card'; import { AntigravityResponsibilityChecklist } from '@/components/account/antigravity-responsibility-checklist'; import { @@ -56,6 +57,7 @@ interface AddAccountDialogProps { onClose: () => void; provider: string; displayName: string; + catalog?: CliproxyProviderCatalog; /** Whether this is the first account being added (shows different toast message) */ isFirstAccount?: boolean; } @@ -74,6 +76,7 @@ export function AddAccountDialog({ onClose, provider, displayName, + catalog, isFirstAccount = false, }: AddAccountDialogProps) { const [nickname, setNickname] = useState(''); @@ -244,7 +247,7 @@ export function AddAccountDialog({ wasAuthenticatingRef.current = false; const applyPresetAndClose = async () => { try { - const result = await applyDefaultPreset(provider); + const result = await applyDefaultPreset(provider, undefined, catalog); if (result.success && result.presetName && isFirstAccount) { toast.success(`Applied "${result.presetName}" preset`); } @@ -331,7 +334,7 @@ export function AddAccountDialog({ wasAuthenticatingRef.current = true; kiroImportMutation.mutate(undefined, { onSuccess: async () => { - const result = await applyDefaultPreset('kiro'); + const result = await applyDefaultPreset('kiro', undefined, catalog); if (result.success && result.presetName && isFirstAccount) { toast.success(`Applied "${result.presetName}" preset`); } diff --git a/ui/src/components/setup/wizard/index.tsx b/ui/src/components/setup/wizard/index.tsx index 98fdfdc6..290b8298 100644 --- a/ui/src/components/setup/wizard/index.tsx +++ b/ui/src/components/setup/wizard/index.tsx @@ -101,7 +101,11 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { { onSuccess: async (data) => { if (isFirstAccount) { - const result = await applyDefaultPreset(selectedProvider); + const result = await applyDefaultPreset( + selectedProvider, + undefined, + catalogs[selectedProvider] + ); if (result.success && result.presetName) { toast.success(`Applied "${result.presetName}" preset`); } else if (!result.success) { diff --git a/ui/src/lib/preset-utils.ts b/ui/src/lib/preset-utils.ts index fb61145d..04adc14c 100644 --- a/ui/src/lib/preset-utils.ts +++ b/ui/src/lib/preset-utils.ts @@ -3,6 +3,7 @@ * Shared functions for applying default presets to provider settings */ +import type { CliproxyProviderCatalog } from './api-client'; import { MODEL_CATALOGS } from './model-catalogs'; import { buildUiCatalogs } from './model-catalogs'; import { CLIPROXY_DEFAULT_PORT } from './default-ports'; @@ -26,7 +27,11 @@ async function fetchEffectiveApiKey(): Promise { } } -async function fetchProviderCatalog(provider: string) { +async function fetchProviderCatalog(provider: string, catalog?: CliproxyProviderCatalog) { + if (catalog) { + return catalog; + } + try { const response = await fetch('/api/cliproxy/catalog'); if (!response.ok) { @@ -52,18 +57,20 @@ async function fetchProviderCatalog(provider: string) { */ export async function applyDefaultPreset( provider: string, - port?: number + port?: number, + catalog?: CliproxyProviderCatalog ): Promise<{ success: boolean; presetName?: string }> { - const catalog = await fetchProviderCatalog(provider); - if (!catalog) return { success: false }; + const resolvedCatalog = await fetchProviderCatalog(provider, catalog); + if (!resolvedCatalog) return { success: false }; const defaultModelEntry = - catalog.models.find((model) => model.id === catalog.defaultModel) || catalog.models[0]; + resolvedCatalog.models.find((model) => model.id === resolvedCatalog.defaultModel) || + resolvedCatalog.models[0]; const mapping = defaultModelEntry?.presetMapping || { - default: catalog.defaultModel, - opus: catalog.defaultModel, - sonnet: catalog.defaultModel, - haiku: catalog.defaultModel, + default: resolvedCatalog.defaultModel, + opus: resolvedCatalog.defaultModel, + sonnet: resolvedCatalog.defaultModel, + haiku: resolvedCatalog.defaultModel, }; // Fetch effective API key (respects user customization) @@ -89,7 +96,7 @@ export async function applyDefaultPreset( }); return { success: res.ok, - presetName: defaultModelEntry?.name || catalog.defaultModel, + presetName: defaultModelEntry?.name || resolvedCatalog.defaultModel, }; } catch { return { success: false }; diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 95cca80b..5a05cd95 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -561,6 +561,7 @@ export function CliproxyPage() { onClose={() => setAddAccountProvider(null)} provider={addAccountProvider?.provider || ''} displayName={addAccountProvider?.displayName || ''} + catalog={addAccountProvider?.provider ? catalogs[addAccountProvider.provider] : undefined} isFirstAccount={addAccountProvider?.isFirstAccount || false} /> diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index 9fc4e890..dbc852a2 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -58,6 +58,31 @@ describe('claude preset utils', () => { }); }); + it('skips catalog fetch when the caller already has the provider catalog cached', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ apiKey: { value: 'managed-key' } }), + }) + .mockResolvedValueOnce({ ok: true }); + + vi.stubGlobal('fetch', fetchMock); + + const result = await applyDefaultPreset('claude', undefined, MODEL_CATALOGS.claude); + + expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 4.6' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/settings/auth/tokens/raw'); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/settings/claude', + expect.objectContaining({ + method: 'PUT', + }) + ); + }); + it('builds UI catalogs from upstream provider models without requiring static dropdown edits', () => { const liveCatalogs = { gemini: { From 87f00c524d6de26d8bee88d95b9147dd64f84777 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 18:01:30 -0400 Subject: [PATCH 4/5] fix(ui): gate preset catalog reuse on query readiness - only reuse provider catalogs after /api/cliproxy/catalog has resolved - avoid passing synthesized static fallback catalogs into preset application - cover quick-setup and cliproxy add-account flows with regression tests --- ui/src/components/setup/wizard/index.tsx | 3 +- ui/src/pages/cliproxy.tsx | 7 +- .../setup/quick-setup-wizard.test.tsx | 129 ++++++++++++++++++ ui/tests/unit/pages/cliproxy-page.test.tsx | 120 ++++++++++++++++ 4 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 ui/tests/unit/components/setup/quick-setup-wizard.test.tsx create mode 100644 ui/tests/unit/pages/cliproxy-page.test.tsx diff --git a/ui/src/components/setup/wizard/index.tsx b/ui/src/components/setup/wizard/index.tsx index 290b8298..5170c8d2 100644 --- a/ui/src/components/setup/wizard/index.tsx +++ b/ui/src/components/setup/wizard/index.tsx @@ -61,6 +61,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { ); const accounts = useMemo(() => providerAuth?.accounts || [], [providerAuth?.accounts]); const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); + const fetchedCatalogsReady = Boolean(catalogData); // Reset on close useEffect(() => { @@ -104,7 +105,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { const result = await applyDefaultPreset( selectedProvider, undefined, - catalogs[selectedProvider] + fetchedCatalogsReady ? catalogs[selectedProvider] : undefined ); if (result.success && result.presetName) { toast.success(`Applied "${result.presetName}" preset`); diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 5a05cd95..ee21191f 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -264,6 +264,7 @@ export function CliproxyPage() { const isRemoteMode = authData?.source === 'remote'; const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); + const fetchedCatalogsReady = Boolean(catalogData); // Wrapper to persist provider selection to localStorage const setSelectedProvider = (provider: string | null) => { @@ -561,7 +562,11 @@ export function CliproxyPage() { onClose={() => setAddAccountProvider(null)} provider={addAccountProvider?.provider || ''} displayName={addAccountProvider?.displayName || ''} - catalog={addAccountProvider?.provider ? catalogs[addAccountProvider.provider] : undefined} + catalog={ + fetchedCatalogsReady && addAccountProvider?.provider + ? catalogs[addAccountProvider.provider] + : undefined + } isFirstAccount={addAccountProvider?.isFirstAccount || false} /> diff --git a/ui/tests/unit/components/setup/quick-setup-wizard.test.tsx b/ui/tests/unit/components/setup/quick-setup-wizard.test.tsx new file mode 100644 index 00000000..abaa5de8 --- /dev/null +++ b/ui/tests/unit/components/setup/quick-setup-wizard.test.tsx @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; + +const hookState = vi.hoisted(() => ({ + catalogData: undefined as + | { + catalogs: Record< + string, + { + provider: string; + displayName: string; + defaultModel: string; + models: Array<{ id: string; name: string }>; + } + >; + } + | undefined, +})); + +const applyDefaultPresetMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useCliproxyAuth: () => ({ + data: { + authStatus: [ + { + provider: 'gemini', + displayName: 'Gemini', + authenticated: false, + accounts: [], + }, + ], + }, + refetch: vi.fn(), + }), + useCliproxyCatalog: () => ({ + data: hookState.catalogData, + }), + useCreateVariant: () => ({ + isPending: false, + mutateAsync: vi.fn(), + }), + useStartAuth: () => ({ + isPending: false, + mutate: (_args: unknown, options?: { onSuccess?: (data: Record) => void }) => { + options?.onSuccess?.({}); + }, + }), + useCancelAuth: () => ({ + mutate: vi.fn(), + }), +})); + +vi.mock('@/lib/preset-utils', () => ({ + applyDefaultPreset: applyDefaultPresetMock, +})); + +vi.mock('@/components/setup/wizard/steps/provider-step', () => ({ + ProviderStep: ({ onSelect }: { onSelect: (providerId: string) => void }) => ( + + ), +})); + +vi.mock('@/components/setup/wizard/steps/auth-step', () => ({ + AuthStep: ({ onStartAuth }: { onStartAuth: () => void }) => ( + + ), +})); + +vi.mock('@/components/setup/wizard/steps/account-step', () => ({ + AccountStep: () =>
account-step
, +})); + +vi.mock('@/components/setup/wizard/steps/variant-step', () => ({ + VariantStep: () =>
variant-step
, +})); + +vi.mock('@/components/setup/wizard/steps/success-step', () => ({ + SuccessStep: () =>
success-step
, +})); + +import { QuickSetupWizard } from '@/components/setup/wizard'; + +describe('QuickSetupWizard preset catalog reuse', () => { + beforeEach(() => { + hookState.catalogData = undefined; + applyDefaultPresetMock.mockReset(); + applyDefaultPresetMock.mockResolvedValue({ success: true, presetName: 'Gemini Pro' }); + }); + + it('does not pass a synthesized static catalog before the catalog query resolves', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'choose-gemini' })); + await userEvent.click(screen.getByRole('button', { name: 'start-auth' })); + + await waitFor(() => + expect(applyDefaultPresetMock).toHaveBeenCalledWith('gemini', undefined, undefined) + ); + }); + + it('passes the fetched provider catalog once the query has resolved', async () => { + hookState.catalogData = { + catalogs: { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-3.9-pro-preview', + models: [{ id: 'gemini-3.9-pro-preview', name: 'Gemini 3.9 Pro Preview' }], + }, + }, + }; + + render(); + + await userEvent.click(screen.getByRole('button', { name: 'choose-gemini' })); + await userEvent.click(screen.getByRole('button', { name: 'start-auth' })); + + await waitFor(() => + expect(applyDefaultPresetMock).toHaveBeenCalledWith( + 'gemini', + undefined, + expect.objectContaining({ + defaultModel: 'gemini-3.9-pro-preview', + }) + ) + ); + }); +}); diff --git a/ui/tests/unit/pages/cliproxy-page.test.tsx b/ui/tests/unit/pages/cliproxy-page.test.tsx new file mode 100644 index 00000000..bd651bfb --- /dev/null +++ b/ui/tests/unit/pages/cliproxy-page.test.tsx @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; + +const hookState = vi.hoisted(() => ({ + catalogData: undefined as + | { + catalogs: Record< + string, + { + provider: string; + displayName: string; + defaultModel: string; + models: Array<{ id: string; name: string }>; + } + >; + } + | undefined, +})); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useCliproxy: () => ({ + data: { variants: [] }, + isFetching: false, + }), + useCliproxyAuth: () => ({ + data: { + authStatus: [ + { + provider: 'gemini', + displayName: 'Gemini', + authenticated: true, + accounts: [{ id: 'acct-1', provider: 'gemini' }], + }, + ], + source: 'local', + }, + isLoading: false, + }), + useCliproxyCatalog: () => ({ + data: hookState.catalogData, + }), + useCliproxyUpdateCheck: () => ({ + data: undefined, + }), + useSetDefaultAccount: () => ({ mutate: vi.fn(), isPending: false }), + useRemoveAccount: () => ({ mutate: vi.fn(), isPending: false }), + usePauseAccount: () => ({ mutate: vi.fn(), isPending: false }), + useResumeAccount: () => ({ mutate: vi.fn(), isPending: false }), + useSoloAccount: () => ({ mutate: vi.fn(), isPending: false }), + useBulkPauseAccounts: () => ({ mutate: vi.fn(), isPending: false }), + useBulkResumeAccounts: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteVariant: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock('@/components/quick-setup-wizard', () => ({ + QuickSetupWizard: () => null, +})); + +vi.mock('@/components/monitoring/proxy-status-widget', () => ({ + ProxyStatusWidget: () => null, +})); + +vi.mock('@/components/account/account-safety-warning-card', () => ({ + AccountSafetyWarningCard: () => null, +})); + +vi.mock('@/components/cliproxy/provider-logo', () => ({ + ProviderLogo: () =>
provider-logo
, +})); + +vi.mock('@/components/cliproxy/provider-editor', () => ({ + ProviderEditor: ({ onAddAccount }: { onAddAccount: () => void }) => ( + + ), +})); + +vi.mock('@/components/account/add-account-dialog', () => ({ + AddAccountDialog: ({ open, catalog }: { open: boolean; catalog?: { defaultModel?: string } }) => + open ? ( +
+ ) : null, +})); + +import { CliproxyPage } from '@/pages/cliproxy'; + +describe('CliproxyPage add-account catalog gating', () => { + beforeEach(() => { + hookState.catalogData = undefined; + }); + + it('does not pass a static fallback catalog before the catalog query resolves', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'open-add-account' })); + + expect(screen.getByTestId('add-account-dialog')).toHaveAttribute('data-catalog', 'missing'); + }); + + it('passes the fetched provider catalog after the catalog query resolves', async () => { + hookState.catalogData = { + catalogs: { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-3.9-pro-preview', + models: [{ id: 'gemini-3.9-pro-preview', name: 'Gemini 3.9 Pro Preview' }], + }, + }, + }; + + render(); + + await userEvent.click(screen.getByRole('button', { name: 'open-add-account' })); + + expect(screen.getByTestId('add-account-dialog')).toHaveAttribute( + 'data-catalog', + 'gemini-3.9-pro-preview' + ); + }); +}); From a70eae558ef3e43e5ddf2d44315e64a3c1a219cd Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 18:02:05 -0400 Subject: [PATCH 5/5] fix(ui): restore searchable-select dropdown scrolling - replace the popover list scroll area with a native overflow container - keep keyboard navigation behavior while restoring wheel scroll in long dropdowns --- ui/src/components/ui/searchable-select.tsx | 9 ++++++--- ui/tests/unit/components/ui/searchable-select.test.tsx | 10 ++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/ui/src/components/ui/searchable-select.tsx b/ui/src/components/ui/searchable-select.tsx index 9da3602e..b971bb0f 100644 --- a/ui/src/components/ui/searchable-select.tsx +++ b/ui/src/components/ui/searchable-select.tsx @@ -5,7 +5,6 @@ import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { ScrollArea } from '@/components/ui/scroll-area'; export interface SearchableSelectGroup { key: string; @@ -280,7 +279,11 @@ export function SearchableSelect({
- +
{filteredOptions.length === 0 ? (
{emptyText}
) : ( @@ -331,7 +334,7 @@ export function SearchableSelect({ ))}
)} -
+ ); diff --git a/ui/tests/unit/components/ui/searchable-select.test.tsx b/ui/tests/unit/components/ui/searchable-select.test.tsx index 4eafc880..ead6a743 100644 --- a/ui/tests/unit/components/ui/searchable-select.test.tsx +++ b/ui/tests/unit/components/ui/searchable-select.test.tsx @@ -122,6 +122,16 @@ describe('SearchableSelect', () => { }); }); + it('renders options inside a native overflow container so wheel scrolling works', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Select model' })); + + const scrollContainer = await screen.findByTestId('searchable-select-scroll-container'); + expect(scrollContainer).toHaveClass('max-h-72'); + expect(scrollContainer).toHaveClass('overflow-y-auto'); + }); + it('opens from the trigger with arrow keys', async () => { render();