mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
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:
+150
-25
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user