fix(cliproxy): resolve gemini presets from live models

This commit is contained in:
Tam Nhu Tran
2026-03-27 17:08:58 -04:00
parent 5ac91e0cac
commit 934e6ab52b
5 changed files with 286 additions and 18 deletions
@@ -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<ModelConfigSectionProps['catalog']>['models'][number];
@@ -62,8 +62,13 @@ export function ModelConfigSection({
.filter((model): model is NonNullable<typeof model> => 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;
@@ -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}`,
+191 -9
View File
@@ -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<string, ProviderCatalog> = {
agy: {
@@ -47,8 +149,8 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
},
{
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<string, ProviderCatalog> = {
{
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<ModelEntry['presetMapping']>,
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<string>();
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 {
@@ -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(
<ModelConfigSection
catalog={MODEL_CATALOGS.agy}
savedPresets={[]}
currentModel="claude-opus-4-6-thinking"
opusModel="claude-opus-4-6-thinking"
sonnetModel="gemini-3.9-pro-preview"
haikuModel="gemini-3-9-flash-preview"
providerModels={[
{ 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' },
]}
provider="agy"
onExtendedContextToggle={vi.fn()}
onApplyPreset={onApplyPreset}
onUpdateEnvValue={vi.fn()}
onOpenCustomPreset={vi.fn()}
onDeletePreset={vi.fn()}
/>
);
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',
});
});
});
+41 -2
View File
@@ -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'
);
});
});