diff --git a/src/lib/antigravity/ClaudeRequestMapper.ts b/src/lib/antigravity/ClaudeRequestMapper.ts index 23a1bee..880240b 100644 --- a/src/lib/antigravity/ClaudeRequestMapper.ts +++ b/src/lib/antigravity/ClaudeRequestMapper.ts @@ -1,6 +1,8 @@ import { v4 as uuidv4 } from 'uuid'; -import { mapClaudeModelToGemini } from './ModelMapping'; -import { cleanJsonSchema } from './JsonSchemaUtils'; +import { isPlainObject, isString } from 'lodash-es'; +import { mapClaudeModelToGemini, normalizeGeminiModelAlias } from './ModelMapping'; +import { getMaxOutputTokens, getThinkingBudget } from './ModelSpecs'; +import { cleanJsonSchema, normalizeObjectJsonSchema } from './JsonSchemaUtils'; import { SignatureStore } from './SignatureStore'; import { logger } from '../../utils/logger'; import { @@ -15,6 +17,11 @@ import { FunctionDeclaration, SafetySetting, } from './types'; +import { + buildUserAgent, + FALLBACK_VERSION, + resolveLocalInstalledVersion, +} from '@/server/modules/proxy/request-user-agent'; /** * Request Configuration @@ -44,6 +51,7 @@ interface RequestConfig { export function transformClaudeRequestIn( claudeReq: ClaudeRequest, projectId?: string, + userAgent?: string, ): GeminiInternalRequest { // Check for networking tools (server tool or built-in tool) const hasWebSearchTool = detectsNetworkingTool(claudeReq.tools); @@ -55,9 +63,7 @@ export function transformClaudeRequestIn( const systemInstruction = buildSystemInstruction(claudeReq.system); // Map model name - const mappedModel = hasWebSearchTool - ? 'gemini-2.5-flash' - : mapClaudeModelToGemini(claudeReq.model); + const mappedModel = hasWebSearchTool ? 'gemini-3-flash' : mapClaudeModelToGemini(claudeReq.model); // Convert Claude tools to Tool array for networking detection const toolsVal: Tool[] | undefined = claudeReq.tools @@ -70,7 +76,11 @@ export function transformClaudeRequestIn( const allowDummyThought = config.finalModel.startsWith('gemini-'); // 4. Generation Config & Thinking - let isThinkingEnabled = claudeReq.thinking?.type === 'enabled'; + const thinkingType = (claudeReq.thinking?.type ?? '').toLowerCase(); + const autoThinkingEnabled = + !claudeReq.thinking && shouldEnableThinkingByDefault(config.finalModel, claudeReq.model); + let isThinkingEnabled = + thinkingType === 'enabled' || thinkingType === 'adaptive' || autoThinkingEnabled; if (isThinkingEnabled) { const globalSig = SignatureStore.get(); @@ -82,11 +92,18 @@ export function transformClaudeRequestIn( }); if (hasFunctionCalls && !hasValidSignatureForFunctionCalls(claudeReq.messages, globalSig)) { - isThinkingEnabled = false; + if (!isGeminiFlashModel(config.finalModel)) { + isThinkingEnabled = false; + } } } - const generationConfig = buildGenerationConfig(claudeReq, hasWebSearchTool, config.finalModel); + const generationConfig = buildGenerationConfig( + claudeReq, + hasWebSearchTool, + config.finalModel, + isThinkingEnabled, + ); // Update thinking config based on the final decision if (!isThinkingEnabled && generationConfig.thinkingConfig) { delete generationConfig.thinkingConfig; @@ -98,10 +115,11 @@ export function transformClaudeRequestIn( toolIdToName, isThinkingEnabled, allowDummyThought, + config.finalModel, ); // 3. Tools - const tools = buildTools(claudeReq.tools, hasWebSearchTool); + const tools = buildTools(claudeReq.tools, hasWebSearchTool, config.finalModel); // 5. Safety Settings const safetySettings = [ @@ -142,7 +160,7 @@ export function transformClaudeRequestIn( // Inject googleSearch tool if needed (and not already done by buildTools) if (config.injectGoogleSearch && !hasWebSearchTool) { - injectGoogleSearchTool(innerRequest); + injectGoogleSearchTool(innerRequest, config.finalModel); } // Inject imageConfig if present (for image generation models) @@ -165,11 +183,12 @@ export function transformClaudeRequestIn( const normalizedProjectId = projectId?.trim(); + const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION; const body: GeminiInternalRequest = { requestId: requestId, request: innerRequest as GeminiInternalRequest['request'], model: config.finalModel, - userAgent: 'antigravity', + userAgent: userAgent?.trim() || buildUserAgent(discoveryVersion), requestType: config.requestType, }; @@ -194,7 +213,7 @@ function resolveRequestConfig( tools?: Tool[], ): RequestConfig { // 1. Image Generation Check - if (mappedModel.startsWith('gemini-3-pro-image')) { + if (isGeminiImageModel(mappedModel)) { const { imageConfig, parsedBaseModel } = parseImageConfig(originalModel); return { requestType: 'image_gen', @@ -212,18 +231,11 @@ function resolveRequestConfig( const enableNetworking = isOnlineSuffix || hasNetworkingTool; let finalModel = mappedModel.replace(/-online$/, ''); - - if (finalModel === 'gemini-3-pro-preview') { - finalModel = 'gemini-3-pro-high'; - } else if (finalModel === 'gemini-3-pro-image-preview') { - finalModel = 'gemini-3-pro-image'; - } else if (finalModel === 'gemini-3-flash-preview') { - finalModel = 'gemini-3-flash'; - } + finalModel = normalizeGeminiModelAlias(finalModel); if (enableNetworking) { - if (finalModel !== 'gemini-2.5-flash') { - finalModel = 'gemini-2.5-flash'; + if (finalModel !== 'gemini-3-flash') { + finalModel = 'gemini-3-flash'; } } @@ -260,6 +272,50 @@ function parseImageConfig(modelName: string): { return { imageConfig: config, parsedBaseModel: 'gemini-3-pro-image' }; } +function isGeminiImageModel(modelName: string): boolean { + const normalized = modelName.toLowerCase(); + return ( + normalized.startsWith('gemini-3-pro-image') || normalized.startsWith('gemini-3.1-pro-image') + ); +} + +function isGeminiFlashModel(modelName: string): boolean { + const normalized = modelName.toLowerCase(); + return normalized.includes('gemini-3-flash') || normalized.includes('gemini-3.1-flash'); +} + +function shouldEnableThinkingByDefault(mappedModel: string, originalModel: string): boolean { + const mappedLower = mappedModel.toLowerCase(); + const originalLower = originalModel.toLowerCase(); + return ( + originalLower.includes('claude-opus-4-5') || + originalLower.includes('claude-opus-4-6') || + mappedLower.includes('-thinking') || + mappedLower.includes('gemini-3.1-pro') || + mappedLower.includes('gemini-3-flash') || + mappedLower.includes('gemini-3.1-flash') + ); +} + +function isClaudeModel(modelName: string): boolean { + return modelName.toLowerCase().includes('claude'); +} + +function resolveAdaptiveThinkingLevel(claudeReq: ClaudeRequest): 'low' | 'medium' | 'high' { + const effort = String(claudeReq.thinking?.effort ?? '').toLowerCase(); + if (effort === 'low') { + return 'low'; + } + if (effort === 'medium') { + return 'medium'; + } + return 'high'; +} + +function toToolSchema(schema: unknown): Record { + return normalizeObjectJsonSchema(schema); +} + /** * Detects if networking tools are present * Checks tool list for web search related tools @@ -274,20 +330,23 @@ function detectsNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolea 'google_search', 'web_search_20250305', 'google_search_retrieval', + 'builtin_web_search', ]; for (const tool of tools) { // Claude Tool format - if ('name' in tool && tool.name && keywords.includes(tool.name)) { + const toolName = (tool as { name?: unknown }).name; + if (isString(toolName) && keywords.includes(toolName)) { return true; } - if ('type' in tool && tool.type && keywords.includes(tool.type)) { + const toolType = (tool as { type?: unknown }).type; + if (isString(toolType) && keywords.includes(toolType)) { return true; } // OpenAI nested format (runtime check) const openaiTool = tool as { function?: { name?: string } }; - if (openaiTool.function?.name && keywords.includes(openaiTool.function.name)) { + if (isString(openaiTool.function?.name) && keywords.includes(openaiTool.function.name)) { return true; } @@ -315,15 +374,25 @@ function detectsNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolea * Inject Google Search Tool * Adds googleSearch tool to the request */ -function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) { +function supportsMixedTools(mappedModel?: string): boolean { + if (!mappedModel) { + return false; + } + const modelLower = mappedModel.toLowerCase(); + return modelLower.includes('gemini-3'); +} + +function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }, mappedModel?: string) { if (!body.tools) { body.tools = []; } const toolsArr = body.tools; const hasFunctions = toolsArr.some((t) => t.functionDeclarations); - if (hasFunctions) { - logger.info('Skipping googleSearch injection due to existing functionDeclarations'); + if (hasFunctions && !supportsMixedTools(mappedModel)) { + logger.info( + 'Skipping googleSearch injection due to existing functionDeclarations on old model', + ); return; } @@ -351,7 +420,7 @@ function buildSystemInstruction( let hasIdentityDirective = false; if (system) { - if (typeof system === 'string') { + if (isString(system)) { if (system.includes(identityMarker)) { hasIdentityDirective = true; } @@ -370,7 +439,7 @@ function buildSystemInstruction( } if (system) { - if (typeof system === 'string') { + if (isString(system)) { parts.push({ text: system }); } else if (Array.isArray(system)) { for (const block of system) { @@ -437,6 +506,7 @@ function buildContents( toolIdToName: Map, isThinkingEnabled: boolean, allowDummyThought: boolean, + mappedModel: string, ): GeminiContent[] { const contents: GeminiContent[] = []; let lastThoughtSignature: string | null = null; @@ -480,12 +550,16 @@ function buildContents( cleanJsonSchema(part); toolIdToName.set(block.id, block.name); const finalSig = block.signature || lastThoughtSignature || SignatureStore.get(); - if (finalSig) part.thoughtSignature = finalSig; + if (finalSig) { + part.thoughtSignature = finalSig; + } else if (isThinkingEnabled && isGeminiFlashModel(mappedModel)) { + part.thoughtSignature = 'skip_thought_signature_validator'; + } parts.push(part); } else if (block.type === 'tool_result') { const funcName = toolIdToName.get(block.tool_use_id) || block.tool_use_id; let mergedContent = ''; - if (typeof block.content === 'string') mergedContent = block.content; + if (isString(block.content)) mergedContent = block.content; else if (Array.isArray(block.content)) mergedContent = block.content .filter((b: any) => b.type === 'text') @@ -524,6 +598,7 @@ function buildContents( function buildTools( tools: Tool[] | undefined, hasWebSearch: boolean, + mappedModel: string, ): GeminiToolDeclaration[] | null { if (!tools) { return null; @@ -535,14 +610,15 @@ function buildTools( if ( tool.name === 'web_search' || tool.name === 'google_search' || - tool.type === 'web_search_20250305' + tool.name === 'builtin_web_search' || + tool.type === 'web_search_20250305' || + tool.type === 'builtin_web_search' ) { hasGoogleSearch = true; continue; } if (tool.name) { - const inputSchema = tool.input_schema || { type: 'object', properties: {} }; - cleanJsonSchema(inputSchema); + const inputSchema = toToolSchema(tool.input_schema); functionDeclarations.push({ name: tool.name, description: tool.description, @@ -551,15 +627,24 @@ function buildTools( } } - const toolObj: GeminiToolDeclaration = {}; + const toolList: GeminiToolDeclaration[] = []; if (functionDeclarations.length > 0) { - toolObj.functionDeclarations = functionDeclarations; + toolList.push({ functionDeclarations }); + if (hasGoogleSearch) { + if (supportsMixedTools(mappedModel)) { + toolList.push({ googleSearch: {} }); + } else { + logger.info( + `[Claude-Request] Skipping googleSearch injection for ${mappedModel} due to existing functionDeclarations on old model`, + ); + } + } } else if (hasGoogleSearch) { - toolObj.googleSearch = {}; + toolList.push({ googleSearch: {} }); } - if (Object.keys(toolObj).length > 0) { - return [toolObj]; + if (toolList.length > 0) { + return toolList; } return null; } @@ -572,32 +657,53 @@ function buildGenerationConfig( claudeReq: ClaudeRequest, hasWebSearch: boolean, mappedModel: string, + isThinkingEnabled: boolean, ): GenerationConfig { const source = String(claudeReq.metadata?.source || '').toLowerCase(); const isOpenAIPath = source === 'openai'; const config: GenerationConfig = {}; + const thinkingType = String(claudeReq.thinking?.type ?? '').toLowerCase(); + + const buildThinkingConfig = (): GenerationConfig['thinkingConfig'] => { + const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true }; + if (thinkingType === 'adaptive') { + if (isClaudeModel(mappedModel)) { + thinkingConfig.thinkingLevel = resolveAdaptiveThinkingLevel(claudeReq); + } else { + thinkingConfig.thinkingBudget = 24576; + } + } else if (claudeReq.thinking?.budget_tokens) { + let budget = claudeReq.thinking.budget_tokens; + const isFlash = hasWebSearch || isGeminiFlashModel(mappedModel); + if (isFlash) { + budget = Math.min(budget, 24576); + } + thinkingConfig.thinkingBudget = budget; + } else { + thinkingConfig.thinkingBudget = getThinkingBudget(mappedModel); + } + return thinkingConfig; + }; if (isOpenAIPath) { config.temperature = claudeReq.temperature ?? 1.0; config.topP = claudeReq.top_p ?? 0.95; if (claudeReq.max_tokens !== undefined) { config.maxOutputTokens = claudeReq.max_tokens; + } else { + config.maxOutputTokens = getMaxOutputTokens(mappedModel); } if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) { config.stopSequences = claudeReq.stop_sequences; } + if (isThinkingEnabled) { + config.thinkingConfig = buildThinkingConfig(); + } return config; } - if (claudeReq.thinking?.type === 'enabled') { - const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true }; - if (claudeReq.thinking.budget_tokens) { - let budget = claudeReq.thinking.budget_tokens; - const isFlash = hasWebSearch || mappedModel.includes('gemini-2.5-flash'); - if (isFlash) budget = Math.min(budget, 24576); - thinkingConfig.thinkingBudget = budget; - } - config.thinkingConfig = thinkingConfig; + if (isThinkingEnabled) { + config.thinkingConfig = buildThinkingConfig(); } if (claudeReq.temperature !== undefined) { config.temperature = claudeReq.temperature; @@ -623,7 +729,7 @@ function buildGenerationConfig( function deepCleanUndefined(obj: unknown): void { if (Array.isArray(obj)) { obj.forEach(deepCleanUndefined); - } else if (obj && typeof obj === 'object') { + } else if (isPlainObject(obj)) { const record = obj as Record; Object.keys(record).forEach((key) => { if (record[key] === undefined) delete record[key]; diff --git a/src/lib/antigravity/ClaudeResponseMapper.ts b/src/lib/antigravity/ClaudeResponseMapper.ts index 6f1f0d6..bd16192 100644 --- a/src/lib/antigravity/ClaudeResponseMapper.ts +++ b/src/lib/antigravity/ClaudeResponseMapper.ts @@ -165,21 +165,21 @@ class NonStreamingProcessor { let groundingText = ''; if (grounding.webSearchQueries && grounding.webSearchQueries.length > 0) { - groundingText += `\n\n---\n**🔍 已为您搜索:** ${grounding.webSearchQueries.join(', ')}`; + groundingText += `\n\n---\n**🔍 Searched for you:** ${grounding.webSearchQueries.join(', ')}`; } if (grounding.groundingChunks) { const links: string[] = []; grounding.groundingChunks.forEach((chunk, index) => { if (chunk.web) { - const title = chunk.web.title || '网页来源'; + const title = chunk.web.title || 'Web source'; const uri = chunk.web.uri || '#'; links.push(`[${index + 1}] [${title}](${uri})`); } }); if (links.length > 0) { - groundingText += `\n\n**🌐 来源引文:**\n` + links.join('\n'); + groundingText += `\n\n**🌐 Citations:**\n` + links.join('\n'); } } diff --git a/src/lib/antigravity/ClaudeStreamingMapper.ts b/src/lib/antigravity/ClaudeStreamingMapper.ts index ec6b876..f506099 100644 --- a/src/lib/antigravity/ClaudeStreamingMapper.ts +++ b/src/lib/antigravity/ClaudeStreamingMapper.ts @@ -173,19 +173,19 @@ export class StreamingState { // Process grounding (web search) -> convert to Markdown text block let groundingText = ''; if (this.webSearchQuery) { - groundingText += `\n\n---\n**🔍 已为您搜索:** ${this.webSearchQuery}`; + groundingText += `\n\n---\n**🔍 Searched for you:** ${this.webSearchQuery}`; } if (this.groundingChunks && this.groundingChunks.length > 0) { const links: string[] = []; this.groundingChunks.forEach((chunk, i) => { if (chunk.web) { - const title = chunk.web.title || '网页来源'; + const title = chunk.web.title || 'Web source'; const uri = chunk.web.uri || '#'; links.push(`[${i + 1}] [${title}](${uri})`); } }); if (links.length > 0) { - groundingText += `\n\n**🌐 来源引文:**\n` + links.join('\n'); + groundingText += `\n\n**🌐 Citations:**\n` + links.join('\n'); } } diff --git a/src/lib/antigravity/JsonSchemaUtils.ts b/src/lib/antigravity/JsonSchemaUtils.ts index b2bb11c..f7c7f0f 100644 --- a/src/lib/antigravity/JsonSchemaUtils.ts +++ b/src/lib/antigravity/JsonSchemaUtils.ts @@ -32,13 +32,37 @@ export function cleanJsonSchema(value: any) { cleanJsonSchemaRecursive(value); } +export function normalizeObjectJsonSchema(schema: unknown): Record { + const fallbackSchema: Record = { type: 'object', properties: {} }; + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { + return fallbackSchema; + } + + const normalizedSchema = JSON.parse(JSON.stringify(schema)) as Record; + cleanJsonSchema(normalizedSchema); + + if (typeof normalizedSchema.type !== 'string') { + normalizedSchema.type = 'object'; + } + if ( + normalizedSchema.type === 'object' && + (!normalizedSchema.properties || + typeof normalizedSchema.properties !== 'object' || + Array.isArray(normalizedSchema.properties)) + ) { + normalizedSchema.properties = {}; + } + + return normalizedSchema; +} + /** * Recursively expand $ref */ function flattenRefs(map: any, defs: Record) { if (!map || typeof map !== 'object') return; - // 检查并替换 $ref + // Check and replace $ref if (typeof map['$ref'] === 'string') { const refPath = map['$ref']; // Parse reference name (e.g. #/$defs/MyType -> MyType) diff --git a/src/lib/antigravity/ModelMapping.ts b/src/lib/antigravity/ModelMapping.ts index b8a155e..bd8b341 100644 --- a/src/lib/antigravity/ModelMapping.ts +++ b/src/lib/antigravity/ModelMapping.ts @@ -1,23 +1,37 @@ import { logger } from '../../utils/logger'; +const PUBLIC_SUPPORTED_MODELS = [ + 'gemini-3.1-pro-high', + 'gemini-3.1-pro-low', + 'gemini-3-flash', + 'claude-sonnet-4-6-thinking', + 'claude-opus-4-6-thinking', +] as const; + const CLAUDE_TO_GEMINI: Record = { // Directly supported models - 'claude-opus-4-5-thinking': 'claude-opus-4-5-thinking', + 'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6-thinking', 'claude-opus-4-6-thinking': 'claude-opus-4-6-thinking', - 'claude-sonnet-4-5': 'claude-sonnet-4-5', - 'claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking', + 'gemini-3.1-pro-low': 'gemini-3.1-pro-low', + 'gemini-3.1-pro-high': 'gemini-3.1-pro-high', + 'gemini-3-flash': 'gemini-3-flash', // Alias mappings - 'claude-sonnet-4-5-20250929': 'claude-sonnet-4-5-thinking', - 'claude-3-5-sonnet-20241022': 'claude-sonnet-4-5', - 'claude-3-5-sonnet-20240620': 'claude-sonnet-4-5', - 'claude-opus-4': 'claude-opus-4-5-thinking', - 'claude-opus-4-5-20251101': 'claude-opus-4-5-thinking', + 'claude-sonnet-4-6': 'claude-sonnet-4-6-thinking', + 'claude-sonnet-4-6-20260219': 'claude-sonnet-4-6-thinking', + 'claude-sonnet-4-5': 'claude-sonnet-4-6-thinking', + 'claude-sonnet-4-5-thinking': 'claude-sonnet-4-6-thinking', + 'claude-sonnet-4-5-20250929': 'claude-sonnet-4-6-thinking', + 'claude-3-5-sonnet-20241022': 'claude-sonnet-4-6-thinking', + 'claude-3-5-sonnet-20240620': 'claude-sonnet-4-6-thinking', + 'claude-opus-4': 'claude-opus-4-6-thinking', + 'claude-opus-4-5-thinking': 'claude-opus-4-6-thinking', + 'claude-opus-4-5-20251101': 'claude-opus-4-6-thinking', 'claude-opus-4-6': 'claude-opus-4-6-thinking', 'claude-opus-4-6-20260201': 'claude-opus-4-6-thinking', - 'claude-haiku-4': 'claude-sonnet-4-5', - 'claude-3-haiku-20240307': 'claude-sonnet-4-5', - 'claude-haiku-4-5-20251001': 'claude-sonnet-4-5', + 'claude-haiku-4': 'claude-sonnet-4-6-thinking', + 'claude-3-haiku-20240307': 'claude-sonnet-4-6-thinking', + 'claude-haiku-4-5-20251001': 'claude-sonnet-4-6-thinking', // OpenAI Protocol Mapping 'gpt-4': 'gemini-3-flash', @@ -42,12 +56,13 @@ const CLAUDE_TO_GEMINI: Record = { // Gemini Protocol Mapping 'gemini-2.5-flash-lite': 'gemini-3-flash', - 'gemini-3-pro-low': 'gemini-3-pro-preview', - 'gemini-3-pro-high': 'gemini-3-pro-preview', - 'gemini-3-pro-preview': 'gemini-3-pro-preview', - 'gemini-3-pro': 'gemini-3-pro-preview', + 'gemini-3.1-pro-preview': 'gemini-3.1-pro-high', + 'gemini-3.1-pro': 'gemini-3.1-pro-high', + 'gemini-3.0-pro': 'gemini-3.1-pro-high', 'gemini-2.5-flash': 'gemini-3-flash', - 'gemini-3-flash': 'gemini-3-flash', + 'gemini-2.5-pro': 'gemini-3.1-pro-high', + 'gemini-2.0-flash': 'gemini-3-flash', + 'gemini-2.0-flash-online': 'gemini-3-flash', 'gemini-3-pro-image': 'gemini-3-pro-image', 'internal-background-task': 'gemini-3-flash', }; @@ -55,17 +70,50 @@ const CLAUDE_TO_GEMINI: Record = { const DYNAMIC_IMAGE_BASE_MODEL = 'gemini-3-pro-image'; const DYNAMIC_IMAGE_RESOLUTIONS = ['', '-2k', '-4k']; const DYNAMIC_IMAGE_RATIOS = ['', '-1x1', '-4x3', '-3x4', '-16x9', '-9x16', '-21x9']; -const EXTRA_DYNAMIC_MODELS = ['gemini-3-flash', 'gemini-3-pro-high', 'gemini-3-pro-low']; +const EXTRA_DYNAMIC_MODELS = [ + 'gemini-3-flash', + 'gemini-3.1-pro-high', + 'gemini-3.1-pro-low', +]; + +const DYNAMIC_MODEL_FORWARDING_RULES = new Map(); export const MODEL_LIST_CREATED_AT = 1770652800; export const MODEL_LIST_OWNER = 'antigravity'; +const GEMINI_MODEL_ALIASES: Record = { + 'gemini-3.1-pro': 'gemini-3.1-pro-high', + 'gemini-3.0-pro': 'gemini-3.1-pro-high', + 'gemini-3.1-pro-preview': 'gemini-3.1-pro-high', + 'gemini-3-pro-image-preview': 'gemini-3-pro-image', + 'gemini-3-flash-preview': 'gemini-3-flash', +}; + export function getSupportedModels(): string[] { - return Object.keys(CLAUDE_TO_GEMINI); + return [...PUBLIC_SUPPORTED_MODELS]; } -export function getAllDynamicModels(customMapping: Record = {}): string[] { +export function updateDynamicForwardingRules(oldModel: string, newModel: string): void { + const normalizedOld = oldModel.trim(); + const normalizedNew = newModel.trim(); + if (!normalizedOld || !normalizedNew) { + return; + } + if (!DYNAMIC_MODEL_FORWARDING_RULES.has(normalizedOld)) { + logger.info(`[Router] Registered dynamic forwarding rule: ${normalizedOld} -> ${normalizedNew}`); + } + DYNAMIC_MODEL_FORWARDING_RULES.set(normalizedOld, normalizedNew); +} + +export function getDynamicForwardingTarget(modelId: string): string | undefined { + return DYNAMIC_MODEL_FORWARDING_RULES.get(modelId); +} + +export function getAllDynamicModels( + customMapping: Record = {}, + dynamicModelIds?: Iterable, +): string[] { const modelIds = new Set(); for (const modelId of getSupportedModels()) { @@ -86,7 +134,17 @@ export function getAllDynamicModels(customMapping: Record = {}): modelIds.add(modelId); } - return [...modelIds].filter((id) => !/gemini-[12](\.|$|-)/.test(id)).sort(); + if (dynamicModelIds) { + for (const dynamicModelId of dynamicModelIds) { + if (typeof dynamicModelId === 'string' && dynamicModelId.trim() !== '') { + modelIds.add(dynamicModelId.trim()); + } + } + } + + return [...modelIds] + .filter((id) => !shouldHideDeprecatedModelFromList(id)) + .sort(); } export function mapClaudeModelToGemini(input: string): string { @@ -98,6 +156,11 @@ export function mapClaudeModelToGemini(input: string): string { return input; } +export function normalizeGeminiModelAlias(modelId: string): string { + const normalizedModelId = modelId.trim().toLowerCase(); + return GEMINI_MODEL_ALIASES[normalizedModelId] ?? modelId; +} + /** * Core Model Routing Engine * Priority: Custom Mapping (Exact) > Group Mapping (Family) > System Mapping (Built-in Plugin) @@ -108,6 +171,12 @@ export function resolveModelRoute( openaiMapping: Record, anthropicMapping: Record, ): string { + const dynamicForwarded = getDynamicForwardingTarget(originalModel); + if (dynamicForwarded) { + logger.info(`[Router] Dynamic deprecated-model forwarding: ${originalModel} -> ${dynamicForwarded}`); + return dynamicForwarded; + } + // 1. Check custom exact mapping (Highest priority) if (customMapping[originalModel]) { logger.info( @@ -194,3 +263,31 @@ export function resolveModelRoute( // 4. Fall through to system default mapping logic return mapClaudeModelToGemini(originalModel); } + +function shouldHideDeprecatedModelFromList(modelId: string): boolean { + const normalized = modelId.toLowerCase(); + + if (/^gemini-1(\.|$|-)/.test(normalized) || /^gemini-2(\.|$|-)/.test(normalized)) { + return true; + } + + if (normalized === 'gemini-3.1-pro' || normalized === 'gemini-3.1-pro-preview') { + return true; + } + + const isLegacyGeminiPro = + /^gemini-3(\.0)?-pro($|-)/.test(normalized) && !normalized.startsWith('gemini-3-pro-image'); + if (isLegacyGeminiPro) { + return true; + } + + if ( + normalized.includes('claude-sonnet-4-5') || + normalized.includes('claude-opus-4-5') || + normalized.includes('claude-haiku-4-5') + ) { + return true; + } + + return false; +} diff --git a/src/lib/antigravity/ModelSpecs.ts b/src/lib/antigravity/ModelSpecs.ts new file mode 100644 index 0000000..5d9523b --- /dev/null +++ b/src/lib/antigravity/ModelSpecs.ts @@ -0,0 +1,40 @@ +import modelSpecsJson from './model-specs'; + +type ModelSpec = { + max_output_tokens?: number; + thinking_budget?: number; + is_thinking?: boolean; +}; + +type SpecsConfig = { + models: Record; + aliases: Record; +}; + +const DEFAULT_MAX_OUTPUT_TOKENS = 65535; +const DEFAULT_THINKING_BUDGET = 24576; + +const SPECS = modelSpecsJson as SpecsConfig; + +export function resolveModelAlias(modelId: string): string { + const normalized = modelId.trim(); + return SPECS.aliases[normalized] ?? normalized; +} + +export function getMaxOutputTokens(modelId: string): number { + const resolved = resolveModelAlias(modelId); + const fromSpec = SPECS.models[resolved]?.max_output_tokens; + if (typeof fromSpec === 'number' && Number.isFinite(fromSpec) && fromSpec > 0) { + return Math.floor(fromSpec); + } + return DEFAULT_MAX_OUTPUT_TOKENS; +} + +export function getThinkingBudget(modelId: string): number { + const resolved = resolveModelAlias(modelId); + const fromSpec = SPECS.models[resolved]?.thinking_budget; + if (typeof fromSpec === 'number' && Number.isFinite(fromSpec) && fromSpec >= 0) { + return Math.floor(fromSpec); + } + return DEFAULT_THINKING_BUDGET; +} diff --git a/src/lib/antigravity/model-specs.json b/src/lib/antigravity/model-specs.json new file mode 100644 index 0000000..02dbad3 --- /dev/null +++ b/src/lib/antigravity/model-specs.json @@ -0,0 +1,52 @@ +{ + "models": { + "gemini-3-flash": { + "max_output_tokens": 65536, + "thinking_budget": 32768, + "is_thinking": true + }, + "gemini-3.1-pro-low": { + "max_output_tokens": 65536, + "thinking_budget": 32768, + "is_thinking": true + }, + "gemini-3.1-pro-high": { + "max_output_tokens": 65536, + "thinking_budget": 49152, + "is_thinking": true + }, + "gemini-3-pro-image": { + "max_output_tokens": 65536, + "thinking_budget": 24576, + "is_thinking": true + }, + "claude-sonnet-4-6-thinking": { + "max_output_tokens": 64000, + "thinking_budget": 32768, + "is_thinking": true + }, + "claude-opus-4-6-thinking": { + "max_output_tokens": 64000, + "thinking_budget": 32768, + "is_thinking": true + }, + "gpt-oss-120b-medium": { + "max_output_tokens": 32768, + "thinking_budget": 0, + "is_thinking": false + } + }, + "aliases": { + "gpt-4o": "gemini-3-flash", + "gpt-3.5-turbo": "gemini-3-flash", + "gemini-2.0-flash": "gemini-3-flash", + "gemini-2.5-flash": "gemini-3-flash", + "gemini-3.1-pro": "gemini-3.1-pro-high", + "gemini-3.1-pro-preview": "gemini-3.1-pro-high", + "claude-sonnet-4-6": "claude-sonnet-4-6-thinking", + "claude-sonnet-4-5": "claude-sonnet-4-6-thinking", + "claude-3-5-sonnet": "claude-sonnet-4-6-thinking", + "claude-3-7-sonnet": "claude-sonnet-4-6-thinking", + "claude-opus-4-5-thinking": "claude-opus-4-6-thinking" + } +} diff --git a/src/lib/antigravity/model-specs.ts b/src/lib/antigravity/model-specs.ts new file mode 100644 index 0000000..7aad13c --- /dev/null +++ b/src/lib/antigravity/model-specs.ts @@ -0,0 +1,54 @@ +const modelSpecs = { + models: { + 'gemini-3-flash': { + max_output_tokens: 65536, + thinking_budget: 32768, + is_thinking: true, + }, + 'gemini-3.1-pro-low': { + max_output_tokens: 65536, + thinking_budget: 32768, + is_thinking: true, + }, + 'gemini-3.1-pro-high': { + max_output_tokens: 65536, + thinking_budget: 49152, + is_thinking: true, + }, + 'gemini-3-pro-image': { + max_output_tokens: 65536, + thinking_budget: 24576, + is_thinking: true, + }, + 'claude-sonnet-4-6-thinking': { + max_output_tokens: 64000, + thinking_budget: 32768, + is_thinking: true, + }, + 'claude-opus-4-6-thinking': { + max_output_tokens: 64000, + thinking_budget: 32768, + is_thinking: true, + }, + 'gpt-oss-120b-medium': { + max_output_tokens: 32768, + thinking_budget: 0, + is_thinking: false, + }, + }, + aliases: { + 'gpt-4o': 'gemini-3-flash', + 'gpt-3.5-turbo': 'gemini-3-flash', + 'gemini-2.0-flash': 'gemini-3-flash', + 'gemini-2.5-flash': 'gemini-3-flash', + 'gemini-3.1-pro': 'gemini-3.1-pro-high', + 'gemini-3.1-pro-preview': 'gemini-3.1-pro-high', + 'claude-sonnet-4-6': 'claude-sonnet-4-6-thinking', + 'claude-sonnet-4-5': 'claude-sonnet-4-6-thinking', + 'claude-3-5-sonnet': 'claude-sonnet-4-6-thinking', + 'claude-3-7-sonnet': 'claude-sonnet-4-6-thinking', + 'claude-opus-4-5-thinking': 'claude-opus-4-6-thinking', + }, +} as const; + +export default modelSpecs; diff --git a/src/lib/antigravity/types.ts b/src/lib/antigravity/types.ts index 0c846e0..de114ae 100644 --- a/src/lib/antigravity/types.ts +++ b/src/lib/antigravity/types.ts @@ -64,6 +64,7 @@ export interface ClaudeRequest { export interface ThinkingConfig { type: 'enabled' | string; budget_tokens?: number; + effort?: 'low' | 'medium' | 'high' | 'max' | string; } export type SystemPrompt = string | SystemBlock[]; @@ -194,6 +195,8 @@ export interface ThinkingGeminiConfig { includeThoughts: boolean; /** Thinking token budget */ thinkingBudget?: number; + /** Thinking level for Claude-native adaptive modes */ + thinkingLevel?: 'low' | 'medium' | 'high' | string; } /** @@ -306,11 +309,22 @@ export interface QuotaData { models: Record; isForbidden: boolean; subscriptionTier?: string; + model_forwarding_rules?: Record; + is_forbidden?: boolean; + subscription_tier?: string; } export interface ModelQuotaInfo { percentage: number; resetTime: string; + display_name?: string; + supports_images?: boolean; + supports_thinking?: boolean; + thinking_budget?: number; + recommended?: boolean; + max_tokens?: number; + max_output_tokens?: number; + supported_mime_types?: Record; } export interface LoadProjectResponse { @@ -327,7 +341,21 @@ export interface Tier { } export interface QuotaApiResponse { - models: Record; + models: Record< + string, + { + quotaInfo?: { remainingFraction?: number; resetTime?: string }; + displayName?: string; + supportsImages?: boolean; + supportsThinking?: boolean; + thinkingBudget?: number; + recommended?: boolean; + maxTokens?: number; + maxOutputTokens?: number; + supportedMimeTypes?: Record; + } + >; + deprecatedModelIds?: Record; } // --- Response Models --- diff --git a/src/tests/AntigravityCoreFeatures.test.ts b/src/tests/AntigravityCoreFeatures.test.ts index b364212..a9b8f5d 100644 --- a/src/tests/AntigravityCoreFeatures.test.ts +++ b/src/tests/AntigravityCoreFeatures.test.ts @@ -79,7 +79,7 @@ describe('Antigravity Core Features (Business Logic)', () => { const requestWithTools = { ...BASE_REQUEST, // Maps to a gemini-3 model potentially - model: 'gemini-3-pro-preview', + model: 'gemini-3.1-pro-preview', tools: [{ name: 'get_weather' }], // Mock tool structure thinking: { type: 'enabled', budget_tokens: 1000 }, } as any; @@ -96,7 +96,7 @@ describe('Antigravity Core Features (Business Logic)', () => { const requestWithTools = { ...BASE_REQUEST, - model: 'gemini-3-pro-preview', + model: 'gemini-3.1-pro-preview', tools: [{ name: 'get_weather' }], thinking: { type: 'enabled', budget_tokens: 1000 }, } as any; @@ -110,7 +110,7 @@ describe('Antigravity Core Features (Business Logic)', () => { it('should KEEP thinking mode if NO function calls exist (pure thinking)', () => { const requestPureThinking = { ...BASE_REQUEST, - model: 'gemini-3-pro-preview', + model: 'gemini-3.1-pro-preview', // No tools thinking: { type: 'enabled', budget_tokens: 1000 }, } as any; @@ -120,5 +120,60 @@ describe('Antigravity Core Features (Business Logic)', () => { // Should keep thinking config expect(result.request.generationConfig?.thinkingConfig).toBeDefined(); }); + + it('should auto-enable thinking for gemini-3-flash even without explicit thinking config', () => { + const result = transformClaudeRequestIn( + { + ...BASE_REQUEST, + model: 'gemini-3-flash', + thinking: undefined, + } as any, + 'test-project', + ); + + expect(result.request.generationConfig?.thinkingConfig).toBeDefined(); + expect(result.request.generationConfig?.thinkingConfig?.includeThoughts).toBe(true); + }); + + it('should inject flash sentinel thoughtSignature for tool_use without cached signature', () => { + const requestWithToolUse = { + ...BASE_REQUEST, + model: 'gemini-3-flash', + thinking: undefined, + messages: [ + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_1', + name: 'get_weather', + input: { location: 'Beijing' }, + }, + ], + }, + ], + } as any; + + const result = transformClaudeRequestIn(requestWithToolUse, 'test-project'); + const part = result.request.contents?.[0]?.parts?.[0] as any; + expect(part?.thoughtSignature).toBe('skip_thought_signature_validator'); + }); + + it('should keep mixed tools for gemini 2.x+ when both function and web search are requested', () => { + const mixedToolsRequest = { + ...BASE_REQUEST, + model: 'gemini-2.0-flash-online', + tools: [{ name: 'get_weather' }, { name: 'web_search' }], + } as any; + + const result = transformClaudeRequestIn(mixedToolsRequest, 'test-project'); + const tools = result.request.tools || []; + const hasFunctions = tools.some((tool: any) => Array.isArray(tool.functionDeclarations)); + const hasGoogleSearch = tools.some((tool: any) => tool.googleSearch); + + expect(hasFunctions).toBe(true); + expect(hasGoogleSearch).toBe(true); + }); }); }); diff --git a/src/tests/scripts/project-id-request-validation.ts b/src/tests/scripts/project-id-request-validation.ts index 491e73d..73ce324 100644 --- a/src/tests/scripts/project-id-request-validation.ts +++ b/src/tests/scripts/project-id-request-validation.ts @@ -8,6 +8,8 @@ const mockTokenManager: any = { getNextToken: async () => null, markAsRateLimited: () => undefined, markAsForbidden: () => undefined, + markFromUpstreamError: () => undefined, + recordParityError: () => undefined, }; const mockGeminiClient: any = { @@ -225,7 +227,11 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise { + getNextToken: async (options?: { + sessionKey?: string; + excludeAccountIds?: string[]; + model?: string; + }) => { observedGetNextTokenOptions = options ?? null; selectedToken = await realTokenManager.getNextToken(options); return selectedToken; @@ -234,6 +240,14 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise realTokenManager.markAsForbidden(accountIdOrEmail), + markFromUpstreamError: (args: { + accountIdOrEmail: string; + status?: number; + retryAfter?: string; + body?: string; + model?: string; + }) => realTokenManager.markFromUpstreamError(args), + recordParityError: () => realTokenManager.recordParityError(), }; try {