fix: sanitize cliproxy provider model pickers

This commit is contained in:
Tam Nhu Tran
2026-04-10 18:07:22 -04:00
parent cfdae7be9f
commit 0e4677f558
6 changed files with 327 additions and 45 deletions
+37 -4
View File
@@ -14,6 +14,21 @@ import {
import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils';
import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '../shared/gemini-minor-version-compatibility';
const AGY_GEMINI_PRO_HIGH_ID = 'gemini-3.1-pro-high';
const AGY_GEMINI_PRO_LOW_ID = 'gemini-3.1-pro-low';
const AGY_GEMINI_PRO_COMPATIBILITY_IDS = Object.freeze({
'gemini-3-pro-high': AGY_GEMINI_PRO_HIGH_ID,
'gemini-3.1-pro-high': AGY_GEMINI_PRO_HIGH_ID,
'gemini-3-pro-low': AGY_GEMINI_PRO_LOW_ID,
'gemini-3.1-pro-low': AGY_GEMINI_PRO_LOW_ID,
'gemini-3-pro-preview': AGY_GEMINI_PRO_HIGH_ID,
'gemini-3-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID,
'gemini-3.1-pro-preview': AGY_GEMINI_PRO_HIGH_ID,
'gemini-3.1-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID,
'gemini-3-1-pro-preview': AGY_GEMINI_PRO_HIGH_ID,
'gemini-3-1-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID,
} satisfies Record<string, string>);
/**
* Thinking support configuration for a model.
* Defines how thinking/reasoning budget can be controlled.
@@ -114,11 +129,19 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
},
},
{
id: 'gemini-3.1-pro-preview',
name: 'Gemini 3.1 Pro',
description: 'Google latest Gemini Pro model via Antigravity',
id: AGY_GEMINI_PRO_HIGH_ID,
name: 'Gemini 3.1 Pro High',
description: 'Current Antigravity Gemini Pro route with higher reasoning budget',
nativeImageInput: true,
thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true },
thinking: { type: 'none' },
extendedContext: true,
},
{
id: AGY_GEMINI_PRO_LOW_ID,
name: 'Gemini 3.1 Pro Low',
description: 'Current Antigravity Gemini Pro route with the lighter quota tier',
nativeImageInput: true,
thinking: { type: 'none' },
extendedContext: true,
},
{
@@ -465,6 +488,16 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt
if (compatibilityId) {
lookupCandidates.add(compatibilityId);
}
if (isAntigravityProvider(provider)) {
const agyCompatibilityId =
AGY_GEMINI_PRO_COMPATIBILITY_IDS[
candidate as keyof typeof AGY_GEMINI_PRO_COMPATIBILITY_IDS
];
if (agyCompatibilityId) {
lookupCandidates.add(agyCompatibilityId);
}
}
}
return catalog.models.find((m) => lookupCandidates.has(m.id.toLowerCase()));
+20 -9
View File
@@ -91,11 +91,20 @@ describe('Model Catalog', () => {
assert.strictEqual(ids.includes('claude-sonnet-4-5'), false);
});
it('includes Gemini 3.1 Pro (free via Antigravity)', () => {
it('includes Gemini 3.1 Pro High via Antigravity', () => {
const { MODEL_CATALOG } = modelCatalog;
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');
const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-high');
assert(gem3, 'Should include Gemini 3.1 Pro High');
assert.strictEqual(gem3.name, 'Gemini 3.1 Pro High');
// AGY models are all free - no paid tier
assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier');
});
it('includes Gemini 3.1 Pro Low via Antigravity', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-low');
assert(gem3, 'Should include Gemini 3.1 Pro Low');
assert.strictEqual(gem3.name, 'Gemini 3.1 Pro Low');
// AGY models are all free - no paid tier
assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier');
});
@@ -108,9 +117,9 @@ describe('Model Catalog', () => {
assert.strictEqual(flash.tier, undefined, 'AGY models should not have paid tier');
});
it('has 4 models total', () => {
it('has 5 models total', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.agy.models.length, 4);
assert.strictEqual(MODEL_CATALOG.agy.models.length, 5);
});
});
@@ -262,13 +271,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', () => {
it('maps legacy Antigravity Gemini Pro aliases onto the current 3.1 high/low models', () => {
const { findModel, getSuggestedReplacementModel } = modelCatalog;
const legacyAgyGemini = findModel('agy', 'gemini-3-pro-preview');
const legacyAgyGeminiLow = findModel('agy', 'gemini-3-pro-low');
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(legacyAgyGemini?.id, 'gemini-3.1-pro-high');
assert.strictEqual(legacyAgyGeminiLow?.id, 'gemini-3.1-pro-low');
assert.strictEqual(legacyGemini?.id, 'gemini-3.1-pro-preview');
assert.strictEqual(currentGemini?.id, 'gemini-3.1-pro-preview');
assert.strictEqual(
@@ -371,7 +382,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.1-pro-preview');
const geminiIdx = models.findIndex((m) => m.id === 'gemini-3.1-pro-high');
// Primary Claude choices should appear ahead of Gemini fallback.
assert(opusIdx < geminiIdx, 'Opus should be above Gemini');
@@ -13,7 +13,7 @@ import { SearchableSelect } from '@/components/ui/searchable-select';
import { Skeleton } from '@/components/ui/skeleton';
import type { CliproxyProviderRoutingHints } from '@/lib/api-client';
import { getCodexEffortDisplay } from '@/lib/codex-effort';
import { getResolvedCatalogModels } from '@/lib/model-catalogs';
import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs';
import { cn } from '@/lib/utils';
/** Model entry from catalog */
@@ -329,6 +329,10 @@ export function FlexibleModelSelector({
() => getResolvedCatalogModels(catalog, allModels),
[allModels, catalog]
);
const supplementalModels = useMemo(
() => getSupplementalCatalogModels(catalog?.provider ?? '', catalog, allModels),
[allModels, catalog]
);
const catalogModelIds = new Set(resolvedCatalogModels.map((model) => model.id));
const routingHints = useMemo(
() =>
@@ -388,7 +392,7 @@ export function FlexibleModelSelector({
),
}));
const allModelOptions = allModels
const allModelOptions = supplementalModels
.filter((model) => !catalogModelIds.has(model.id))
.filter(
(model) =>
@@ -495,14 +499,20 @@ export function FlexibleModelSelector({
<span className="text-xs text-primary">{t('providerModelSelector.recommended')}</span>
),
},
{
key: 'all',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.allModelsCount', { count: allModels.length })}
</span>
),
},
...(allModelOptions.length > 0
? [
{
key: 'all',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.allModelsCount', {
count: allModelOptions.length,
})}
</span>
),
},
]
: []),
]}
options={[
...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
+124 -7
View File
@@ -9,6 +9,21 @@ import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '@shared/gemini-minor-ver
const GEMINI_PREVIEW_MODEL_ID_PATTERN =
/^gemini-(\d+(?:[.-]\d+)*)-(pro|flash)-preview(-customtools)?$/i;
const AGY_GEMINI_PRO_HIGH_ID = 'gemini-3.1-pro-high';
const AGY_GEMINI_PRO_LOW_ID = 'gemini-3.1-pro-low';
const AGY_GEMINI_PRO_COMPATIBILITY_IDS = new Map<string, string>([
['gemini-3-pro-high', AGY_GEMINI_PRO_HIGH_ID],
['gemini-3.1-pro-high', AGY_GEMINI_PRO_HIGH_ID],
['gemini-3-pro-low', AGY_GEMINI_PRO_LOW_ID],
['gemini-3.1-pro-low', AGY_GEMINI_PRO_LOW_ID],
['gemini-3-pro-preview', AGY_GEMINI_PRO_HIGH_ID],
['gemini-3-pro-preview-customtools', AGY_GEMINI_PRO_HIGH_ID],
['gemini-3.1-pro-preview', AGY_GEMINI_PRO_HIGH_ID],
['gemini-3.1-pro-preview-customtools', AGY_GEMINI_PRO_HIGH_ID],
['gemini-3-1-pro-preview', AGY_GEMINI_PRO_HIGH_ID],
['gemini-3-1-pro-preview-customtools', AGY_GEMINI_PRO_HIGH_ID],
]);
const MANAGED_MODEL_PREFIXES = ['agy/', 'gcli/'] as const;
export type CatalogAvailableModel = {
id: string;
@@ -29,6 +44,27 @@ function normalizeModelId(modelId: string): string {
return stripModelConfigurationSuffixes(modelId).toLowerCase();
}
function stripManagedModelPrefix(modelId: string): string {
const trimmedModelId = modelId.trim();
const normalizedModelId = trimmedModelId.toLowerCase();
for (const prefix of MANAGED_MODEL_PREFIXES) {
if (normalizedModelId.startsWith(prefix)) {
return trimmedModelId.slice(prefix.length);
}
}
return trimmedModelId;
}
function stripCustomtoolsSuffix(modelId: string): string {
return modelId.replace(/-customtools$/i, '');
}
function getAgyGeminiProCompatibilityId(modelId: string): string | undefined {
return AGY_GEMINI_PRO_COMPATIBILITY_IDS.get(normalizeModelId(modelId));
}
function parseGeminiPreviewModelId(modelId: string): GeminiPreviewModelInfo | null {
const normalizedId = normalizeModelId(modelId);
const match = normalizedId.match(GEMINI_PREVIEW_MODEL_ID_PATTERN);
@@ -142,14 +178,26 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
},
},
{
id: 'gemini-3.1-pro-preview',
name: 'Gemini Pro',
description: 'Resolves to the best advertised Gemini Pro preview via Antigravity',
id: AGY_GEMINI_PRO_HIGH_ID,
name: 'Gemini Pro High',
description: 'Current Antigravity Gemini Pro route with higher reasoning budget',
extendedContext: true,
presetMapping: {
default: 'gemini-3.1-pro-preview',
opus: 'gemini-3.1-pro-preview',
sonnet: 'gemini-3.1-pro-preview',
default: AGY_GEMINI_PRO_HIGH_ID,
opus: AGY_GEMINI_PRO_HIGH_ID,
sonnet: AGY_GEMINI_PRO_HIGH_ID,
haiku: 'gemini-3-1-flash-preview',
},
},
{
id: AGY_GEMINI_PRO_LOW_ID,
name: 'Gemini Pro Low',
description: 'Current Antigravity Gemini Pro route with the lighter quota tier',
extendedContext: true,
presetMapping: {
default: AGY_GEMINI_PRO_LOW_ID,
opus: AGY_GEMINI_PRO_LOW_ID,
sonnet: AGY_GEMINI_PRO_LOW_ID,
haiku: 'gemini-3-1-flash-preview',
},
},
@@ -684,6 +732,13 @@ function findCatalogModelInCatalog(catalog: ProviderCatalog | undefined, modelId
if (!catalog) return undefined;
const normalizedModelId = normalizeModelId(modelId);
if (catalog.provider === 'agy') {
const agyCompatibilityId = getAgyGeminiProCompatibilityId(normalizedModelId);
if (agyCompatibilityId) {
const compatibilityMatch = catalog.models.find((model) => model.id === agyCompatibilityId);
if (compatibilityMatch) return compatibilityMatch;
}
}
const compatibilityModelId =
GEMINI_MINOR_VERSION_COMPATIBILITY_IDS[
normalizedModelId.toLowerCase() as keyof typeof GEMINI_MINOR_VERSION_COMPATIBILITY_IDS
@@ -835,18 +890,24 @@ export function getResolvedCatalogModels(
) {
if (!catalog) return [];
const recommendedCatalog = MODEL_CATALOGS[catalog.provider.toLowerCase()] ?? catalog;
const seenModelIds = new Set<string>();
return catalog.models
return recommendedCatalog.models
.map((model) => {
const resolvedModelId = resolveCatalogModelId(model.id, availableModels);
const resolvedPresetModelMapping = model.presetMapping
? resolvePresetMapping(model.presetMapping, availableModels)
: undefined;
const liveModelMatch = catalog.models.find(
(catalogModel) => normalizeModelId(catalogModel.id) === normalizeModelId(resolvedModelId)
);
return {
...model,
id: resolvedModelId,
name: liveModelMatch?.name || model.name,
description: liveModelMatch?.description ?? model.description,
presetMapping: resolvedPresetModelMapping,
};
})
@@ -857,6 +918,62 @@ export function getResolvedCatalogModels(
});
}
export function getSupplementalCatalogModels(
provider: string,
catalog: ProviderCatalog | undefined,
availableModels: CatalogAvailableModel[] = []
) {
const normalizedProvider = provider.trim().toLowerCase();
if (!catalog || !normalizedProvider) return [];
const staticCatalog = MODEL_CATALOGS[normalizedProvider] ?? catalog;
const recommendedModels = getResolvedCatalogModels(catalog, availableModels);
const recommendedIds = new Set(recommendedModels.map((model) => normalizeModelId(model.id)));
const recommendedCanonicalIds = new Set(
recommendedModels
.map((model) => findCatalogModelInCatalog(staticCatalog, model.id)?.id)
.filter((modelId): modelId is string => Boolean(modelId))
.map((modelId) => normalizeModelId(modelId))
);
const seenCanonicalIds = new Set<string>();
const normalizedAvailableIds = new Set(
availableModels.map((model) => normalizeModelId(stripManagedModelPrefix(model.id)))
);
return availableModels.filter((availableModel) => {
const strippedModelId = stripManagedModelPrefix(availableModel.id);
const baseModelId = stripCustomtoolsSuffix(strippedModelId);
const matchedModel =
findCatalogModelInCatalog(staticCatalog, strippedModelId) ??
findCatalogModelInCatalog(staticCatalog, baseModelId);
if (!matchedModel) return false;
if (recommendedIds.has(normalizeModelId(strippedModelId))) {
return false;
}
const canonicalId = normalizeModelId(matchedModel.id);
if (recommendedCanonicalIds.has(canonicalId)) {
return false;
}
if (seenCanonicalIds.has(canonicalId)) {
return false;
}
const normalizedBaseModelId = normalizeModelId(baseModelId);
if (
normalizedBaseModelId !== normalizeModelId(strippedModelId) &&
normalizedAvailableIds.has(normalizedBaseModelId)
) {
return false;
}
seenCanonicalIds.add(canonicalId);
return true;
});
}
export function supportsExtendedContext(
provider: string,
modelId: string,
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FlexibleModelSelector } from '@/components/cliproxy/provider-model-selector';
import { buildUiCatalogs } from '@/lib/model-catalogs';
import { render, screen, userEvent } from '@tests/setup/test-utils';
const noisyAgyModels = [
{ id: 'agy/gemini-3.1-pro-high', owned_by: 'antigravity' },
{ id: 'gemini-3.1-pro-high', owned_by: 'antigravity' },
{ id: 'gemini-3.1-pro-low', owned_by: 'antigravity' },
{ id: 'gemini-3.1-pro-preview', owned_by: 'antigravity' },
{ id: 'gemini-3.1-pro-preview-customtools', owned_by: 'antigravity' },
{ id: 'gemini-3-pro-preview', owned_by: 'antigravity' },
{ id: 'gemini-3-1-flash-preview', owned_by: 'antigravity' },
{ id: 'gemini-3-1-flash-preview-customtools', owned_by: 'antigravity' },
{ id: 'gpt-oss-120b-medium', owned_by: 'antigravity' },
];
const catalog = buildUiCatalogs({
agy: {
provider: 'agy',
displayName: 'Antigravity',
defaultModel: 'claude-opus-4-6-thinking',
models: noisyAgyModels.map(({ id }) => ({ id, name: id })),
},
}).agy;
describe('FlexibleModelSelector', () => {
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
});
});
it('keeps the picker focused on curated Antigravity live routes', async () => {
render(
<FlexibleModelSelector
label="Primary model"
value={undefined}
onChange={vi.fn()}
catalog={catalog}
allModels={noisyAgyModels}
/>
);
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
expect(screen.getByText('gemini-3.1-pro-high')).toBeInTheDocument();
expect(screen.getByText('gemini-3.1-pro-low')).toBeInTheDocument();
expect(screen.getByText('gemini-3-1-flash-preview')).toBeInTheDocument();
expect(screen.queryByText('gemini-3.1-pro-preview')).not.toBeInTheDocument();
expect(screen.queryByText('gemini-3-pro-preview')).not.toBeInTheDocument();
expect(screen.queryByText('agy/gemini-3.1-pro-high')).not.toBeInTheDocument();
expect(screen.queryByText('gemini-3-1-flash-preview-customtools')).not.toBeInTheDocument();
expect(screen.queryByText('gpt-oss-120b-medium')).not.toBeInTheDocument();
expect(screen.queryByText(/All Models \(/i)).not.toBeInTheDocument();
});
it('preserves a filtered legacy value under the current-value fallback group', async () => {
render(
<FlexibleModelSelector
label="Primary model"
value="gemini-3.1-pro-preview"
onChange={vi.fn()}
catalog={catalog}
allModels={noisyAgyModels}
/>
);
await userEvent.click(screen.getByRole('button', { name: /gemini-3\.1-pro-preview/i }));
expect(screen.getByText('Current value')).toBeInTheDocument();
expect(screen.getAllByText('gemini-3.1-pro-preview').length).toBeGreaterThan(0);
expect(screen.getByText('gemini-3.1-pro-high')).toBeInTheDocument();
});
});
+36 -15
View File
@@ -5,6 +5,7 @@ import {
MODEL_CATALOGS,
findCatalogModel,
getResolvedCatalogModels,
getSupplementalCatalogModels,
resolveCatalogModelId,
} from '@/lib/model-catalogs';
import { applyDefaultPreset } from '@/lib/preset-utils';
@@ -126,29 +127,49 @@ describe('claude preset utils', () => {
);
});
it('resolves Gemini preview presets to the best live family match', () => {
it('maps legacy Antigravity Gemini Pro aliases to the current catalog entries', () => {
expect(findCatalogModel('agy', 'gemini-3.1-pro-preview')?.id).toBe('gemini-3.1-pro-high');
expect(findCatalogModel('agy', 'gemini-3-pro-preview')?.id).toBe('gemini-3.1-pro-high');
expect(findCatalogModel('agy', 'gemini-3-pro-low')?.id).toBe('gemini-3.1-pro-low');
});
it('resolves Antigravity Gemini presets to the current live-safe model ids', () => {
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: 'agy/gemini-3.1-pro-high', owned_by: 'antigravity' },
{ id: 'gemini-3.1-pro-high', owned_by: 'antigravity' },
{ id: 'gemini-3.1-pro-low', owned_by: 'antigravity' },
{ id: 'gemini-3.1-pro-preview', owned_by: 'antigravity' },
{ id: 'gemini-3.1-pro-preview-customtools', owned_by: 'antigravity' },
{ id: 'gemini-3-pro-preview', owned_by: 'antigravity' },
{ id: 'gemini-3-1-flash-preview-customtools', owned_by: 'antigravity' },
{ id: 'gemini-3-1-flash-preview', owned_by: 'antigravity' },
{ id: 'gpt-oss-120b-medium', 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');
expect(findCatalogModel('agy', 'gemini-3.1-pro-preview')?.id).toBe('gemini-3.1-pro-high');
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 Pro High')?.id).toBe(
'gemini-3.1-pro-high'
);
expect(resolvedAgyModels.find((model) => model.name === 'Gemini Pro Low')?.id).toBe(
'gemini-3.1-pro-low'
);
expect(resolvedAgyModels.find((model) => model.name === 'Gemini Flash')?.id).toBe(
'gemini-3-9-flash-preview'
'gemini-3-1-flash-preview'
);
const supplementalAgyModels = getSupplementalCatalogModels(
'agy',
MODEL_CATALOGS.agy,
availableModels
);
expect(supplementalAgyModels.map((model) => model.id)).not.toContain('gpt-oss-120b-medium');
expect(supplementalAgyModels.map((model) => model.id)).not.toContain('gemini-3-pro-preview');
expect(supplementalAgyModels.map((model) => model.id)).not.toContain('gemini-3.1-pro-preview');
expect(supplementalAgyModels.map((model) => model.id)).not.toContain('agy/gemini-3.1-pro-high');
expect(supplementalAgyModels.map((model) => model.id)).not.toContain(
'gemini-3-1-flash-preview-customtools'
);
});