mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 02:15:36 +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:
1 parent
db8f955a95
commit
7bbf32ae9a
16 files changed
+425
-92
No files matched your search
@@ -122,7 +122,7 @@ export function ProviderEditor({
|
||||
conflictDialog,
|
||||
handleConflictResolve,
|
||||
missingRequiredFields,
|
||||
} = useProviderEditor(provider);
|
||||
} = useProviderEditor(provider, catalog);
|
||||
|
||||
// Defensive normalization: remote/legacy payloads may omit account.provider.
|
||||
// Fallback to current editor provider to avoid runtime crashes in account UI.
|
||||
|
||||
@@ -58,7 +58,7 @@ export function ModelConfigSection({
|
||||
|
||||
const uniqueIds = [...new Set(selectedModels)];
|
||||
return uniqueIds
|
||||
.map((modelId) => findCatalogModel(catalog.provider, modelId))
|
||||
.map((modelId) => findCatalogModel(catalog.provider, modelId, catalog))
|
||||
.filter((model): model is NonNullable<typeof model> => Boolean(model?.extendedContext));
|
||||
}, [catalog, currentModel, opusModel, sonnetModel, haikuModel]);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useState, useMemo, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import type { SettingsResponse, UseProviderEditorReturn } from './types';
|
||||
import type { ProviderCatalog } from '../provider-model-selector';
|
||||
import {
|
||||
applyExtendedContextPreferenceToAnthropicModels,
|
||||
hasAnthropicExtendedContextEnabled,
|
||||
@@ -23,7 +24,10 @@ function checkMissingFields(settings: { env?: Record<string, string> }): string[
|
||||
return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim());
|
||||
}
|
||||
|
||||
export function useProviderEditor(provider: string): UseProviderEditorReturn {
|
||||
export function useProviderEditor(
|
||||
provider: string,
|
||||
catalog?: ProviderCatalog
|
||||
): UseProviderEditorReturn {
|
||||
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||
const [conflictDialog, setConflictDialog] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -82,9 +86,9 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn {
|
||||
const applySavedLongContextIntent = useCallback(
|
||||
(env: Record<string, string>, enabled: boolean) =>
|
||||
applyExtendedContextPreferenceToAnthropicModels(env, enabled, {
|
||||
supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId),
|
||||
supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId, catalog),
|
||||
}),
|
||||
[provider]
|
||||
[catalog, provider]
|
||||
);
|
||||
|
||||
// Update a single setting value
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import {
|
||||
useCliproxyAuth,
|
||||
useCliproxyCatalog,
|
||||
useCreateVariant,
|
||||
useStartAuth,
|
||||
useCancelAuth,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||
import type { CLIProxyProvider } from '@/lib/provider-config';
|
||||
import { applyDefaultPreset } from '@/lib/preset-utils';
|
||||
import { buildUiCatalogs } from '@/lib/model-catalogs';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import { toast } from 'sonner';
|
||||
@@ -47,6 +49,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
const [isAddingNewAccount, setIsAddingNewAccount] = useState(false);
|
||||
|
||||
const { data: authData, refetch } = useCliproxyAuth();
|
||||
const { data: catalogData } = useCliproxyCatalog();
|
||||
const createMutation = useCreateVariant();
|
||||
const startAuthMutation = useStartAuth();
|
||||
const cancelAuthMutation = useCancelAuth();
|
||||
@@ -57,6 +60,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
(s: AuthStatus) => s.provider === selectedProvider
|
||||
);
|
||||
const accounts = useMemo(() => providerAuth?.accounts || [], [providerAuth?.accounts]);
|
||||
const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]);
|
||||
|
||||
// Reset on close
|
||||
useEffect(() => {
|
||||
@@ -228,6 +232,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
{step === 'variant' && (
|
||||
<VariantStep
|
||||
selectedProvider={selectedProvider}
|
||||
catalog={catalogs[selectedProvider]}
|
||||
selectedAccount={selectedAccount}
|
||||
variantName={variantName}
|
||||
modelName={modelName}
|
||||
|
||||
@@ -26,6 +26,7 @@ const CUSTOM_MODEL_VALUE = '__custom__';
|
||||
|
||||
export function VariantStep({
|
||||
selectedProvider,
|
||||
catalog,
|
||||
selectedAccount,
|
||||
variantName,
|
||||
modelName,
|
||||
@@ -39,7 +40,8 @@ export function VariantStep({
|
||||
}: VariantStepProps) {
|
||||
const { t } = useTranslation();
|
||||
// Track if user selected custom model option
|
||||
const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || [];
|
||||
const resolvedCatalog = catalog || MODEL_CATALOGS[selectedProvider];
|
||||
const catalogModels = resolvedCatalog?.models || [];
|
||||
const isCustomModel = modelName && !catalogModels.some((m) => m.id === modelName);
|
||||
const [showCustomInput, setShowCustomInput] = useState(isCustomModel);
|
||||
const deniedCustomModel =
|
||||
@@ -171,9 +173,7 @@ export function VariantStep({
|
||||
{showCustomInput
|
||||
? t('setupVariant.enterAnyModel')
|
||||
: t('setupVariant.defaultModel', {
|
||||
model:
|
||||
MODEL_CATALOGS[selectedProvider]?.defaultModel ||
|
||||
t('setupVariant.providerDefault'),
|
||||
model: resolvedCatalog?.defaultModel || t('setupVariant.providerDefault'),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Types for Quick Setup Wizard
|
||||
*/
|
||||
|
||||
import type { OAuthAccount } from '@/lib/api-client';
|
||||
import type { CliproxyProviderCatalog, OAuthAccount } from '@/lib/api-client';
|
||||
|
||||
export type WizardStep = 'provider' | 'auth' | 'account' | 'variant' | 'success';
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface AccountStepProps {
|
||||
|
||||
export interface VariantStepProps {
|
||||
selectedProvider: string;
|
||||
catalog?: CliproxyProviderCatalog;
|
||||
selectedAccount: OAuthAccount | null;
|
||||
variantName: string;
|
||||
modelName: string;
|
||||
|
||||
@@ -28,6 +28,15 @@ export function useCliproxyAuth() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCliproxyCatalog() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-catalog'],
|
||||
queryFn: () => api.cliproxy.catalog(),
|
||||
staleTime: 30000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCliproxyRoutingStrategy() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-routing'],
|
||||
|
||||
@@ -448,6 +448,42 @@ export interface CliproxyModelsResponse {
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface CliproxyCatalogPresetMapping {
|
||||
default: string;
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
}
|
||||
|
||||
export interface CliproxyCatalogModel {
|
||||
id: string;
|
||||
name: string;
|
||||
tier?: 'free' | 'paid';
|
||||
description?: string;
|
||||
broken?: boolean;
|
||||
issueUrl?: string;
|
||||
deprecated?: boolean;
|
||||
deprecationReason?: string;
|
||||
extendedContext?: boolean;
|
||||
presetMapping?: CliproxyCatalogPresetMapping;
|
||||
}
|
||||
|
||||
export interface CliproxyProviderCatalog {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
models: CliproxyCatalogModel[];
|
||||
defaultModel: string;
|
||||
}
|
||||
|
||||
export interface CliproxyCatalogResponse {
|
||||
catalogs: Partial<Record<string, CliproxyProviderCatalog>>;
|
||||
source: 'live' | 'cache' | 'static';
|
||||
cache: {
|
||||
synced: boolean;
|
||||
age: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Individual model quota info from Google Cloud Code API */
|
||||
export interface ModelQuota {
|
||||
/** Model name, e.g., "gemini-3-pro-high" */
|
||||
@@ -1070,6 +1106,7 @@ export const api = {
|
||||
|
||||
// Stats and models for Overview tab
|
||||
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
|
||||
catalog: () => request<CliproxyCatalogResponse>('/cliproxy/catalog'),
|
||||
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),
|
||||
updateModel: (provider: string, model: string) =>
|
||||
request(`/cliproxy/models/${provider}`, {
|
||||
|
||||
@@ -680,8 +680,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
},
|
||||
};
|
||||
|
||||
export function findCatalogModel(provider: string, modelId: string) {
|
||||
const catalog = MODEL_CATALOGS[provider.toLowerCase()];
|
||||
function findCatalogModelInCatalog(catalog: ProviderCatalog | undefined, modelId: string) {
|
||||
if (!catalog) return undefined;
|
||||
|
||||
const normalizedModelId = normalizeModelId(modelId);
|
||||
@@ -711,6 +710,91 @@ export function findCatalogModel(provider: string, modelId: string) {
|
||||
.sort((left, right) => compareGeminiVersions(right.info.version, left.info.version))[0]?.model;
|
||||
}
|
||||
|
||||
function normalizeCatalogTier(tier: unknown): ModelEntry['tier'] {
|
||||
if (tier === 'free') return 'free';
|
||||
if (typeof tier === 'string' && tier.trim().length > 0) return 'paid';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildUiCatalog(
|
||||
provider: string,
|
||||
liveCatalog: ProviderCatalog | undefined
|
||||
): ProviderCatalog | undefined {
|
||||
const staticCatalog = MODEL_CATALOGS[provider.toLowerCase()];
|
||||
if (!liveCatalog || liveCatalog.models.length === 0) {
|
||||
return staticCatalog;
|
||||
}
|
||||
|
||||
const availableModels = liveCatalog.models.map((model) => ({
|
||||
id: model.id,
|
||||
owned_by: liveCatalog.provider,
|
||||
}));
|
||||
|
||||
const models = liveCatalog.models.map((model) => {
|
||||
const staticModel = findCatalogModelInCatalog(staticCatalog, model.id);
|
||||
return {
|
||||
...model,
|
||||
name: model.name || staticModel?.name || model.id,
|
||||
tier: staticModel?.tier ?? normalizeCatalogTier(model.tier),
|
||||
description: model.description ?? staticModel?.description,
|
||||
broken: staticModel?.broken,
|
||||
issueUrl: staticModel?.issueUrl,
|
||||
deprecated: staticModel?.deprecated,
|
||||
deprecationReason: staticModel?.deprecationReason,
|
||||
extendedContext: model.extendedContext ?? staticModel?.extendedContext,
|
||||
presetMapping: staticModel?.presetMapping,
|
||||
};
|
||||
});
|
||||
|
||||
const fallbackDefaultModel = staticCatalog?.defaultModel
|
||||
? resolveCatalogModelId(staticCatalog.defaultModel, availableModels)
|
||||
: undefined;
|
||||
const hasFallbackDefaultModel =
|
||||
typeof fallbackDefaultModel === 'string' &&
|
||||
availableModels.some(
|
||||
(model) => normalizeModelId(model.id) === normalizeModelId(fallbackDefaultModel)
|
||||
);
|
||||
|
||||
return {
|
||||
provider: liveCatalog.provider,
|
||||
displayName: liveCatalog.displayName || staticCatalog?.displayName || provider,
|
||||
defaultModel: hasFallbackDefaultModel ? fallbackDefaultModel : liveCatalog.defaultModel,
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUiCatalogs(
|
||||
liveCatalogs: Partial<Record<string, ProviderCatalog>> | undefined
|
||||
): Partial<Record<string, ProviderCatalog>> {
|
||||
const catalogs: Partial<Record<string, ProviderCatalog>> = {};
|
||||
const providers = new Set<string>([
|
||||
...Object.keys(MODEL_CATALOGS),
|
||||
...Object.keys(liveCatalogs ?? {}),
|
||||
]);
|
||||
|
||||
for (const provider of providers) {
|
||||
const catalog = buildUiCatalog(provider, liveCatalogs?.[provider]);
|
||||
if (catalog) {
|
||||
catalogs[provider] = catalog;
|
||||
}
|
||||
}
|
||||
|
||||
return catalogs;
|
||||
}
|
||||
|
||||
export function findCatalogModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
catalogOverride?: ProviderCatalog
|
||||
) {
|
||||
const overrideMatch = findCatalogModelInCatalog(catalogOverride, modelId);
|
||||
if (overrideMatch) {
|
||||
return overrideMatch;
|
||||
}
|
||||
|
||||
return findCatalogModelInCatalog(MODEL_CATALOGS[provider.toLowerCase()], modelId);
|
||||
}
|
||||
|
||||
export function resolveCatalogModelId(
|
||||
modelId: string,
|
||||
availableModels: CatalogAvailableModel[] = []
|
||||
@@ -773,6 +857,10 @@ export function getResolvedCatalogModels(
|
||||
});
|
||||
}
|
||||
|
||||
export function supportsExtendedContext(provider: string, modelId: string): boolean {
|
||||
return findCatalogModel(provider, modelId)?.extendedContext === true;
|
||||
export function supportsExtendedContext(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
catalogOverride?: ProviderCatalog
|
||||
): boolean {
|
||||
return findCatalogModel(provider, modelId, catalogOverride)?.extendedContext === true;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { MODEL_CATALOGS } from './model-catalogs';
|
||||
import { buildUiCatalogs } from './model-catalogs';
|
||||
import { CLIPROXY_DEFAULT_PORT } from './default-ports';
|
||||
export { CLIPROXY_DEFAULT_PORT } from './default-ports';
|
||||
|
||||
@@ -25,6 +26,22 @@ async function fetchEffectiveApiKey(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProviderCatalog(provider: string) {
|
||||
try {
|
||||
const response = await fetch('/api/cliproxy/catalog');
|
||||
if (!response.ok) {
|
||||
return MODEL_CATALOGS[provider];
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
catalogs?: Partial<Record<string, (typeof MODEL_CATALOGS)[string]>>;
|
||||
};
|
||||
return buildUiCatalogs(data.catalogs)[provider] ?? MODEL_CATALOGS[provider];
|
||||
} catch {
|
||||
return MODEL_CATALOGS[provider];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply default preset for a provider to its settings
|
||||
* Uses the catalog default model's preset mapping or falls back to using defaultModel for all tiers
|
||||
@@ -37,7 +54,7 @@ export async function applyDefaultPreset(
|
||||
provider: string,
|
||||
port?: number
|
||||
): Promise<{ success: boolean; presetName?: string }> {
|
||||
const catalog = MODEL_CATALOGS[provider];
|
||||
const catalog = await fetchProviderCatalog(provider);
|
||||
if (!catalog) return { success: false };
|
||||
|
||||
const defaultModelEntry =
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
|
||||
import {
|
||||
useCliproxy,
|
||||
useCliproxyAuth,
|
||||
useCliproxyCatalog,
|
||||
useCliproxyUpdateCheck,
|
||||
useSetDefaultAccount,
|
||||
useRemoveAccount,
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
useDeleteVariant,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import type { AuthStatus, Variant } from '@/lib/api-client';
|
||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||
import { buildUiCatalogs } from '@/lib/model-catalogs';
|
||||
import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -209,6 +210,7 @@ export function CliproxyPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
||||
const { data: variantsData, isFetching } = useCliproxy();
|
||||
const { data: catalogData } = useCliproxyCatalog();
|
||||
const { data: updateCheck } = useCliproxyUpdateCheck();
|
||||
const setDefaultMutation = useSetDefaultAccount();
|
||||
const removeMutation = useRemoveAccount();
|
||||
@@ -261,6 +263,7 @@ export function CliproxyPage() {
|
||||
const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]);
|
||||
const isRemoteMode = authData?.source === 'remote';
|
||||
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
|
||||
const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]);
|
||||
|
||||
// Wrapper to persist provider selection to localStorage
|
||||
const setSelectedProvider = (provider: string | null) => {
|
||||
@@ -300,6 +303,7 @@ export function CliproxyPage() {
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
|
||||
};
|
||||
|
||||
const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => {
|
||||
@@ -453,7 +457,7 @@ export function CliproxyPage() {
|
||||
provider: selectedVariantData.provider,
|
||||
})}
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||
catalog={catalogs[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
baseProvider={selectedVariantData.provider}
|
||||
defaultTarget={selectedVariantData.target}
|
||||
@@ -506,7 +510,7 @@ export function CliproxyPage() {
|
||||
provider={selectedStatus.provider}
|
||||
displayName={selectedStatus.displayName}
|
||||
authStatus={selectedStatus}
|
||||
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
||||
catalog={catalogs[selectedStatus.provider]}
|
||||
isRemoteMode={isRemoteMode}
|
||||
topNotice={
|
||||
showAccountSafetyWarning ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildUiCatalogs,
|
||||
MODEL_CATALOGS,
|
||||
findCatalogModel,
|
||||
getResolvedCatalogModels,
|
||||
@@ -24,6 +25,16 @@ describe('claude preset utils', () => {
|
||||
it('applies the default claude preset from the catalog default model mapping', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
catalogs: {
|
||||
claude: MODEL_CATALOGS.claude,
|
||||
},
|
||||
source: 'live',
|
||||
cache: { synced: true, age: '0m ago' },
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ apiKey: { value: 'managed-key' } }),
|
||||
@@ -36,7 +47,7 @@ describe('claude preset utils', () => {
|
||||
|
||||
expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 4.6' });
|
||||
|
||||
const [, requestInit] = fetchMock.mock.calls[1] ?? [];
|
||||
const [, requestInit] = fetchMock.mock.calls[2] ?? [];
|
||||
const body = JSON.parse(String(requestInit?.body));
|
||||
|
||||
expect(body.settings.env).toMatchObject({
|
||||
@@ -47,6 +58,37 @@ describe('claude preset utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('builds UI catalogs from upstream provider models without requiring static dropdown edits', () => {
|
||||
const liveCatalogs = {
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
defaultModel: 'gemini-3.9-pro-preview',
|
||||
models: [
|
||||
{ id: 'gemini-3.9-pro-preview', name: 'Gemini 3.9 Pro Preview' },
|
||||
{ id: 'gemini-3-9-flash-preview', name: 'Gemini 3.9 Flash Preview' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const catalogs = buildUiCatalogs(liveCatalogs);
|
||||
const resolvedGeminiModels = getResolvedCatalogModels(catalogs.gemini, [
|
||||
{ id: 'gemini-3.9-pro-preview', owned_by: 'google' },
|
||||
{ id: 'gemini-3-9-flash-preview', owned_by: 'google' },
|
||||
]);
|
||||
|
||||
expect(catalogs.gemini?.defaultModel).toBe('gemini-3.9-pro-preview');
|
||||
expect(resolvedGeminiModels.find((model) => model.id === 'gemini-3.9-pro-preview')).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'Gemini 3.9 Pro Preview',
|
||||
presetMapping: expect.objectContaining({
|
||||
default: 'gemini-3.9-pro-preview',
|
||||
haiku: 'gemini-3-9-flash-preview',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps Gemini presets on 3.1 Pro while resolving 3/3.1 alias variants', () => {
|
||||
const geminiCatalog = MODEL_CATALOGS.gemini;
|
||||
const latestPro = geminiCatalog.models.find((model) => model.id === 'gemini-3.1-pro-preview');
|
||||
|
||||
Reference in new issue
Block a user