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' ); });