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 committed 2026-04-08 17:23:35 -04:00
1 parent db8f955a95
commit 7bbf32ae9a
16 files changed
+425 -92

No files matched your search

@@ -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');