Merge pull request #826 from kaitranntt/kai/fix/gemini-3-1-presets

fix(cliproxy): resolve gemini presets from live models
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-28 15:39:56 -04:00
committed by GitHub
19 changed files with 488 additions and 54 deletions
+3 -1
View File
@@ -35,8 +35,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
* v11: Migrated deprecated claude-sonnet-4-6-thinking aliases to claude-sonnet-4-6
* v12: Removed denylisted Antigravity Claude 4.5 aliases
* v13: Removed aggressive Gemini alias expansion to reduce model list noise in Control Panel
* v14: Added Gemini 3.1 Flash Antigravity aliases for upcoming rollout compatibility
*/
export const CLIPROXY_CONFIG_VERSION = 13;
export const CLIPROXY_CONFIG_VERSION = 14;
interface OAuthModelAliasEntry {
name: string;
@@ -61,6 +62,7 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [
{ name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview' },
{ name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview-customtools' },
{ name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' },
{ name: 'gemini-3-flash', alias: 'gemini-3.1-flash-preview' },
{ name: 'claude-sonnet-4-6', alias: 'claude-sonnet-4-6', fork: true },
// Backward compatibility: legacy sonnet thinking alias now routes to canonical model ID.
{ name: 'claude-sonnet-4-6-thinking', alias: 'claude-sonnet-4-6', fork: true },
+1 -1
View File
@@ -65,7 +65,7 @@ export function detectTierFromModel(modelName: string): ModelTier {
*
* @param model - Base model name
* @param thinkingValue - Level name (e.g., 'high') or numeric budget
* @returns Model name with thinking suffix, e.g., "gemini-3-pro-preview(high)"
* @returns Model name with thinking suffix, e.g., "gemini-3.1-pro-preview(high)"
*/
export function applyThinkingSuffix(model: string, thinkingValue: string | number): string {
return applyThinkingSuffixForProvider(model, thinkingValue);
+13 -2
View File
@@ -34,7 +34,13 @@ import { DEFAULT_BACKEND } from '../platform-detector';
import { configureProviderModel, getCurrentModel } from '../model-config';
import { reconcileCodexModelForActivePlan } from '../codex-plan-compatibility';
import { resolveProxyConfig, PROXY_CLI_FLAGS } from '../proxy-config-resolver';
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from '../model-catalog';
import {
supportsModelConfig,
isModelBroken,
getModelIssueUrl,
findModel,
getSuggestedReplacementModel,
} from '../model-catalog';
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
import {
@@ -714,9 +720,14 @@ export async function execClaudeWithCLIProxy(
if (currentModel && isModelBroken(provider, currentModel)) {
const modelEntry = findModel(provider, currentModel);
const issueUrl = getModelIssueUrl(provider, currentModel);
const replacementModel = getSuggestedReplacementModel(provider, currentModel);
console.error('');
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.');
if (replacementModel) {
console.error(` Tool calls will fail. Use "${replacementModel}" instead.`);
} else {
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
}
if (issueUrl) {
console.error(` Tracking: ${issueUrl}`);
}
+39 -7
View File
@@ -11,6 +11,8 @@ import {
migrateDeniedAntigravityModelAliases,
normalizeModelIdForProvider,
} from './model-id-normalizer';
import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils';
import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '../shared/gemini-minor-version-compatibility';
/**
* Thinking support configuration for a model.
@@ -108,9 +110,9 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
},
},
{
id: 'gemini-3-pro-preview',
name: 'Gemini 3 Pro',
description: 'Google latest model via Antigravity',
id: 'gemini-3.1-pro-preview',
name: 'Gemini 3.1 Pro',
description: 'Google latest Gemini Pro model via Antigravity',
thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true },
extendedContext: true,
},
@@ -122,10 +124,10 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
defaultModel: 'gemini-2.5-pro',
models: [
{
id: 'gemini-3-pro-preview',
name: 'Gemini 3 Pro',
id: 'gemini-3.1-pro-preview',
name: 'Gemini 3.1 Pro',
tier: 'pro',
description: 'Latest model, requires paid Google account',
description: 'Latest Gemini Pro model, requires paid Google account',
thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true },
extendedContext: true,
},
@@ -383,6 +385,26 @@ export function getProviderCatalog(provider: CLIProxyProvider): ProviderCatalog
return MODEL_CATALOG[provider];
}
/**
* Suggest a supported replacement model from the provider catalog.
* Prefers the provider default unless it matches the excluded model or is itself broken.
*/
export function getSuggestedReplacementModel(
provider: CLIProxyProvider,
excludedModelId?: string
): string | undefined {
const catalog = MODEL_CATALOG[provider];
if (!catalog) return undefined;
const excludedId = excludedModelId ? findModel(provider, excludedModelId)?.id : undefined;
const defaultModel = findModel(provider, catalog.defaultModel);
if (defaultModel && !defaultModel.broken && defaultModel.id !== excludedId) {
return defaultModel.id;
}
return catalog.models.find((model) => !model.broken && model.id !== excludedId)?.id;
}
/**
* Find model entry by ID
* Note: Model IDs are normalized to lowercase for case-insensitive comparison
@@ -390,7 +412,7 @@ export function getProviderCatalog(provider: CLIProxyProvider): ProviderCatalog
export function findModel(provider: CLIProxyProvider, modelId: string): ModelEntry | undefined {
const catalog = MODEL_CATALOG[provider];
if (!catalog || !modelId) return undefined;
const normalizedId = modelId.trim().toLowerCase();
const normalizedId = stripModelConfigurationSuffixes(modelId).trim().toLowerCase();
const providerNormalizedId = normalizeModelIdForProvider(normalizedId, provider)
.trim()
.toLowerCase();
@@ -404,6 +426,16 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt
lookupCandidates.add(migratedProvider);
}
for (const candidate of [...lookupCandidates]) {
const compatibilityId =
GEMINI_MINOR_VERSION_COMPATIBILITY_IDS[
candidate as keyof typeof GEMINI_MINOR_VERSION_COMPATIBILITY_IDS
];
if (compatibilityId) {
lookupCandidates.add(compatibilityId);
}
}
return catalog.models.find((m) => lookupCandidates.has(m.id.toLowerCase()));
}
+7 -2
View File
@@ -32,11 +32,16 @@ const GEMINI_CLI_GROUPS: Record<
> = {
'gemini-flash-series': {
label: 'Gemini Flash Series',
models: ['gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'],
models: [
'gemini-3-flash-preview',
'gemini-3.1-flash-preview',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
],
},
'gemini-pro-series': {
label: 'Gemini Pro Series',
models: ['gemini-3-pro-preview', 'gemini-2.5-pro'],
models: ['gemini-3-pro-preview', 'gemini-3.1-pro-preview', 'gemini-2.5-pro'],
},
};
@@ -0,0 +1,10 @@
/**
* Shared Gemini preview aliases for minor-version rollouts.
* Keep CLIProxy backend and dashboard model resolution on the same compatibility pairs.
*/
export const GEMINI_MINOR_VERSION_COMPATIBILITY_IDS = Object.freeze({
'gemini-3-pro-preview': 'gemini-3.1-pro-preview',
'gemini-3.1-pro-preview': 'gemini-3-pro-preview',
'gemini-3-flash-preview': 'gemini-3.1-flash-preview',
'gemini-3.1-flash-preview': 'gemini-3-flash-preview',
});
+10
View File
@@ -718,6 +718,16 @@ const MODEL_PRICING_ALIASES: Record<string, string> = {
'qwen3-235b': 'qwen3-max',
'qwen3-vl-plus': 'qwen3.5-plus',
'qwen3-32b': 'qwen3.5-plus',
'gemini-3-flash-preview': 'gemini-2.5-flash',
'gemini-3-flash-preview-customtools': 'gemini-2.5-flash',
'gemini-3.1-pro-preview': 'gemini-3-pro-preview',
'gemini-3.1-flash-preview': 'gemini-2.5-flash',
'gemini-3.1-pro-preview-customtools': 'gemini-3-pro-preview',
'gemini-3.1-flash-preview-customtools': 'gemini-2.5-flash',
'gemini-3-1-pro-preview': 'gemini-3-pro-preview',
'gemini-3-1-flash-preview': 'gemini-2.5-flash',
'gemini-3-1-pro-preview-customtools': 'gemini-3-pro-preview',
'gemini-3-1-flash-preview-customtools': 'gemini-2.5-flash',
};
// Default pricing for unknown models
@@ -640,6 +640,8 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
const gemini31AliasLines = [
'alias: gemini-3.1-pro-preview',
'alias: gemini-3.1-pro-preview-customtools',
'alias: gemini-3.1-flash-preview',
'alias: gemini-3.1-flash-preview-customtools',
];
for (const aliasLine of gemini31AliasLines) {
+39 -9
View File
@@ -91,11 +91,11 @@ describe('Model Catalog', () => {
assert.strictEqual(ids.includes('claude-sonnet-4-5'), false);
});
it('includes Gemini 3 Pro (free via Antigravity)', () => {
it('includes Gemini 3.1 Pro (free via Antigravity)', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-pro-preview');
assert(gem3, 'Should include Gemini 3 Pro');
assert.strictEqual(gem3.name, 'Gemini 3 Pro');
const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-preview');
assert(gem3, 'Should include Gemini 3.1 Pro');
assert.strictEqual(gem3.name, 'Gemini 3.1 Pro');
// AGY models are all free - no paid tier
assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier');
});
@@ -139,11 +139,11 @@ describe('Model Catalog', () => {
assert.strictEqual(MODEL_CATALOG.gemini.defaultModel, 'gemini-2.5-pro');
});
it('includes Gemini 3 Pro with pro tier', () => {
it('includes Gemini 3.1 Pro with pro tier', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem3 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-pro-preview');
assert(gem3, 'Should include Gemini 3 Pro');
assert.strictEqual(gem3.name, 'Gemini 3 Pro');
const gem3 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3.1-pro-preview');
assert(gem3, 'Should include Gemini 3.1 Pro');
assert.strictEqual(gem3.name, 'Gemini 3.1 Pro');
assert.strictEqual(gem3.tier, 'pro');
});
@@ -246,6 +246,36 @@ describe('Model Catalog', () => {
assert.strictEqual(legacySonnet?.id, 'claude-sonnet-4-6');
});
it('treats Gemini 3 and 3.1 preview IDs as the same catalog family', () => {
const { findModel, getSuggestedReplacementModel } = modelCatalog;
const legacyAgyGemini = findModel('agy', 'gemini-3-pro-preview');
const legacyGemini = findModel('gemini', 'gemini-3-pro-preview');
const currentGemini = findModel('gemini', 'gemini-3.1-pro-preview');
assert.strictEqual(legacyAgyGemini?.id, 'gemini-3.1-pro-preview');
assert.strictEqual(legacyGemini?.id, 'gemini-3.1-pro-preview');
assert.strictEqual(currentGemini?.id, 'gemini-3.1-pro-preview');
assert.strictEqual(
getSuggestedReplacementModel('gemini', 'gemini-3.1-pro-preview'),
'gemini-2.5-pro'
);
});
it('falls back to the next supported model when the default is excluded', () => {
const { getSuggestedReplacementModel } = modelCatalog;
expect(getSuggestedReplacementModel('agy', 'claude-opus-4-6-thinking')).toBe(
'claude-sonnet-4-6'
);
expect(getSuggestedReplacementModel('agy')).toBe('claude-opus-4-6-thinking');
});
it('returns undefined when no provider catalog exists', () => {
const { getSuggestedReplacementModel } = modelCatalog;
expect(getSuggestedReplacementModel('qwen')).toBeUndefined();
});
it('returns undefined for unknown model', () => {
const { findModel } = modelCatalog;
const model = findModel('agy', 'unknown-model');
@@ -325,7 +355,7 @@ describe('Model Catalog', () => {
const sonnetThinkingIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-6');
// Find indices of the remaining non-Claude model
const geminiIdx = models.findIndex((m) => m.id === 'gemini-3-pro-preview');
const geminiIdx = models.findIndex((m) => m.id === 'gemini-3.1-pro-preview');
// Primary Claude choices should appear ahead of Gemini fallback.
assert(opusIdx < geminiIdx, 'Opus should be above Gemini');
@@ -168,6 +168,23 @@ describe('Gemini CLI Quota Fetcher', () => {
expect(proBucket!.remainingFraction).toBe(0.9);
});
it('should recognize Gemini 3.1 preview IDs during the rollout', () => {
const rawBuckets = [
{ model_id: 'gemini-3.1-flash-preview', remaining_fraction: 0.7 },
{ model_id: 'gemini-3.1-pro-preview', remaining_fraction: 0.4 },
];
const buckets = buildGeminiCliBuckets(rawBuckets);
const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series');
const proBucket = buckets.find((b) => b.label === 'Gemini Pro Series');
expect(flashBucket).toBeDefined();
expect(flashBucket!.modelIds).toContain('gemini-3.1-flash-preview');
expect(proBucket).toBeDefined();
expect(proBucket!.modelIds).toContain('gemini-3.1-pro-preview');
});
it('should handle camelCase API response', () => {
const rawBuckets = [{ modelId: 'gemini-3-flash-preview', remainingFraction: 0.75 }];
+16
View File
@@ -76,6 +76,22 @@ describe('model-pricing', () => {
expect(pricing).not.toEqual(getModelPricing('unknown-model-xyz'));
});
it('should map Gemini 3 and 3.1 Flash preview variants to flash pricing', () => {
const canonical = getModelPricing('gemini-2.5-flash');
const aliases = [
'gemini-3-flash-preview',
'gemini-3-flash-preview-customtools',
'gemini-3.1-flash-preview',
'gemini-3.1-flash-preview-customtools',
'gemini-3-1-flash-preview',
'gemini-3-1-flash-preview-customtools',
];
for (const model of aliases) {
expect(getModelPricing(model)).toEqual(canonical);
}
});
it('should return different pricing for different model tiers', () => {
const sonnet = getModelPricing('claude-sonnet-4-5');
const opus = getModelPricing('claude-opus-4-5-20251101');
@@ -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}`,
+208 -23
View File
@@ -3,8 +3,111 @@
* 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';
import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '@shared/gemini-minor-version-compatibility';
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> = {
@@ -39,26 +142,26 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
},
},
{
id: 'gemini-3-pro-preview',
name: 'Gemini 3 Pro',
description: 'Google latest model via Antigravity',
id: 'gemini-3.1-pro-preview',
name: 'Gemini Pro',
description: 'Resolves to the best advertised Gemini Pro preview via Antigravity',
extendedContext: true,
presetMapping: {
default: 'gemini-3-pro-preview',
opus: 'gemini-3-pro-preview',
sonnet: 'gemini-3-pro-preview',
default: 'gemini-3.1-pro-preview',
opus: 'gemini-3.1-pro-preview',
sonnet: 'gemini-3.1-pro-preview',
haiku: 'gemini-3-flash-preview',
},
},
{
id: 'gemini-3-flash-preview',
name: 'Gemini 3 Flash',
description: 'Fast Gemini model via Antigravity',
name: 'Gemini Flash',
description: 'Resolves to the best advertised Gemini Flash preview via Antigravity',
extendedContext: true,
presetMapping: {
default: 'gemini-3-flash-preview',
opus: 'gemini-3-pro-preview',
sonnet: 'gemini-3-pro-preview',
opus: 'gemini-3.1-pro-preview',
sonnet: 'gemini-3.1-pro-preview',
haiku: 'gemini-3-flash-preview',
},
},
@@ -70,28 +173,28 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
defaultModel: 'gemini-2.5-pro',
models: [
{
id: 'gemini-3-pro-preview',
name: 'Gemini 3 Pro',
id: 'gemini-3.1-pro-preview',
name: 'Gemini Pro',
tier: 'paid',
description: 'Latest model, requires paid Google account',
description: 'Uses the best advertised Gemini Pro preview when Google exposes one',
extendedContext: true,
presetMapping: {
default: 'gemini-3-pro-preview',
opus: 'gemini-3-pro-preview',
sonnet: 'gemini-3-pro-preview',
default: 'gemini-3.1-pro-preview',
opus: 'gemini-3.1-pro-preview',
sonnet: 'gemini-3.1-pro-preview',
haiku: 'gemini-3-flash-preview',
},
},
{
id: 'gemini-3-flash-preview',
name: 'Gemini 3 Flash',
name: 'Gemini Flash',
tier: 'paid',
description: 'Fast Gemini 3 model, requires paid Google account',
description: 'Uses the best advertised Gemini Flash preview when Google exposes one',
extendedContext: true,
presetMapping: {
default: 'gemini-3-flash-preview',
opus: 'gemini-3-pro-preview',
sonnet: 'gemini-3-pro-preview',
opus: 'gemini-3.1-pro-preview',
sonnet: 'gemini-3.1-pro-preview',
haiku: 'gemini-3-flash-preview',
},
},
@@ -559,8 +662,90 @@ export function findCatalogModel(provider: string, modelId: string) {
const catalog = MODEL_CATALOGS[provider.toLowerCase()];
if (!catalog) return undefined;
const normalizedModelId = stripModelConfigurationSuffixes(modelId);
return catalog.models.find((model) => model.id === normalizedModelId);
const normalizedModelId = normalizeModelId(modelId);
const compatibilityModelId =
GEMINI_MINOR_VERSION_COMPATIBILITY_IDS[
normalizedModelId.toLowerCase() as keyof typeof GEMINI_MINOR_VERSION_COMPATIBILITY_IDS
];
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(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(
presetMapping: NonNullable<ModelEntry['presetMapping']>,
availableModels: CatalogAvailableModel[] = []
) {
return {
default: resolveCatalogModelId(presetMapping.default, availableModels),
opus: resolveCatalogModelId(presetMapping.opus, availableModels),
sonnet: resolveCatalogModelId(presetMapping.sonnet, availableModels),
haiku: resolveCatalogModelId(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(model.id, availableModels);
const resolvedPresetModelMapping = model.presetMapping
? resolvePresetMapping(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',
});
});
});
+65 -1
View File
@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
import {
MODEL_CATALOGS,
findCatalogModel,
getResolvedCatalogModels,
resolveCatalogModelId,
} from '@/lib/model-catalogs';
import { applyDefaultPreset } from '@/lib/preset-utils';
describe('claude preset utils', () => {
@@ -41,4 +46,63 @@ describe('claude preset utils', () => {
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
});
});
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');
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('gemini-3.1-pro-preview', availableModels)).toBe(
'gemini-3.9-pro-preview'
);
expect(resolveCatalogModelId('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-3-flash-preview', availableModels)).toBe(
'gemini-3-flash-preview'
);
});
it('passes through non-Gemini model ids unchanged', () => {
expect(resolveCatalogModelId('claude-sonnet-4-6')).toBe('claude-sonnet-4-6');
});
it('falls back to the catalog id when no live model matches', () => {
expect(resolveCatalogModelId('gemini-3.1-pro-preview', [])).toBe('gemini-3.1-pro-preview');
expect(
resolveCatalogModelId('gemini-3.1-pro-preview', [
{ id: 'gemini-2.5-pro', owned_by: 'google' },
])
).toBe('gemini-3.1-pro-preview');
});
});
+2 -1
View File
@@ -26,7 +26,8 @@
/* Path alias */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@shared/*": ["../src/shared/*"]
}
},
"include": ["src"]
+1
View File
@@ -11,6 +11,7 @@ export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@shared': path.resolve(REPO_ROOT, './src/shared'),
'@': path.resolve(__dirname, './src'),
},
},
+1
View File
@@ -45,6 +45,7 @@ export default defineConfig({
},
resolve: {
alias: {
'@shared': path.resolve(__dirname, '../src/shared'),
'@': path.resolve(__dirname, './src'),
'@tests': path.resolve(__dirname, './tests'),
},