mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-12 20:22:02 +00:00
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:
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
|
||||
@@ -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
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
/* Path alias */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["../src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': path.resolve(REPO_ROOT, './src/shared'),
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': path.resolve(__dirname, '../src/shared'),
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@tests': path.resolve(__dirname, './tests'),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user