From 934e6ab52b7bc80dd3876b6fe7c75803f0392d82 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 27 Mar 2026 17:08:58 -0400 Subject: [PATCH] fix(cliproxy): resolve gemini presets from live models --- .../provider-editor/model-config-section.tsx | 11 +- .../cliproxy/provider-model-selector.tsx | 9 +- ui/src/lib/model-catalogs.ts | 200 +++++++++++++++++- .../model-config-section.test.tsx | 41 +++- ui/tests/unit/ui/lib/preset-utils.test.ts | 43 +++- 5 files changed, 286 insertions(+), 18 deletions(-) diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index d93df27a..7b010bbd 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -11,7 +11,7 @@ import { Sparkles, Zap, Star, X, Plus } from 'lucide-react'; import { FlexibleModelSelector } from '../provider-model-selector'; import { ExtendedContextToggle } from '../extended-context-toggle'; import { stripExtendedContextSuffix } from '@/lib/extended-context-utils'; -import { findCatalogModel } from '@/lib/model-catalogs'; +import { findCatalogModel, getResolvedCatalogModels } from '@/lib/model-catalogs'; import type { ModelConfigSectionProps } from './types'; type CatalogPresetModel = NonNullable['models'][number]; @@ -62,8 +62,13 @@ export function ModelConfigSection({ .filter((model): model is NonNullable => Boolean(model?.extendedContext)); }, [catalog, currentModel, opusModel, sonnetModel, haikuModel]); + const resolvedCatalogModels = useMemo( + () => getResolvedCatalogModels(catalog, providerModels), + [catalog, providerModels] + ); + const presetGroups = useMemo(() => { - const presetModels = (catalog?.models ?? []).filter((model) => model.presetMapping); + const presetModels = resolvedCatalogModels.filter((model) => model.presetMapping); if (presetModels.length === 0) return []; const hasPaidPresets = presetModels.some((model) => model.tier === 'paid'); @@ -89,7 +94,7 @@ export function ModelConfigSection({ models: presetModels.filter((model) => model.tier === 'paid'), }, ].filter((group) => group.models.length > 0); - }, [catalog]); + }, [resolvedCatalogModels]); const showPresets = presetGroups.length > 0 || savedPresets.length > 0; diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index af2a1afb..845c2910 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge'; import { SearchableSelect } from '@/components/ui/searchable-select'; import { Skeleton } from '@/components/ui/skeleton'; import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import { getResolvedCatalogModels } from '@/lib/model-catalogs'; import { cn } from '@/lib/utils'; /** Model entry from catalog */ @@ -303,10 +304,14 @@ export function FlexibleModelSelector({ disabled, }: FlexibleModelSelectorProps) { const { t } = useTranslation(); - const catalogModelIds = new Set(catalog?.models.map((model) => model.id) || []); const isCodexProvider = catalog?.provider === 'codex'; + const resolvedCatalogModels = useMemo( + () => getResolvedCatalogModels(catalog, allModels), + [allModels, catalog] + ); + const catalogModelIds = new Set(resolvedCatalogModels.map((model) => model.id)); - const recommendedOptions = (catalog?.models ?? []).map((model) => ({ + const recommendedOptions = resolvedCatalogModels.map((model) => ({ value: model.id, groupKey: 'recommended', searchText: `${model.id} ${model.name}`, diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 317950b2..2fa29700 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -3,7 +3,7 @@ * Shared data for Quick Setup Wizard and Provider Editor */ -import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; +import type { ModelEntry, ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; import { stripModelConfigurationSuffixes } from '@/lib/extended-context-utils'; const GEMINI_MINOR_VERSION_COMPATIBILITY_IDS = Object.freeze({ @@ -13,6 +13,108 @@ const GEMINI_MINOR_VERSION_COMPATIBILITY_IDS = Object.freeze({ 'gemini-3.1-flash-preview': 'gemini-3-flash-preview', }); +const GEMINI_PREVIEW_MODEL_ID_PATTERN = + /^gemini-(\d+(?:[.-]\d+)*)-(pro|flash)-preview(-customtools)?$/i; + +export type CatalogAvailableModel = { + id: string; + owned_by: string; +}; + +type GeminiPreviewFamily = 'pro' | 'flash'; + +type GeminiPreviewModelInfo = { + normalizedId: string; + version: number[]; + family: GeminiPreviewFamily; + customtools: boolean; + dottedVersion: boolean; +}; + +function normalizeModelId(modelId: string): string { + return stripModelConfigurationSuffixes(modelId).toLowerCase(); +} + +function parseGeminiPreviewModelId(modelId: string): GeminiPreviewModelInfo | null { + const normalizedId = normalizeModelId(modelId); + const match = normalizedId.match(GEMINI_PREVIEW_MODEL_ID_PATTERN); + if (!match) return null; + + const [, versionString, family, customtoolsSuffix] = match; + + return { + normalizedId, + version: versionString.split(/[.-]/).map((segment) => Number(segment)), + family: family as GeminiPreviewFamily, + customtools: Boolean(customtoolsSuffix), + dottedVersion: versionString.includes('.'), + }; +} + +function compareGeminiVersions(a: number[], b: number[]): number { + const maxLength = Math.max(a.length, b.length); + for (let index = 0; index < maxLength; index += 1) { + const left = a[index] ?? 0; + const right = b[index] ?? 0; + if (left === right) continue; + return left > right ? 1 : -1; + } + + return 0; +} + +function compareGeminiPreviewCandidates( + left: GeminiPreviewModelInfo, + right: GeminiPreviewModelInfo, + target: GeminiPreviewModelInfo +): number { + if (left.customtools !== right.customtools) { + return left.customtools ? 1 : -1; + } + + const versionComparison = compareGeminiVersions(left.version, right.version); + if (versionComparison !== 0) { + return versionComparison > 0 ? -1 : 1; + } + + const leftStyleMatch = Number(left.dottedVersion === target.dottedVersion); + const rightStyleMatch = Number(right.dottedVersion === target.dottedVersion); + if (leftStyleMatch !== rightStyleMatch) { + return rightStyleMatch - leftStyleMatch; + } + + return left.normalizedId.localeCompare(right.normalizedId); +} + +function findAvailableModelId( + availableModels: CatalogAvailableModel[], + modelId: string +): string | undefined { + const normalizedModelId = normalizeModelId(modelId); + return availableModels.find((model) => normalizeModelId(model.id) === normalizedModelId)?.id; +} + +function resolveGeminiPreviewModelId( + modelId: string, + availableModels: CatalogAvailableModel[] +): string | undefined { + const targetModel = parseGeminiPreviewModelId(modelId); + if (!targetModel || availableModels.length === 0) return undefined; + + const bestMatch = availableModels + .map((model) => { + const info = parseGeminiPreviewModelId(model.id); + if (!info || info.family !== targetModel.family) return null; + return { id: model.id, info }; + }) + .filter((candidate): candidate is { id: string; info: GeminiPreviewModelInfo } => + Boolean(candidate) + ) + .sort((left, right) => compareGeminiPreviewCandidates(left.info, right.info, targetModel))[0]; + + return bestMatch?.id; +} + /** Model catalog data - mirrors src/cliproxy/model-catalog.ts */ export const MODEL_CATALOGS: Record = { agy: { @@ -47,8 +149,8 @@ export const MODEL_CATALOGS: Record = { }, { id: 'gemini-3.1-pro-preview', - name: 'Gemini 3.1 Pro', - description: 'Google latest Gemini Pro model via Antigravity', + name: 'Gemini Pro', + description: 'Resolves to the best advertised Gemini Pro preview via Antigravity', extendedContext: true, presetMapping: { default: 'gemini-3.1-pro-preview', @@ -60,7 +162,7 @@ export const MODEL_CATALOGS: Record = { { id: 'gemini-3-flash-preview', name: 'Gemini Flash', - description: 'Fast Gemini model via Antigravity with 3/3.1 Flash rollout compatibility', + description: 'Resolves to the best advertised Gemini Flash preview via Antigravity', extendedContext: true, presetMapping: { default: 'gemini-3-flash-preview', @@ -78,9 +180,9 @@ export const MODEL_CATALOGS: Record = { models: [ { id: 'gemini-3.1-pro-preview', - name: 'Gemini 3.1 Pro', + name: 'Gemini Pro', tier: 'paid', - description: 'Latest Gemini Pro model, requires paid Google account', + description: 'Uses the best advertised Gemini Pro preview when Google exposes one', extendedContext: true, presetMapping: { default: 'gemini-3.1-pro-preview', @@ -93,7 +195,7 @@ export const MODEL_CATALOGS: Record = { id: 'gemini-3-flash-preview', name: 'Gemini Flash', tier: 'paid', - description: 'Fast Gemini model, requires paid Google account and tracks 3/3.1 Flash IDs', + description: 'Uses the best advertised Gemini Flash preview when Google exposes one', extendedContext: true, presetMapping: { default: 'gemini-3-flash-preview', @@ -566,15 +668,95 @@ export function findCatalogModel(provider: string, modelId: string) { const catalog = MODEL_CATALOGS[provider.toLowerCase()]; if (!catalog) return undefined; - const normalizedModelId = stripModelConfigurationSuffixes(modelId); + const normalizedModelId = normalizeModelId(modelId); const compatibilityModelId = GEMINI_MINOR_VERSION_COMPATIBILITY_IDS[ normalizedModelId.toLowerCase() as keyof typeof GEMINI_MINOR_VERSION_COMPATIBILITY_IDS ]; - return catalog.models.find( + const exactMatch = catalog.models.find( (model) => model.id === normalizedModelId || model.id === compatibilityModelId ); + if (exactMatch) return exactMatch; + + const geminiModelInfo = parseGeminiPreviewModelId(normalizedModelId); + if (!geminiModelInfo) return undefined; + + return catalog.models + .map((model) => ({ model, info: parseGeminiPreviewModelId(model.id) })) + .filter( + ( + candidate + ): candidate is { + model: ModelEntry; + info: GeminiPreviewModelInfo; + } => Boolean(candidate.info && candidate.info.family === geminiModelInfo.family) + ) + .sort((left, right) => compareGeminiVersions(right.info.version, left.info.version))[0]?.model; +} + +export function resolveCatalogModelId( + _provider: string, + modelId: string, + availableModels: CatalogAvailableModel[] = [] +): string { + const normalizedModelId = normalizeModelId(modelId); + const liveGeminiModelId = resolveGeminiPreviewModelId(normalizedModelId, availableModels); + if (liveGeminiModelId) return liveGeminiModelId; + + const exactLiveModelId = findAvailableModelId(availableModels, normalizedModelId); + if (exactLiveModelId) return exactLiveModelId; + + const compatibilityModelId = + GEMINI_MINOR_VERSION_COMPATIBILITY_IDS[ + normalizedModelId as keyof typeof GEMINI_MINOR_VERSION_COMPATIBILITY_IDS + ]; + const compatibleLiveModelId = compatibilityModelId + ? findAvailableModelId(availableModels, compatibilityModelId) + : undefined; + + return compatibleLiveModelId ?? normalizedModelId; +} + +export function resolvePresetMapping( + provider: string, + presetMapping: NonNullable, + availableModels: CatalogAvailableModel[] = [] +) { + return { + default: resolveCatalogModelId(provider, presetMapping.default, availableModels), + opus: resolveCatalogModelId(provider, presetMapping.opus, availableModels), + sonnet: resolveCatalogModelId(provider, presetMapping.sonnet, availableModels), + haiku: resolveCatalogModelId(provider, presetMapping.haiku, availableModels), + }; +} + +export function getResolvedCatalogModels( + catalog: ProviderCatalog | undefined, + availableModels: CatalogAvailableModel[] = [] +) { + if (!catalog) return []; + + const seenModelIds = new Set(); + + return catalog.models + .map((model) => { + const resolvedModelId = resolveCatalogModelId(catalog.provider, model.id, availableModels); + const resolvedPresetModelMapping = model.presetMapping + ? resolvePresetMapping(catalog.provider, model.presetMapping, availableModels) + : undefined; + + return { + ...model, + id: resolvedModelId, + presetMapping: resolvedPresetModelMapping, + }; + }) + .filter((model) => { + if (seenModelIds.has(model.id)) return false; + seenModelIds.add(model.id); + return true; + }); } export function supportsExtendedContext(provider: string, modelId: string): boolean { diff --git a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx index ea31f449..6ecbdf4b 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx @@ -56,8 +56,8 @@ describe('ModelConfigSection presets', () => { savedPresets={[]} currentModel="claude-opus-4-6-thinking" opusModel="claude-opus-4-6-thinking" - sonnetModel="gemini-3-pro-preview" - haikuModel="gemini-3-flash-preview" + sonnetModel="gemini-3.9-pro-preview" + haikuModel="gemini-3-9-flash-preview" providerModels={[]} provider="agy" onExtendedContextToggle={vi.fn()} @@ -71,6 +71,43 @@ describe('ModelConfigSection presets', () => { expect(screen.queryByText('Free Tier')).not.toBeInTheDocument(); expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Gemini Pro' })).toBeInTheDocument(); expect(screen.getByTestId('extended-context-toggle')).toBeInTheDocument(); }); + + it('applies Antigravity Gemini presets using the best live Gemini family ids', async () => { + const onApplyPreset = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Gemini Pro' })); + + expect(onApplyPreset).toHaveBeenCalledWith({ + ANTHROPIC_MODEL: 'gemini-3.9-pro-preview', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3.9-pro-preview', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3.9-pro-preview', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-9-flash-preview', + }); + }); }); diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index ce3c189b..87e4a1b0 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { MODEL_CATALOGS, findCatalogModel } from '@/lib/model-catalogs'; +import { + MODEL_CATALOGS, + findCatalogModel, + getResolvedCatalogModels, + resolveCatalogModelId, +} from '@/lib/model-catalogs'; import { applyDefaultPreset } from '@/lib/preset-utils'; describe('claude preset utils', () => { @@ -46,11 +51,45 @@ describe('claude preset utils', () => { const geminiCatalog = MODEL_CATALOGS.gemini; const latestPro = geminiCatalog.models.find((model) => model.id === 'gemini-3.1-pro-preview'); - expect(latestPro?.name).toBe('Gemini 3.1 Pro'); + expect(latestPro?.name).toBe('Gemini Pro'); expect(latestPro?.presetMapping?.default).toBe('gemini-3.1-pro-preview'); expect(findCatalogModel('gemini', 'gemini-3-pro-preview')?.id).toBe('gemini-3.1-pro-preview'); expect(findCatalogModel('gemini', 'gemini-3.1-flash-preview')?.id).toBe( 'gemini-3-flash-preview' ); }); + + it('resolves Gemini preview presets to the best live family match', () => { + const availableModels = [ + { id: 'gemini-3.9-pro-preview-customtools', owned_by: 'antigravity' }, + { id: 'gemini-3.9-pro-preview', owned_by: 'antigravity' }, + { id: 'gemini-3-9-flash-preview-customtools', owned_by: 'antigravity' }, + { id: 'gemini-3-9-flash-preview', owned_by: 'antigravity' }, + { id: 'gemini-3.1-pro-preview', owned_by: 'antigravity' }, + ]; + + expect(resolveCatalogModelId('agy', 'gemini-3.1-pro-preview', availableModels)).toBe( + 'gemini-3.9-pro-preview' + ); + expect(resolveCatalogModelId('agy', 'gemini-3-flash-preview', availableModels)).toBe( + 'gemini-3-9-flash-preview' + ); + expect(findCatalogModel('agy', 'gemini-3.9-pro-preview')?.id).toBe('gemini-3.1-pro-preview'); + + const resolvedAgyModels = getResolvedCatalogModels(MODEL_CATALOGS.agy, availableModels); + expect(resolvedAgyModels.find((model) => model.name === 'Gemini Pro')?.id).toBe( + 'gemini-3.9-pro-preview' + ); + expect(resolvedAgyModels.find((model) => model.name === 'Gemini Flash')?.id).toBe( + 'gemini-3-9-flash-preview' + ); + }); + + it('does not silently swap Gemini Flash presets to flash-lite', () => { + const availableModels = [{ id: 'gemini-3.1-flash-lite-preview', owned_by: 'google' }]; + + expect(resolveCatalogModelId('gemini', 'gemini-3-flash-preview', availableModels)).toBe( + 'gemini-3-flash-preview' + ); + }); });