mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 02:14:32 +00:00
Merge pull request #932 from kaitranntt/kai/feat/931-upstream-provider-model-catalogs
feat(cliproxy): source provider model pickers from upstream catalogs
This commit is contained in:
22 files changed
+748
-106
No files matched your search
@@ -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.
|
||||
|
||||
+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']);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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,8 @@ 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]);
|
||||
const fetchedCatalogsReady = Boolean(catalogData);
|
||||
|
||||
// Reset on close
|
||||
useEffect(() => {
|
||||
@@ -97,7 +102,11 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
{
|
||||
onSuccess: async (data) => {
|
||||
if (isFirstAccount) {
|
||||
const result = await applyDefaultPreset(selectedProvider);
|
||||
const result = await applyDefaultPreset(
|
||||
selectedProvider,
|
||||
undefined,
|
||||
fetchedCatalogsReady ? catalogs[selectedProvider] : undefined
|
||||
);
|
||||
if (result.success && result.presetName) {
|
||||
toast.success(`Applied "${result.presetName}" preset`);
|
||||
} else if (!result.success) {
|
||||
@@ -228,6 +237,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;
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-72">
|
||||
<div
|
||||
data-slot="searchable-select-scroll-container"
|
||||
data-testid="searchable-select-scroll-container"
|
||||
className="max-h-72 overflow-y-auto overscroll-contain"
|
||||
>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">{emptyText}</div>
|
||||
) : (
|
||||
@@ -331,7 +334,7 @@ export function SearchableSelect({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
* 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';
|
||||
export { CLIPROXY_DEFAULT_PORT } from './default-ports';
|
||||
|
||||
@@ -25,6 +27,26 @@ async function fetchEffectiveApiKey(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProviderCatalog(provider: string, catalog?: CliproxyProviderCatalog) {
|
||||
if (catalog) {
|
||||
return catalog;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -35,18 +57,20 @@ async function fetchEffectiveApiKey(): Promise<string> {
|
||||
*/
|
||||
export async function applyDefaultPreset(
|
||||
provider: string,
|
||||
port?: number
|
||||
port?: number,
|
||||
catalog?: CliproxyProviderCatalog
|
||||
): Promise<{ success: boolean; presetName?: string }> {
|
||||
const catalog = MODEL_CATALOGS[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)
|
||||
@@ -72,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 };
|
||||
|
||||
@@ -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,8 @@ 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]);
|
||||
const fetchedCatalogsReady = Boolean(catalogData);
|
||||
|
||||
// Wrapper to persist provider selection to localStorage
|
||||
const setSelectedProvider = (provider: string | null) => {
|
||||
@@ -300,6 +304,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 +458,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 +511,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 ? (
|
||||
@@ -557,6 +562,11 @@ export function CliproxyPage() {
|
||||
onClose={() => setAddAccountProvider(null)}
|
||||
provider={addAccountProvider?.provider || ''}
|
||||
displayName={addAccountProvider?.displayName || ''}
|
||||
catalog={
|
||||
fetchedCatalogsReady && addAccountProvider?.provider
|
||||
? catalogs[addAccountProvider.provider]
|
||||
: undefined
|
||||
}
|
||||
isFirstAccount={addAccountProvider?.isFirstAccount || false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<string, unknown>) => 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 }) => (
|
||||
<button onClick={() => onSelect('gemini')}>choose-gemini</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/setup/wizard/steps/auth-step', () => ({
|
||||
AuthStep: ({ onStartAuth }: { onStartAuth: () => void }) => (
|
||||
<button onClick={onStartAuth}>start-auth</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/setup/wizard/steps/account-step', () => ({
|
||||
AccountStep: () => <div>account-step</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/setup/wizard/steps/variant-step', () => ({
|
||||
VariantStep: () => <div>variant-step</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/setup/wizard/steps/success-step', () => ({
|
||||
SuccessStep: () => <div>success-step</div>,
|
||||
}));
|
||||
|
||||
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(<QuickSetupWizard open onClose={vi.fn()} />);
|
||||
|
||||
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(<QuickSetupWizard open onClose={vi.fn()} />);
|
||||
|
||||
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',
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,16 @@ describe('SearchableSelect', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders options inside a native overflow container so wheel scrolling works', async () => {
|
||||
render(<SearchableSelectHarness />);
|
||||
|
||||
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(<SearchableSelectHarness />);
|
||||
|
||||
|
||||
@@ -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: () => <div>provider-logo</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/cliproxy/provider-editor', () => ({
|
||||
ProviderEditor: ({ onAddAccount }: { onAddAccount: () => void }) => (
|
||||
<button onClick={onAddAccount}>open-add-account</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/account/add-account-dialog', () => ({
|
||||
AddAccountDialog: ({ open, catalog }: { open: boolean; catalog?: { defaultModel?: string } }) =>
|
||||
open ? (
|
||||
<div data-testid="add-account-dialog" data-catalog={catalog?.defaultModel ?? 'missing'} />
|
||||
) : 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(<CliproxyPage />);
|
||||
|
||||
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(<CliproxyPage />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'open-add-account' }));
|
||||
|
||||
expect(screen.getByTestId('add-account-dialog')).toHaveAttribute(
|
||||
'data-catalog',
|
||||
'gemini-3.9-pro-preview'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,62 @@ 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: {
|
||||
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