mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 04:22:40 +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 { CLIProxyProvider } from './types';
|
||||||
import type { ModelEntry, ProviderCatalog, ThinkingSupport } from './model-catalog';
|
import type { ModelEntry, ProviderCatalog, ThinkingSupport } from './model-catalog';
|
||||||
import { MODEL_CATALOG } 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 { getDeniedModelIdReasonForProvider } from './model-id-normalizer';
|
||||||
|
import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver';
|
||||||
|
|
||||||
const CACHE_FILE_NAME = 'model-catalog-cache.json';
|
const CACHE_FILE_NAME = 'model-catalog-cache.json';
|
||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
const LIVE_FETCH_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
/** Cache structure stored on disk */
|
/** Cache structure stored on disk */
|
||||||
interface CatalogCacheData {
|
interface CatalogCacheData {
|
||||||
@@ -25,6 +31,8 @@ const CHANNEL_TO_PROVIDER: Record<string, CLIProxyProvider> = {
|
|||||||
qwen: 'qwen',
|
qwen: 'qwen',
|
||||||
iflow: 'iflow',
|
iflow: 'iflow',
|
||||||
kimi: 'kimi',
|
kimi: 'kimi',
|
||||||
|
kiro: 'kiro',
|
||||||
|
'github-copilot': 'ghcp',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** CCS provider → channel name mapping (reverse) */
|
/** 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 */
|
/** 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 {
|
function getCacheFilePath(): string {
|
||||||
return path.join(getCcsDir(), CACHE_FILE_NAME);
|
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 */
|
/** Map remote thinking support to CCS ThinkingSupport */
|
||||||
function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefined {
|
function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefined {
|
||||||
if (!remote) return undefined;
|
if (!remote) return undefined;
|
||||||
@@ -137,8 +226,8 @@ function mapRemoteToModelEntry(remote: RemoteModelInfo): ModelEntry {
|
|||||||
* Merge remote models with static catalog for a provider.
|
* Merge remote models with static catalog for a provider.
|
||||||
* Remote fields override static where present.
|
* Remote fields override static where present.
|
||||||
* Static-only fields preserved: broken, deprecated, deprecationReason, issueUrl, tier.
|
* 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 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(
|
export function mergeCatalog(
|
||||||
provider: CLIProxyProvider,
|
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 {
|
return {
|
||||||
provider,
|
provider,
|
||||||
displayName,
|
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.
|
* Get resolved catalog for a provider.
|
||||||
* Uses cached remote data if available, falls back to static.
|
* 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).
|
* Get all resolved catalogs (for Dashboard).
|
||||||
*/
|
*/
|
||||||
export function getAllResolvedCatalogs(): Partial<Record<CLIProxyProvider, ProviderCatalog>> {
|
export function getAllResolvedCatalogs(): Partial<Record<CLIProxyProvider, ProviderCatalog>> {
|
||||||
const result: Partial<Record<CLIProxyProvider, ProviderCatalog>> = {};
|
|
||||||
const cached = getCachedCatalog();
|
const cached = getCachedCatalog();
|
||||||
|
return getAllResolvedCatalogsFromProviders(cached?.providers);
|
||||||
|
}
|
||||||
|
|
||||||
// Get all known providers from both static and cache
|
export async function getResolvedCatalogSnapshot(): Promise<ResolvedCatalogSnapshot> {
|
||||||
const providers = new Set<CLIProxyProvider>();
|
const liveProviders = await refreshCatalogFromProxy();
|
||||||
for (const p of Object.keys(MODEL_CATALOG) as CLIProxyProvider[]) providers.add(p);
|
if (liveProviders) {
|
||||||
if (cached) {
|
return {
|
||||||
for (const p of Object.keys(cached.providers) as CLIProxyProvider[]) providers.add(p);
|
catalogs: getAllResolvedCatalogsFromProviders(liveProviders),
|
||||||
|
source: 'live',
|
||||||
|
cacheAge: getCacheAge(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const provider of providers) {
|
const cached = getCachedCatalog();
|
||||||
const catalog = getResolvedCatalog(provider);
|
if (cached?.providers) {
|
||||||
if (catalog) result[provider] = catalog;
|
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 { initUI, header, subheader, color, dim } from '../../utils/ui';
|
||||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
|
||||||
import { createManagementClient } from '../../cliproxy/management-api-client';
|
|
||||||
import {
|
import {
|
||||||
getCacheAge,
|
getCacheAge,
|
||||||
setCachedCatalog,
|
|
||||||
clearCatalogCache,
|
clearCatalogCache,
|
||||||
SYNCABLE_PROVIDERS,
|
SYNCABLE_PROVIDERS,
|
||||||
PROVIDER_TO_CHANNEL,
|
|
||||||
getResolvedCatalog,
|
getResolvedCatalog,
|
||||||
|
refreshCatalogFromProxy,
|
||||||
} from '../../cliproxy/catalog-cache';
|
} from '../../cliproxy/catalog-cache';
|
||||||
|
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
import type { RemoteModelInfo } from '../../cliproxy/management-api-types';
|
import type { RemoteModelInfo } from '../../cliproxy/management-api-types';
|
||||||
|
|
||||||
@@ -16,46 +14,27 @@ import type { RemoteModelInfo } from '../../cliproxy/management-api-types';
|
|||||||
async function fetchRemoteCatalogs(
|
async function fetchRemoteCatalogs(
|
||||||
verbose: boolean
|
verbose: boolean
|
||||||
): Promise<Record<string, RemoteModelInfo[]> | null> {
|
): Promise<Record<string, RemoteModelInfo[]> | null> {
|
||||||
const config = loadOrCreateUnifiedConfig();
|
const target = getProxyTarget();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
console.log(dim(` Connected to ${client.getBaseUrl()}`));
|
console.log(
|
||||||
if (health.version) console.log(dim(` CLIProxy version: ${health.version}`));
|
dim(
|
||||||
|
` Connected to ${target.protocol}://${target.host}:${target.port} (${target.isRemote ? 'remote' : 'local'})`
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: Record<string, RemoteModelInfo[]> = {};
|
const result = await refreshCatalogFromProxy();
|
||||||
|
if (verbose && result) {
|
||||||
for (const provider of SYNCABLE_PROVIDERS) {
|
for (const provider of SYNCABLE_PROVIDERS) {
|
||||||
const channel = PROVIDER_TO_CHANNEL[provider];
|
const models = result[provider];
|
||||||
if (!channel) continue;
|
if (models?.length) {
|
||||||
|
console.log(dim(` ${provider}: ${models.length} models`));
|
||||||
try {
|
|
||||||
const response = await client.getModelDefinitions(channel);
|
|
||||||
if (response && response.length > 0) {
|
|
||||||
result[provider] = response;
|
|
||||||
if (verbose) console.log(dim(` ${provider}: ${response.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 */
|
/** Show catalog status */
|
||||||
@@ -104,20 +83,18 @@ export async function handleCatalogRefresh(verbose: boolean): Promise<void> {
|
|||||||
|
|
||||||
const result = await fetchRemoteCatalogs(verbose);
|
const result = await fetchRemoteCatalogs(verbose);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
console.log(' Failed to fetch remote catalogs. Static catalog unchanged.');
|
console.log(' Failed to fetch live catalogs. Static catalog unchanged.');
|
||||||
console.log('');
|
console.log('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCachedCatalog(result);
|
|
||||||
|
|
||||||
// Show summary
|
// Show summary
|
||||||
let totalModels = 0;
|
let totalModels = 0;
|
||||||
for (const [provider, models] of Object.entries(result)) {
|
for (const [provider, models] of Object.entries(result)) {
|
||||||
const merged = getResolvedCatalog(provider as CLIProxyProvider);
|
const merged = getResolvedCatalog(provider as CLIProxyProvider);
|
||||||
const mergedCount = merged?.models.length ?? 0;
|
const mergedCount = merged?.models.length ?? 0;
|
||||||
console.log(
|
console.log(
|
||||||
` ${color(provider.padEnd(12), 'command')} ${models.length} remote -> ${mergedCount} merged`
|
` ${color(provider.padEnd(12), 'command')} ${models.length} live -> ${mergedCount} merged`
|
||||||
);
|
);
|
||||||
totalModels += mergedCount;
|
totalModels += mergedCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { getAllResolvedCatalogs, getCacheAge } from '../../cliproxy/catalog-cache';
|
import { getResolvedCatalogSnapshot } from '../../cliproxy/catalog-cache';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/cliproxy/catalog - Get merged model catalogs
|
* 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 {
|
try {
|
||||||
const catalogs = getAllResolvedCatalogs();
|
const snapshot = await getResolvedCatalogSnapshot();
|
||||||
const cacheAge = getCacheAge();
|
|
||||||
res.json({
|
res.json({
|
||||||
catalogs,
|
catalogs: snapshot.catalogs,
|
||||||
|
source: snapshot.source,
|
||||||
cache: {
|
cache: {
|
||||||
synced: cacheAge !== null,
|
synced: snapshot.source !== 'static' || snapshot.cacheAge !== null,
|
||||||
age: cacheAge,
|
age: snapshot.cacheAge,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, it, expect } from 'bun:test';
|
import { describe, it, expect } from 'bun:test';
|
||||||
import { findModel, supportsThinking } from '../../../src/cliproxy/model-catalog';
|
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', () => {
|
describe('model-catalog compatibility lookups', () => {
|
||||||
it('finds agy Claude models using dotted major.minor IDs', () => {
|
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(dottedLegacy?.id).toBe('claude-sonnet-4-6');
|
||||||
expect(hyphenLegacy?.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,
|
conflictDialog,
|
||||||
handleConflictResolve,
|
handleConflictResolve,
|
||||||
missingRequiredFields,
|
missingRequiredFields,
|
||||||
} = useProviderEditor(provider);
|
} = useProviderEditor(provider, catalog);
|
||||||
|
|
||||||
// Defensive normalization: remote/legacy payloads may omit account.provider.
|
// Defensive normalization: remote/legacy payloads may omit account.provider.
|
||||||
// Fallback to current editor provider to avoid runtime crashes in account UI.
|
// Fallback to current editor provider to avoid runtime crashes in account UI.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function ModelConfigSection({
|
|||||||
|
|
||||||
const uniqueIds = [...new Set(selectedModels)];
|
const uniqueIds = [...new Set(selectedModels)];
|
||||||
return uniqueIds
|
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));
|
.filter((model): model is NonNullable<typeof model> => Boolean(model?.extendedContext));
|
||||||
}, [catalog, currentModel, opusModel, sonnetModel, haikuModel]);
|
}, [catalog, currentModel, opusModel, sonnetModel, haikuModel]);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useState, useMemo, useCallback } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { SettingsResponse, UseProviderEditorReturn } from './types';
|
import type { SettingsResponse, UseProviderEditorReturn } from './types';
|
||||||
|
import type { ProviderCatalog } from '../provider-model-selector';
|
||||||
import {
|
import {
|
||||||
applyExtendedContextPreferenceToAnthropicModels,
|
applyExtendedContextPreferenceToAnthropicModels,
|
||||||
hasAnthropicExtendedContextEnabled,
|
hasAnthropicExtendedContextEnabled,
|
||||||
@@ -23,7 +24,10 @@ function checkMissingFields(settings: { env?: Record<string, string> }): string[
|
|||||||
return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim());
|
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 [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||||
const [conflictDialog, setConflictDialog] = useState(false);
|
const [conflictDialog, setConflictDialog] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -82,9 +86,9 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn {
|
|||||||
const applySavedLongContextIntent = useCallback(
|
const applySavedLongContextIntent = useCallback(
|
||||||
(env: Record<string, string>, enabled: boolean) =>
|
(env: Record<string, string>, enabled: boolean) =>
|
||||||
applyExtendedContextPreferenceToAnthropicModels(env, enabled, {
|
applyExtendedContextPreferenceToAnthropicModels(env, enabled, {
|
||||||
supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId),
|
supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId, catalog),
|
||||||
}),
|
}),
|
||||||
[provider]
|
[catalog, provider]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update a single setting value
|
// Update a single setting value
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { Sparkles } from 'lucide-react';
|
import { Sparkles } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useCliproxyAuth,
|
useCliproxyAuth,
|
||||||
|
useCliproxyCatalog,
|
||||||
useCreateVariant,
|
useCreateVariant,
|
||||||
useStartAuth,
|
useStartAuth,
|
||||||
useCancelAuth,
|
useCancelAuth,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||||
import type { CLIProxyProvider } from '@/lib/provider-config';
|
import type { CLIProxyProvider } from '@/lib/provider-config';
|
||||||
import { applyDefaultPreset } from '@/lib/preset-utils';
|
import { applyDefaultPreset } from '@/lib/preset-utils';
|
||||||
|
import { buildUiCatalogs } from '@/lib/model-catalogs';
|
||||||
import i18n from '@/lib/i18n';
|
import i18n from '@/lib/i18n';
|
||||||
import { usePrivacy } from '@/contexts/privacy-context';
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -47,6 +49,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
|||||||
const [isAddingNewAccount, setIsAddingNewAccount] = useState(false);
|
const [isAddingNewAccount, setIsAddingNewAccount] = useState(false);
|
||||||
|
|
||||||
const { data: authData, refetch } = useCliproxyAuth();
|
const { data: authData, refetch } = useCliproxyAuth();
|
||||||
|
const { data: catalogData } = useCliproxyCatalog();
|
||||||
const createMutation = useCreateVariant();
|
const createMutation = useCreateVariant();
|
||||||
const startAuthMutation = useStartAuth();
|
const startAuthMutation = useStartAuth();
|
||||||
const cancelAuthMutation = useCancelAuth();
|
const cancelAuthMutation = useCancelAuth();
|
||||||
@@ -57,6 +60,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
|||||||
(s: AuthStatus) => s.provider === selectedProvider
|
(s: AuthStatus) => s.provider === selectedProvider
|
||||||
);
|
);
|
||||||
const accounts = useMemo(() => providerAuth?.accounts || [], [providerAuth?.accounts]);
|
const accounts = useMemo(() => providerAuth?.accounts || [], [providerAuth?.accounts]);
|
||||||
|
const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]);
|
||||||
|
|
||||||
// Reset on close
|
// Reset on close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -228,6 +232,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
|||||||
{step === 'variant' && (
|
{step === 'variant' && (
|
||||||
<VariantStep
|
<VariantStep
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider}
|
||||||
|
catalog={catalogs[selectedProvider]}
|
||||||
selectedAccount={selectedAccount}
|
selectedAccount={selectedAccount}
|
||||||
variantName={variantName}
|
variantName={variantName}
|
||||||
modelName={modelName}
|
modelName={modelName}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const CUSTOM_MODEL_VALUE = '__custom__';
|
|||||||
|
|
||||||
export function VariantStep({
|
export function VariantStep({
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
|
catalog,
|
||||||
selectedAccount,
|
selectedAccount,
|
||||||
variantName,
|
variantName,
|
||||||
modelName,
|
modelName,
|
||||||
@@ -39,7 +40,8 @@ export function VariantStep({
|
|||||||
}: VariantStepProps) {
|
}: VariantStepProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Track if user selected custom model option
|
// 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 isCustomModel = modelName && !catalogModels.some((m) => m.id === modelName);
|
||||||
const [showCustomInput, setShowCustomInput] = useState(isCustomModel);
|
const [showCustomInput, setShowCustomInput] = useState(isCustomModel);
|
||||||
const deniedCustomModel =
|
const deniedCustomModel =
|
||||||
@@ -171,9 +173,7 @@ export function VariantStep({
|
|||||||
{showCustomInput
|
{showCustomInput
|
||||||
? t('setupVariant.enterAnyModel')
|
? t('setupVariant.enterAnyModel')
|
||||||
: t('setupVariant.defaultModel', {
|
: t('setupVariant.defaultModel', {
|
||||||
model:
|
model: resolvedCatalog?.defaultModel || t('setupVariant.providerDefault'),
|
||||||
MODEL_CATALOGS[selectedProvider]?.defaultModel ||
|
|
||||||
t('setupVariant.providerDefault'),
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Types for Quick Setup Wizard
|
* 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';
|
export type WizardStep = 'provider' | 'auth' | 'account' | 'variant' | 'success';
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ export interface AccountStepProps {
|
|||||||
|
|
||||||
export interface VariantStepProps {
|
export interface VariantStepProps {
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
|
catalog?: CliproxyProviderCatalog;
|
||||||
selectedAccount: OAuthAccount | null;
|
selectedAccount: OAuthAccount | null;
|
||||||
variantName: string;
|
variantName: string;
|
||||||
modelName: 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() {
|
export function useCliproxyRoutingStrategy() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['cliproxy-routing'],
|
queryKey: ['cliproxy-routing'],
|
||||||
|
|||||||
@@ -448,6 +448,42 @@ export interface CliproxyModelsResponse {
|
|||||||
totalCount: number;
|
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 */
|
/** Individual model quota info from Google Cloud Code API */
|
||||||
export interface ModelQuota {
|
export interface ModelQuota {
|
||||||
/** Model name, e.g., "gemini-3-pro-high" */
|
/** Model name, e.g., "gemini-3-pro-high" */
|
||||||
@@ -1070,6 +1106,7 @@ export const api = {
|
|||||||
|
|
||||||
// Stats and models for Overview tab
|
// Stats and models for Overview tab
|
||||||
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
|
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
|
||||||
|
catalog: () => request<CliproxyCatalogResponse>('/cliproxy/catalog'),
|
||||||
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),
|
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),
|
||||||
updateModel: (provider: string, model: string) =>
|
updateModel: (provider: string, model: string) =>
|
||||||
request(`/cliproxy/models/${provider}`, {
|
request(`/cliproxy/models/${provider}`, {
|
||||||
|
|||||||
@@ -680,8 +680,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function findCatalogModel(provider: string, modelId: string) {
|
function findCatalogModelInCatalog(catalog: ProviderCatalog | undefined, modelId: string) {
|
||||||
const catalog = MODEL_CATALOGS[provider.toLowerCase()];
|
|
||||||
if (!catalog) return undefined;
|
if (!catalog) return undefined;
|
||||||
|
|
||||||
const normalizedModelId = normalizeModelId(modelId);
|
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;
|
.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(
|
export function resolveCatalogModelId(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
availableModels: CatalogAvailableModel[] = []
|
availableModels: CatalogAvailableModel[] = []
|
||||||
@@ -773,6 +857,10 @@ export function getResolvedCatalogModels(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function supportsExtendedContext(provider: string, modelId: string): boolean {
|
export function supportsExtendedContext(
|
||||||
return findCatalogModel(provider, modelId)?.extendedContext === true;
|
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 { MODEL_CATALOGS } from './model-catalogs';
|
||||||
|
import { buildUiCatalogs } from './model-catalogs';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from './default-ports';
|
import { CLIPROXY_DEFAULT_PORT } from './default-ports';
|
||||||
export { 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
|
* 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
|
* 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,
|
provider: string,
|
||||||
port?: number
|
port?: number
|
||||||
): Promise<{ success: boolean; presetName?: string }> {
|
): Promise<{ success: boolean; presetName?: string }> {
|
||||||
const catalog = MODEL_CATALOGS[provider];
|
const catalog = await fetchProviderCatalog(provider);
|
||||||
if (!catalog) return { success: false };
|
if (!catalog) return { success: false };
|
||||||
|
|
||||||
const defaultModelEntry =
|
const defaultModelEntry =
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
|
|||||||
import {
|
import {
|
||||||
useCliproxy,
|
useCliproxy,
|
||||||
useCliproxyAuth,
|
useCliproxyAuth,
|
||||||
|
useCliproxyCatalog,
|
||||||
useCliproxyUpdateCheck,
|
useCliproxyUpdateCheck,
|
||||||
useSetDefaultAccount,
|
useSetDefaultAccount,
|
||||||
useRemoveAccount,
|
useRemoveAccount,
|
||||||
@@ -31,7 +32,7 @@ import {
|
|||||||
useDeleteVariant,
|
useDeleteVariant,
|
||||||
} from '@/hooks/use-cliproxy';
|
} from '@/hooks/use-cliproxy';
|
||||||
import type { AuthStatus, Variant } from '@/lib/api-client';
|
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 { getProviderDisplayName, isValidProvider } from '@/lib/provider-config';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -209,6 +210,7 @@ export function CliproxyPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
||||||
const { data: variantsData, isFetching } = useCliproxy();
|
const { data: variantsData, isFetching } = useCliproxy();
|
||||||
|
const { data: catalogData } = useCliproxyCatalog();
|
||||||
const { data: updateCheck } = useCliproxyUpdateCheck();
|
const { data: updateCheck } = useCliproxyUpdateCheck();
|
||||||
const setDefaultMutation = useSetDefaultAccount();
|
const setDefaultMutation = useSetDefaultAccount();
|
||||||
const removeMutation = useRemoveAccount();
|
const removeMutation = useRemoveAccount();
|
||||||
@@ -261,6 +263,7 @@ export function CliproxyPage() {
|
|||||||
const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]);
|
const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]);
|
||||||
const isRemoteMode = authData?.source === 'remote';
|
const isRemoteMode = authData?.source === 'remote';
|
||||||
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
|
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
|
||||||
|
const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]);
|
||||||
|
|
||||||
// Wrapper to persist provider selection to localStorage
|
// Wrapper to persist provider selection to localStorage
|
||||||
const setSelectedProvider = (provider: string | null) => {
|
const setSelectedProvider = (provider: string | null) => {
|
||||||
@@ -300,6 +303,7 @@ export function CliproxyPage() {
|
|||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => {
|
const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => {
|
||||||
@@ -453,7 +457,7 @@ export function CliproxyPage() {
|
|||||||
provider: selectedVariantData.provider,
|
provider: selectedVariantData.provider,
|
||||||
})}
|
})}
|
||||||
authStatus={parentAuthForVariant}
|
authStatus={parentAuthForVariant}
|
||||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
catalog={catalogs[selectedVariantData.provider]}
|
||||||
logoProvider={selectedVariantData.provider}
|
logoProvider={selectedVariantData.provider}
|
||||||
baseProvider={selectedVariantData.provider}
|
baseProvider={selectedVariantData.provider}
|
||||||
defaultTarget={selectedVariantData.target}
|
defaultTarget={selectedVariantData.target}
|
||||||
@@ -506,7 +510,7 @@ export function CliproxyPage() {
|
|||||||
provider={selectedStatus.provider}
|
provider={selectedStatus.provider}
|
||||||
displayName={selectedStatus.displayName}
|
displayName={selectedStatus.displayName}
|
||||||
authStatus={selectedStatus}
|
authStatus={selectedStatus}
|
||||||
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
catalog={catalogs[selectedStatus.provider]}
|
||||||
isRemoteMode={isRemoteMode}
|
isRemoteMode={isRemoteMode}
|
||||||
topNotice={
|
topNotice={
|
||||||
showAccountSafetyWarning ? (
|
showAccountSafetyWarning ? (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildUiCatalogs,
|
||||||
MODEL_CATALOGS,
|
MODEL_CATALOGS,
|
||||||
findCatalogModel,
|
findCatalogModel,
|
||||||
getResolvedCatalogModels,
|
getResolvedCatalogModels,
|
||||||
@@ -24,6 +25,16 @@ describe('claude preset utils', () => {
|
|||||||
it('applies the default claude preset from the catalog default model mapping', async () => {
|
it('applies the default claude preset from the catalog default model mapping', async () => {
|
||||||
const fetchMock = vi
|
const fetchMock = vi
|
||||||
.fn()
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
catalogs: {
|
||||||
|
claude: MODEL_CATALOGS.claude,
|
||||||
|
},
|
||||||
|
source: 'live',
|
||||||
|
cache: { synced: true, age: '0m ago' },
|
||||||
|
}),
|
||||||
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ apiKey: { value: 'managed-key' } }),
|
json: async () => ({ apiKey: { value: 'managed-key' } }),
|
||||||
@@ -36,7 +47,7 @@ describe('claude preset utils', () => {
|
|||||||
|
|
||||||
expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 4.6' });
|
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));
|
const body = JSON.parse(String(requestInit?.body));
|
||||||
|
|
||||||
expect(body.settings.env).toMatchObject({
|
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', () => {
|
it('keeps Gemini presets on 3.1 Pro while resolving 3/3.1 alias variants', () => {
|
||||||
const geminiCatalog = MODEL_CATALOGS.gemini;
|
const geminiCatalog = MODEL_CATALOGS.gemini;
|
||||||
const latestPro = geminiCatalog.models.find((model) => model.id === 'gemini-3.1-pro-preview');
|
const latestPro = geminiCatalog.models.find((model) => model.id === 'gemini-3.1-pro-preview');
|
||||||
|
|||||||
Reference in New Issue
Block a user