From 5ac91e0cac6cb4acc9f26f9461943788d5c43ae6 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 27 Mar 2026 16:39:40 -0400 Subject: [PATCH 1/4] fix(cliproxy): align gemini 3.1 preset compatibility --- src/cliproxy/config/generator.ts | 4 +- src/cliproxy/executor/index.ts | 2 +- src/cliproxy/model-catalog.ts | 32 ++++++++--- src/cliproxy/quota-fetcher-gemini-cli.ts | 9 ++- src/web-server/model-pricing.ts | 4 ++ tests/unit/cliproxy/config-generator.test.js | 2 + tests/unit/cliproxy/model-catalog.test.js | 27 ++++++--- .../cliproxy/quota-fetcher-gemini-cli.test.ts | 17 ++++++ ui/src/lib/model-catalogs.ts | 56 ++++++++++++------- ui/tests/unit/ui/lib/preset-utils.test.ts | 14 ++++- 10 files changed, 125 insertions(+), 42 deletions(-) diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index dd4e65a7..61b565a5 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -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 }, diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index ec772b15..f6028662 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -716,7 +716,7 @@ export async function execClaudeWithCLIProxy( const issueUrl = getModelIssueUrl(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.'); + console.error(' Tool calls will fail. Use "gemini-3.1-pro-preview" instead.'); if (issueUrl) { console.error(` Tracking: ${issueUrl}`); } diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index a9d7970e..729c8a86 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -11,6 +11,14 @@ import { migrateDeniedAntigravityModelAliases, normalizeModelIdForProvider, } from './model-id-normalizer'; +import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils'; + +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', +}); /** * Thinking support configuration for a model. @@ -108,9 +116,9 @@ export const MODEL_CATALOG: Partial> = }, }, { - 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 +130,10 @@ export const MODEL_CATALOG: Partial> = 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, }, @@ -390,7 +398,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 +412,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())); } diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index f8364a91..c8034ebc 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -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'], }, }; diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 935ecccf..9cf381e7 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -718,6 +718,10 @@ const MODEL_PRICING_ALIASES: Record = { 'qwen3-235b': 'qwen3-max', 'qwen3-vl-plus': 'qwen3.5-plus', 'qwen3-32b': 'qwen3.5-plus', + 'gemini-3.1-pro-preview': 'gemini-3-pro-preview', + 'gemini-3.1-pro-preview-customtools': 'gemini-3-pro-preview', + 'gemini-3-1-pro-preview': 'gemini-3-pro-preview', + 'gemini-3-1-pro-preview-customtools': 'gemini-3-pro-preview', }; // Default pricing for unknown models diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 8830bff3..4445c807 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -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) { diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 0b2b47f2..748916dd 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -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,15 @@ 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 } = modelCatalog; + const legacyGemini = findModel('gemini', 'gemini-3-pro-preview'); + const currentGemini = findModel('gemini', 'gemini-3.1-pro-preview'); + + assert.strictEqual(legacyGemini?.id, 'gemini-3.1-pro-preview'); + assert.strictEqual(currentGemini?.id, 'gemini-3.1-pro-preview'); + }); + it('returns undefined for unknown model', () => { const { findModel } = modelCatalog; const model = findModel('agy', 'unknown-model'); @@ -325,7 +334,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'); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index e8cb4e46..66262b3c 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -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 }]; diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 8d9f64a2..317950b2 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -6,6 +6,13 @@ import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; import { stripModelConfigurationSuffixes } from '@/lib/extended-context-utils'; +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', +}); + /** Model catalog data - mirrors src/cliproxy/model-catalog.ts */ export const MODEL_CATALOGS: Record = { agy: { @@ -39,26 +46,26 @@ export const MODEL_CATALOGS: Record = { }, }, { - 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', 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: 'Fast Gemini model via Antigravity with 3/3.1 Flash rollout compatibility', 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 +77,28 @@ export const MODEL_CATALOGS: Record = { 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: 'paid', - description: 'Latest model, requires paid Google account', + description: 'Latest Gemini Pro model, requires paid Google account', 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: 'Fast Gemini model, requires paid Google account and tracks 3/3.1 Flash IDs', 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', }, }, @@ -560,7 +567,14 @@ export function findCatalogModel(provider: string, modelId: string) { if (!catalog) return undefined; const normalizedModelId = stripModelConfigurationSuffixes(modelId); - return catalog.models.find((model) => model.id === normalizedModelId); + const compatibilityModelId = + GEMINI_MINOR_VERSION_COMPATIBILITY_IDS[ + normalizedModelId.toLowerCase() as keyof typeof GEMINI_MINOR_VERSION_COMPATIBILITY_IDS + ]; + + return catalog.models.find( + (model) => model.id === normalizedModelId || model.id === compatibilityModelId + ); } export function supportsExtendedContext(provider: string, modelId: string): boolean { diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index 460c1e1f..ce3c189b 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { MODEL_CATALOGS } from '@/lib/model-catalogs'; +import { MODEL_CATALOGS, findCatalogModel } from '@/lib/model-catalogs'; import { applyDefaultPreset } from '@/lib/preset-utils'; describe('claude preset utils', () => { @@ -41,4 +41,16 @@ 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 3.1 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' + ); + }); }); From 934e6ab52b7bc80dd3876b6fe7c75803f0392d82 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 27 Mar 2026 17:08:58 -0400 Subject: [PATCH 2/4] 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' + ); + }); }); From 2251312411f1e2b8f04844efa4a6ca2a07229a4e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 27 Mar 2026 17:38:31 -0400 Subject: [PATCH 3/4] fix(cliproxy): centralize gemini compatibility and fallback hints --- src/cliproxy/config/thinking-config.ts | 2 +- src/cliproxy/executor/index.ts | 15 ++++++++-- src/cliproxy/model-catalog.ts | 28 ++++++++++++++----- .../gemini-minor-version-compatibility.ts | 10 +++++++ tests/unit/cliproxy/model-catalog.test.js | 8 +++++- ui/src/lib/model-catalogs.ts | 27 ++++++------------ ui/tests/unit/ui/lib/preset-utils.test.ts | 6 ++-- 7 files changed, 63 insertions(+), 33 deletions(-) create mode 100644 src/shared/gemini-minor-version-compatibility.ts diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index 3988ccf7..d9f2b39f 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -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); diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index f6028662..dfef56f5 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -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.1-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}`); } diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 729c8a86..67a0812f 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -12,13 +12,7 @@ import { normalizeModelIdForProvider, } from './model-id-normalizer'; import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils'; - -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', -}); +import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '../shared/gemini-minor-version-compatibility'; /** * Thinking support configuration for a model. @@ -391,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 diff --git a/src/shared/gemini-minor-version-compatibility.ts b/src/shared/gemini-minor-version-compatibility.ts new file mode 100644 index 00000000..19364bd3 --- /dev/null +++ b/src/shared/gemini-minor-version-compatibility.ts @@ -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', +}); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 748916dd..e394ed1a 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -247,12 +247,18 @@ describe('Model Catalog', () => { }); it('treats Gemini 3 and 3.1 preview IDs as the same catalog family', () => { - const { findModel } = modelCatalog; + 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('returns undefined for unknown model', () => { diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 2fa29700..4ce9a65d 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -5,13 +5,7 @@ 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({ - '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', -}); +import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '../../../src/shared/gemini-minor-version-compatibility'; const GEMINI_PREVIEW_MODEL_ID_PATTERN = /^gemini-(\d+(?:[.-]\d+)*)-(pro|flash)-preview(-customtools)?$/i; @@ -695,11 +689,7 @@ export function findCatalogModel(provider: string, modelId: string) { .sort((left, right) => compareGeminiVersions(right.info.version, left.info.version))[0]?.model; } -export function resolveCatalogModelId( - _provider: string, - modelId: string, - availableModels: CatalogAvailableModel[] = [] -): string { +export function resolveCatalogModelId(modelId: string, availableModels: CatalogAvailableModel[] = []): string { const normalizedModelId = normalizeModelId(modelId); const liveGeminiModelId = resolveGeminiPreviewModelId(normalizedModelId, availableModels); if (liveGeminiModelId) return liveGeminiModelId; @@ -719,15 +709,14 @@ export function resolveCatalogModelId( } 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), + default: resolveCatalogModelId(presetMapping.default, availableModels), + opus: resolveCatalogModelId(presetMapping.opus, availableModels), + sonnet: resolveCatalogModelId(presetMapping.sonnet, availableModels), + haiku: resolveCatalogModelId(presetMapping.haiku, availableModels), }; } @@ -741,9 +730,9 @@ export function getResolvedCatalogModels( return catalog.models .map((model) => { - const resolvedModelId = resolveCatalogModelId(catalog.provider, model.id, availableModels); + const resolvedModelId = resolveCatalogModelId(model.id, availableModels); const resolvedPresetModelMapping = model.presetMapping - ? resolvePresetMapping(catalog.provider, model.presetMapping, availableModels) + ? resolvePresetMapping(model.presetMapping, availableModels) : undefined; return { diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index 87e4a1b0..b219f9c6 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -68,10 +68,10 @@ describe('claude preset utils', () => { { id: 'gemini-3.1-pro-preview', owned_by: 'antigravity' }, ]; - expect(resolveCatalogModelId('agy', 'gemini-3.1-pro-preview', availableModels)).toBe( + expect(resolveCatalogModelId('gemini-3.1-pro-preview', availableModels)).toBe( 'gemini-3.9-pro-preview' ); - expect(resolveCatalogModelId('agy', 'gemini-3-flash-preview', availableModels)).toBe( + 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'); @@ -88,7 +88,7 @@ describe('claude preset utils', () => { 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( + expect(resolveCatalogModelId('gemini-3-flash-preview', availableModels)).toBe( 'gemini-3-flash-preview' ); }); From 2423864817bd045e04f76f5905c2e09c6748a687 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 28 Mar 2026 09:54:26 -0400 Subject: [PATCH 4/4] fix(cliproxy): align gemini flash pricing and dashboard imports --- src/web-server/model-pricing.ts | 6 ++++++ tests/unit/cliproxy/model-catalog.test.js | 15 +++++++++++++++ tests/unit/model-pricing.test.ts | 16 ++++++++++++++++ ui/src/lib/model-catalogs.ts | 2 +- ui/tests/unit/ui/lib/preset-utils.test.ts | 13 +++++++++++++ ui/tsconfig.app.json | 3 ++- ui/vite.config.ts | 1 + ui/vitest.config.ts | 1 + 8 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 9cf381e7..d8e40679 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -718,10 +718,16 @@ const MODEL_PRICING_ALIASES: Record = { '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 diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index e394ed1a..4d675641 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -261,6 +261,21 @@ describe('Model Catalog', () => { ); }); + 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'); diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 3b483563..470ced04 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -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'); diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 4ce9a65d..27b78d27 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -5,7 +5,7 @@ import type { ModelEntry, ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; import { stripModelConfigurationSuffixes } from '@/lib/extended-context-utils'; -import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '../../../src/shared/gemini-minor-version-compatibility'; +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; diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index b219f9c6..e4d0c618 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -92,4 +92,17 @@ describe('claude preset utils', () => { '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'); + }); }); diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json index 99fbd39c..a751b0ca 100644 --- a/ui/tsconfig.app.json +++ b/ui/tsconfig.app.json @@ -26,7 +26,8 @@ /* Path alias */ "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@shared/*": ["../src/shared/*"] } }, "include": ["src"] diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 39c8d654..4a31e9f5 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { alias: { + '@shared': path.resolve(REPO_ROOT, './src/shared'), '@': path.resolve(__dirname, './src'), }, }, diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index a0501bb5..0b3f2697 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ }, resolve: { alias: { + '@shared': path.resolve(__dirname, '../src/shared'), '@': path.resolve(__dirname, './src'), '@tests': path.resolve(__dirname, './tests'), },