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
This commit is contained in:
Tam Nhu Tran
2026-04-08 17:23:35 -04:00
parent db8f955a95
commit 7bbf32ae9a
16 changed files with 425 additions and 92 deletions
+150 -25
View File
@@ -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<string, CLIProxyProvider> = {
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<string, string> = 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<Record<CLIProxyProvider, ProviderCatalog>>;
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<boolean> {
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<Record<string, RemoteModelInfo[]> | 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<string, RemoteModelInfo[]>;
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<string, RemoteModelInfo[]>
): ProviderCatalog | undefined {
if (providers?.[provider]) {
return mergeCatalog(provider, providers[provider]);
}
return MODEL_CATALOG[provider];
}
function getAllResolvedCatalogsFromProviders(
providers?: Record<string, RemoteModelInfo[]>
): Partial<Record<CLIProxyProvider, ProviderCatalog>> {
const result: Partial<Record<CLIProxyProvider, ProviderCatalog>> = {};
const providerIds = new Set<CLIProxyProvider>();
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<Record<CLIProxyProvider, ProviderCatalog>> {
const result: Partial<Record<CLIProxyProvider, ProviderCatalog>> = {};
const cached = getCachedCatalog();
return getAllResolvedCatalogsFromProviders(cached?.providers);
}
// Get all known providers from both static and cache
const providers = new Set<CLIProxyProvider>();
for (const p of Object.keys(MODEL_CATALOG) as CLIProxyProvider[]) providers.add(p);
if (cached) {
for (const p of Object.keys(cached.providers) as CLIProxyProvider[]) providers.add(p);
export async function getResolvedCatalogSnapshot(): Promise<ResolvedCatalogSnapshot> {
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,
};
}
+17 -40
View File
@@ -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<Record<string, RemoteModelInfo[]> | null> {
const config = loadOrCreateUnifiedConfig();
const remote = config.cliproxy_server?.remote;
if (!remote?.host) {
if (verbose) console.log(dim(' No remote CLIProxy configured'));
return null;
}
const client = createManagementClient(remote);
// Check health first
const health = await client.health();
if (!health.healthy) {
console.log(color(` [!] CLIProxy unreachable: ${health.error || 'unknown error'}`, 'warning'));
return null;
}
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<string, RemoteModelInfo[]> = {};
for (const provider of SYNCABLE_PROVIDERS) {
const channel = PROVIDER_TO_CHANNEL[provider];
if (!channel) continue;
try {
const response = await client.getModelDefinitions(channel);
if (response && response.length > 0) {
result[provider] = response;
if (verbose) console.log(dim(` ${provider}: ${response.length} models`));
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<void> {
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;
}
+8 -8
View File
@@ -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<void> => {
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) {
@@ -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']);
});
});
@@ -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.
@@ -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<typeof model> => Boolean(model?.extendedContext));
}, [catalog, currentModel, opusModel, sonnetModel, haikuModel]);
@@ -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, string> }): 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<string | null>(null);
const [conflictDialog, setConflictDialog] = useState(false);
const queryClient = useQueryClient();
@@ -82,9 +86,9 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn {
const applySavedLongContextIntent = useCallback(
(env: Record<string, string>, enabled: boolean) =>
applyExtendedContextPreferenceToAnthropicModels(env, enabled, {
supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId),
supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId, catalog),
}),
[provider]
[catalog, provider]
);
// Update a single setting value
+5
View File
@@ -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' && (
<VariantStep
selectedProvider={selectedProvider}
catalog={catalogs[selectedProvider]}
selectedAccount={selectedAccount}
variantName={variantName}
modelName={modelName}
@@ -26,6 +26,7 @@ const CUSTOM_MODEL_VALUE = '__custom__';
export function VariantStep({
selectedProvider,
catalog,
selectedAccount,
variantName,
modelName,
@@ -39,7 +40,8 @@ export function VariantStep({
}: VariantStepProps) {
const { t } = useTranslation();
// Track if user selected custom model option
const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || [];
const resolvedCatalog = catalog || MODEL_CATALOGS[selectedProvider];
const catalogModels = resolvedCatalog?.models || [];
const isCustomModel = modelName && !catalogModels.some((m) => 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'),
})}
</div>
</div>
+2 -1
View File
@@ -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;
+9
View File
@@ -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'],
+37
View File
@@ -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<Record<string, CliproxyProviderCatalog>>;
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<string, unknown> }>('/cliproxy/usage'),
catalog: () => request<CliproxyCatalogResponse>('/cliproxy/catalog'),
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),
updateModel: (provider: string, model: string) =>
request(`/cliproxy/models/${provider}`, {
+92 -4
View File
@@ -680,8 +680,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
},
};
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<Record<string, ProviderCatalog>> | undefined
): Partial<Record<string, ProviderCatalog>> {
const catalogs: Partial<Record<string, ProviderCatalog>> = {};
const providers = new Set<string>([
...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;
}
+18 -1
View File
@@ -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<string> {
}
}
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<Record<string, (typeof MODEL_CATALOGS)[string]>>;
};
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 =
+7 -3
View File
@@ -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 ? (
+43 -1
View File
@@ -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');