diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 8ca8d2ffc2..0a0dd6628d 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -697,9 +697,22 @@ export const REGISTRY: Record = { clientSecretEnv: "GEMINI_OAUTH_CLIENT_SECRET", clientSecretDefault: resolvePublicCred("gemini_alt"), }, - models: [], - // Models are populated from Google's API via sync-models (per API key). - // No hardcoded fallback — show nothing until a key is added. + models: [ + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", toolCalling: true, supportsVision: true }, + { + id: "gemini-2.0-flash-thinking-exp-01-21", + name: "Gemini 2.0 Flash Thinking", + supportsReasoning: true, + }, + { + id: "gemini-2.0-pro-exp-02-05", + name: "Gemini 2.0 Pro Experimental", + toolCalling: true, + supportsVision: true, + }, + { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro", toolCalling: true, supportsVision: true }, + { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash", toolCalling: true, supportsVision: true }, + ], }, "gemini-cli": { @@ -722,6 +735,9 @@ export const REGISTRY: Record = { clientSecretDefault: resolvePublicCred("gemini_alt"), }, models: [ + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, + { id: "gemini-2.0-flash-thinking", name: "Gemini 2.0 Flash Thinking" }, + { id: "gemini-2.0-pro-exp-02-05", name: "Gemini 2.0 Pro Experimental" }, { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" }, { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, @@ -3072,6 +3088,24 @@ export const REGISTRY: Record = { ], }, + "vertex-partner": { + id: "vertex-partner", + alias: "vp", + format: "gemini", + executor: "vertex", + baseUrl: "https://us-central1-aiplatform.googleapis.com/v1/projects", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "DeepSeek-V4-Flash", name: "DeepSeek V4 Flash" }, + { id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" }, + { id: "Qwen3.6-35B-A3B", name: "Qwen 3.6 35B A3B" }, + { id: "GLM-5.1-FP8", name: "GLM 5.1" }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ], + }, + alibaba: { id: "alibaba", alias: "ali", diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index b1ad8f0bb6..6c59fd8a55 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -372,6 +372,95 @@ export class GeminiCLIExecutor extends BaseExecutor { return envelope; } + async execute({ + model, + body, + stream, + credentials, + signal, + log, + upstreamExtraHeaders, + }: ExecuteInput) { + const fallbackCount = this.getFallbackCount(); + let lastError = null; + let lastStatus = 0; + const MAX_AUTO_RETRIES = 3; + const retryAttemptsByUrl: Record = {}; + + for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { + const url = this.buildUrl(model, stream, urlIndex); + const headers = this.buildHeaders(credentials, stream, null, model); + mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); + + const transformed = await this.transformRequest(model, body, stream, credentials); + if (transformed instanceof Response) { + return { response: transformed, url, headers, transformedBody: body }; + } + const transformedBody = transformed; + + if (!retryAttemptsByUrl[urlIndex]) { + retryAttemptsByUrl[urlIndex] = 0; + } + + try { + log?.debug?.( + "TELEMETRY", + `[Gemini CLI] Execute - URL: ${url}, Model: ${model}, Retry: ${retryAttemptsByUrl[urlIndex]}` + ); + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal, + }); + + if (!response.ok) { + log?.warn?.( + "TELEMETRY", + `[Gemini CLI] Error Response - URL: ${url}, Status: ${response.status}` + ); + + let retryMs: number | null = null; + if (response.status === 429 || response.status === 503) { + try { + const errorBody = await response.clone().text(); + retryMs = this.parseRetryFromErrorMessage(errorBody); + } catch { + /* ignore parse error */ + } + + if ((!retryMs || retryMs <= 60000) && retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES) { + retryAttemptsByUrl[urlIndex]++; + const backoffMs = + retryMs || Math.min(1000 * 2 ** retryAttemptsByUrl[urlIndex], 30000); + log?.debug?.( + "RETRY", + `Gemini CLI 429 retry ${retryAttemptsByUrl[urlIndex]} after ${backoffMs}ms` + ); + await sleep(backoffMs); + urlIndex--; + continue; + } + } + } + + if (this.shouldRetry(response.status, urlIndex)) { + lastStatus = response.status; + continue; + } + + return { response, url, headers, transformedBody }; + } catch (error) { + lastError = error; + if (urlIndex + 1 < fallbackCount) continue; + throw error; + } + } + + throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`); + } + async refreshCredentials(credentials, log) { if (!credentials.refreshToken) return null; @@ -400,12 +489,29 @@ export class GeminiCLIExecutor extends BaseExecutor { refreshToken: tokens.refresh_token || credentials.refreshToken, expiresIn: tokens.expires_in, projectId: credentials.projectId, + providerSpecificData: credentials.providerSpecificData, }; } catch (error) { log?.error?.("TOKEN", `Gemini CLI refresh error: ${error.message}`); return null; } } + + // Parse retry time from Gemini error message body + // Format: "Your quota will reset after 2h7m23s" + parseRetryFromErrorMessage(errorMessage: unknown): number | null { + if (!errorMessage || typeof errorMessage !== "string") return null; + + const match = errorMessage.match(/reset (?:after|in) (\d+h)?(\d+m)?(\d+s)?/i); + if (!match) return null; + + let totalMs = 0; + if (match[1]) totalMs += parseInt(match[1]) * 3600 * 1000; + if (match[2]) totalMs += parseInt(match[2]) * 60 * 1000; + if (match[3]) totalMs += parseInt(match[3]) * 1000; + + return totalMs || 2_000; + } } export default GeminiCLIExecutor; diff --git a/open-sse/services/geminiCliHeaders.ts b/open-sse/services/geminiCliHeaders.ts index 7c08bb281e..cd7d10a131 100644 --- a/open-sse/services/geminiCliHeaders.ts +++ b/open-sse/services/geminiCliHeaders.ts @@ -4,11 +4,11 @@ import { normalizeCloudCodePlatform, } from "./cloudCodeHeaders.ts"; -export const GEMINI_CLI_VERSION = "0.41.2"; -export const GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION = "9.15.1"; +export const GEMINI_CLI_VERSION = "0.42.0"; +export const GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION = "10.3.0"; const GEMINI_CLI_LOAD_CODE_ASSIST_METADATA = Object.freeze({ - ideType: "IDE_UNSPECIFIED", + ideType: "TERMINAL", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI", }); diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 66a226bf95..1413ef9d20 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -184,7 +184,12 @@ function applyAntigravityGenerationDefaults(generationConfig: GeminiGenerationCo } // Core: Convert OpenAI request to Gemini format (base for all variants) -function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolNameOptions = {}) { +function openaiToGeminiBase( + model: string, + body: Record, + stream: boolean, + toolNameOptions: GeminiToolNameOptions = {} +) { const result: GeminiRequest = { model: model, contents: [], @@ -200,7 +205,7 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName // Preserve cachedContent if provided by client (for explicit Gemini caching) if (body.cachedContent) { - result.cachedContent = body.cachedContent; + result.cachedContent = body.cachedContent as string; } // Generation config @@ -216,21 +221,50 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName if (body.stop !== undefined) { result.generationConfig.stopSequences = Array.isArray(body.stop) ? body.stop : [body.stop]; } - const requestedMaxOutputTokens = body.max_tokens ?? body.max_completion_tokens; + const requestedMaxOutputTokens = (body.max_tokens ?? body.max_completion_tokens) as + | number + | undefined; if (requestedMaxOutputTokens !== undefined) { result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, requestedMaxOutputTokens); } else { result.generationConfig.maxOutputTokens = capMaxOutputTokens(model); } + // Thinking / Reasoning support (Google Gemini 2.0+ Thinking models) + // 1. OpenAI format: reasoning_effort (low/medium/high) + if (body.reasoning_effort) { + const budgetMap: Record = { + low: 1024, + medium: getDefaultThinkingBudget(model) || 8192, + high: capThinkingBudget(model, 32768), + }; + const budget = + budgetMap[body.reasoning_effort as string] || getDefaultThinkingBudget(model) || 8192; + result.generationConfig.thinkingConfig = { + thinkingBudget: budget, + includeThoughts: true, + }; + } + // 2. Claude format: thinking (type: enabled, budget_tokens) + const thinking = body.thinking as { type?: string; budget_tokens?: number } | undefined; + if (thinking?.type === "enabled" && thinking.budget_tokens) { + result.generationConfig.thinkingConfig = { + thinkingBudget: thinking.budget_tokens, + includeThoughts: true, + }; + } + // Build tool_call_id -> name map - const tcID2Name = {}; - if (body.messages && Array.isArray(body.messages)) { - for (const msg of body.messages) { - if (msg.role === "assistant" && msg.tool_calls) { - for (const tc of msg.tool_calls) { - if (tc.type === "function" && tc.id && tc.function?.name) { - tcID2Name[tc.id] = tc.function.name; + const tcID2Name: Record = {}; + const messages = body.messages as Array> | undefined; + if (messages && Array.isArray(messages)) { + for (const msg of messages) { + const toolCalls = msg.tool_calls as Array> | undefined; + if (msg.role === "assistant" && toolCalls) { + for (const tc of toolCalls) { + const fn = tc.function as { name?: string } | undefined; + if (tc.type === "function" && tc.id && fn?.name) { + tcID2Name[tc.id as string] = fn.name; } } } @@ -238,23 +272,23 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName } // Build tool responses cache - const toolResponses = {}; - if (body.messages && Array.isArray(body.messages)) { - for (const msg of body.messages) { + const toolResponses: Record = {}; + if (messages && Array.isArray(messages)) { + for (const msg of messages) { if (msg.role === "tool" && msg.tool_call_id) { - toolResponses[msg.tool_call_id] = msg.content; + toolResponses[msg.tool_call_id as string] = msg.content; } } } // Convert messages - if (body.messages && Array.isArray(body.messages)) { - for (let i = 0; i < body.messages.length; i++) { - const msg = body.messages[i]; + if (messages && Array.isArray(messages)) { + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; const role = msg.role; const content = msg.content; - if (role === "system" && body.messages.length > 1) { + if (role === "system" && messages.length > 1) { const systemText = typeof content === "string" ? content : extractTextContent(content); if (systemText) { if (!result.systemInstruction) { @@ -266,19 +300,19 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName result.systemInstruction.parts.push({ text: systemText }); } } - } else if (role === "user" || (role === "system" && body.messages.length === 1)) { + } else if (role === "user" || (role === "system" && messages.length === 1)) { const parts = convertOpenAIContentToParts(content); if (parts.length > 0) { result.contents.push({ role: "user", parts }); } } else if (role === "assistant") { - const parts = []; + const parts: GeminiPart[] = []; // Thinking/reasoning → thought part with signature if (msg.reasoning_content) { parts.push({ thought: true, - text: msg.reasoning_content, + text: msg.reasoning_content as string, }); } @@ -289,17 +323,19 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName } } - if (msg.tool_calls && Array.isArray(msg.tool_calls)) { - const toolCallIds = []; - const resolvedSignatures = new Map(); + const toolCalls = msg.tool_calls as Array> | undefined; + if (toolCalls && Array.isArray(toolCalls)) { + const toolCallIds: string[] = []; + const resolvedSignatures = new Map(); let firstPersistedSignature: string | undefined; - for (const tc of msg.tool_calls) { + for (const tc of toolCalls) { + const id = tc.id as string; const resolved = resolveGeminiThoughtSignature( - buildGeminiThoughtSignatureKey(toolNameOptions.signatureNamespace, tc.id), + buildGeminiThoughtSignatureKey(toolNameOptions.signatureNamespace, id), extractClientThoughtSignature(tc) ); if (typeof resolved === "string" && resolved.length > 0) { - resolvedSignatures.set(tc.id, resolved); + resolvedSignatures.set(id, resolved); firstPersistedSignature ??= resolved; } } @@ -308,19 +344,23 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName const stringifySignaturelessToolCalls = toolNameOptions.signaturelessToolCallMode === "text"; - for (const tc of msg.tool_calls) { + for (const tc of toolCalls) { if (tc.type !== "function") continue; - const signatureForToolCall = resolvedSignatures.get(tc.id); + const id = tc.id as string; + const fn = tc.function as { name: string; arguments?: string } | undefined; + if (!fn) continue; + + const signatureForToolCall = resolvedSignatures.get(id); if (!signatureForToolCall && stringifySignaturelessToolCalls) { - const args = tc.function?.arguments || "{}"; + const args = fn.arguments || "{}"; parts.push({ - text: `[Tool call: ${tc.function?.name || "unknown"}]\nArguments: ${args}`, + text: `[Tool call: ${fn.name || "unknown"}]\nArguments: ${args}`, }); continue; } - const args = tryParseJSON(tc.function?.arguments || "{}"); + const args = tryParseJSON(fn.arguments || "{}"); const embeddedThoughtSignature = shouldUseEmbeddedSignature ? firstPersistedSignature || signatureForToolCall : undefined; @@ -333,13 +373,13 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName parts.push({ ...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}), functionCall: { - id: tc.id, - name: sanitizeToolName(tc.function.name), + id: id, + name: sanitizeToolName(fn.name), args: args, }, }); - toolCallIds.push(tc.id); + toolCallIds.push(id); } if (parts.length > 0) { @@ -349,15 +389,15 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName // Check if there are actual tool responses in the next messages const hasSignaturelessTextResponses = stringifySignaturelessToolCalls && - msg.tool_calls.some( - (tc) => - tc.type === "function" && !resolvedSignatures.has(tc.id) && toolResponses[tc.id] - ); + toolCalls.some((tc) => { + const id = tc.id as string; + return tc.type === "function" && !resolvedSignatures.has(id) && toolResponses[id]; + }); const hasActualResponses = toolCallIds.some((fid) => toolResponses[fid]) || hasSignaturelessTextResponses; if (hasActualResponses) { - const toolParts = []; + const toolParts: GeminiPart[] = []; for (const fid of toolCallIds) { if (!toolResponses[fid]) continue; @@ -372,8 +412,8 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName } name = sanitizeToolName(name); - let resp = toolResponses[fid]; - let parsedResp = tryParseJSON(resp); + const resp = toolResponses[fid]; + let parsedResp = tryParseJSON(resp as string); if (parsedResp === null) { parsedResp = { result: resp }; } else if (typeof parsedResp !== "object") { @@ -396,11 +436,13 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName // Signature-less historical tool responses are represented as text // so strict Gemini/Antigravity endpoints don't reject them as native // functionResponse parts missing a matching thoughtSignature. - for (const tc of msg.tool_calls) { - if (tc.type !== "function" || !tc.id) continue; - if (!resolvedSignatures.has(tc.id) && toolResponses[tc.id]) { - const name = tcID2Name[tc.id] || tc.function?.name || "unknown"; - const resp = toolResponses[tc.id]; + for (const tc of toolCalls) { + const id = tc.id as string; + if (tc.type !== "function" || !id) continue; + if (!resolvedSignatures.has(id) && toolResponses[id]) { + const fn = tc.function as { name?: string } | undefined; + const name = tcID2Name[id] || fn?.name || "unknown"; + const resp = toolResponses[id]; toolParts.push({ text: `[Tool response: ${name}]\nResult: ${resp}`, }); @@ -420,27 +462,48 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName } // Convert tools - const geminiTools = buildGeminiTools(body.tools, { + const bodyTools = body.tools as Array> | undefined; + const geminiTools = buildGeminiTools(bodyTools, { ...toolNameOptions, toolNameMap, }); + + // Support for Google Search grounding if requested via 'google_search' tool + const hasGoogleSearch = bodyTools?.some((t) => { + const fn = t.function as { name?: string } | undefined; + return t.type === "function" && (fn?.name === "google_search" || fn?.name === "googleSearch"); + }); + + type ToolEntry = NonNullable[number]; + if (geminiTools && geminiTools.length > 0) { result.tools = geminiTools; + if (hasGoogleSearch) { + result.tools.push({ googleSearch: {} } as ToolEntry); + } result.toolConfig = { functionCallingConfig: { mode: "VALIDATED" } }; + } else if (hasGoogleSearch) { + result.tools = [{ googleSearch: {} } as ToolEntry]; } // Convert response_format to Gemini's responseMimeType/responseSchema - if (body.response_format) { - if (body.response_format.type === "json_schema" && body.response_format.json_schema) { + const responseFormat = body.response_format as + | { + type?: string; + json_schema?: { schema?: unknown; [key: string]: unknown }; + } + | undefined; + if (responseFormat) { + if (responseFormat.type === "json_schema" && responseFormat.json_schema) { result.generationConfig.responseMimeType = "application/json"; // Extract the schema (may be nested under .schema key) - const schema = body.response_format.json_schema.schema || body.response_format.json_schema; + const schema = responseFormat.json_schema.schema || responseFormat.json_schema; if (schema && typeof schema === "object") { result.generationConfig.responseSchema = cleanJSONSchemaForAntigravity(schema); } - } else if (body.response_format.type === "json_object") { + } else if (responseFormat.type === "json_object") { result.generationConfig.responseMimeType = "application/json"; - } else if (body.response_format.type === "text") { + } else if (responseFormat.type === "text") { result.generationConfig.responseMimeType = "text/plain"; } } @@ -456,61 +519,40 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName } // OpenAI -> Gemini (standard API) -export function openaiToGeminiRequest(model, body, stream, credentials = null) { +export function openaiToGeminiRequest( + model: string, + body: Record, + stream: boolean, + credentials: Record | null = null +) { // Thread the signature namespace so a thinking model's thoughtSignature (cached on the // response turn under `:`) is found and re-attached to the // functionCall on the follow-up request. Without this the streaming lookup key didn't // match and Gemini rejected tool calls with 400 "missing thought_signature" (#2504). const signatureNamespace = - credentials && - typeof credentials === "object" && - typeof (credentials as Record)._signatureNamespace === "string" - ? ((credentials as Record)._signatureNamespace as string) + credentials && typeof credentials._signatureNamespace === "string" + ? credentials._signatureNamespace : null; return openaiToGeminiBase(model, body, stream, { signatureNamespace }); } // OpenAI -> Gemini CLI (Cloud Code Assist) export function openaiToGeminiCLIRequest( - model, - body, - stream, + model: string, + body: Record, + stream: boolean, options: { functionResponseShape?: "result" | "output"; signatureNamespace?: string | null; signaturelessToolCallMode?: "native" | "text"; } = {} ) { - const gemini = openaiToGeminiBase(model, body, stream, { + return openaiToGeminiBase(model, body, stream, { stripNamespace: true, functionResponseShape: options.functionResponseShape, signatureNamespace: options.signatureNamespace, signaturelessToolCallMode: options.signaturelessToolCallMode, }); - - // Add thinking config for CLI - if (body.reasoning_effort) { - const budgetMap = { - low: 1024, - medium: getDefaultThinkingBudget(model) || 8192, - high: capThinkingBudget(model, 32768), - }; - const budget = budgetMap[body.reasoning_effort] || getDefaultThinkingBudget(model) || 8192; - gemini.generationConfig.thinkingConfig = { - thinkingBudget: budget, - includeThoughts: true, - }; - } - - // Thinking config from Claude format - if (body.thinking?.type === "enabled" && body.thinking.budget_tokens) { - gemini.generationConfig.thinkingConfig = { - thinkingBudget: body.thinking.budget_tokens, - includeThoughts: true, - }; - } - - return gemini; } // Wrap Gemini CLI format in Cloud Code wrapper diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index e6e8681a4f..1fc5090756 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -241,6 +241,40 @@ export function geminiToOpenAIResponse(chunk, state) { } } + // Grounding Metadata (Google Search) + const grounding = candidate.groundingMetadata || candidate.grounding_metadata; + if (grounding && !state.groundingProcessed) { + const citations = []; + if (grounding.groundingChunks || grounding.grounding_chunks) { + const chunks = grounding.groundingChunks || grounding.grounding_chunks; + for (const chunk of chunks) { + if (chunk.web) { + citations.push({ + title: chunk.web.title, + url: chunk.web.uri, + }); + } + } + } + + if (citations.length > 0) { + results.push({ + id: `chatcmpl-${state.messageId}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: state.model, + choices: [ + { + index: 0, + delta: { citations }, + finish_reason: null, + }, + ], + }); + state.groundingProcessed = true; + } + } + // Usage metadata - extract before finish reason so we can include it const usageMeta = response.usageMetadata || chunk.usageMetadata; if (usageMeta && typeof usageMeta === "object") { diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 579eca1811..8e85c5d8af 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -969,3 +969,34 @@ test("openaiToGeminiRequest re-attaches cached thoughtSignature for FORMATS.GEMI "cached thoughtSignature must be re-attached to the functionCall" ); }); +test("OpenAI -> Gemini request maps reasoning_effort to thinkingConfig", () => { + const result = openaiToGeminiRequest( + "gemini-2.0-flash-thinking", + { + messages: [{ role: "user", content: "Solve this complex puzzle" }], + reasoning_effort: "high", + }, + false + ); + + assert.ok((result as any).generationConfig.thinkingConfig, "expected thinkingConfig"); + assert.equal((result as any).generationConfig.thinkingConfig.includeThoughts, true); + assert.equal((result as any).generationConfig.thinkingConfig.thinkingBudget, 32768); +}); + +test("OpenAI -> Gemini request maps google_search tool", () => { + const result = openaiToGeminiRequest( + "gemini-2.0-flash", + { + messages: [{ role: "user", content: "What happened today?" }], + tools: [{ type: "function", function: { name: "google_search" } }], + }, + false + ); + + assert.ok(Array.isArray((result as any).tools), "expected tools array"); + assert.ok( + (result as any).tools.some((t: any) => t.googleSearch), + "expected googleSearch tool" + ); +}); diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index 42f52df891..184e9fd51c 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -64,7 +64,9 @@ test("Gemini non-stream: multiple candidates keep multimodal content, reasoning { thought: true, text: "Plan first." }, { text: "Answer:" }, { inlineData: { mimeType: "image/png", data: "abc123" } }, - { functionCall: { id: "native-read-1", name: "read_file", args: { path: "/tmp/a" } } }, + { + functionCall: { id: "native-read-1", name: "read_file", args: { path: "/tmp/a" } }, + }, ], }, finishReason: "STOP", @@ -283,10 +285,7 @@ test("Gemini stream: reasoning, tool call, image and MAX_TOKENS finish are conve ); assert.equal(result[1].choices[0].delta.reasoning_content, "Need a plan."); - assert.equal( - result[2].choices[0].delta.tool_calls[0].id, - "native-call-1" - ); + assert.equal(result[2].choices[0].delta.tool_calls[0].id, "native-call-1"); assert.equal( result[2].choices[0].delta.tool_calls[0].function.name, "mcp__filesystem__read_multiple_files_with_validation_and_metadata_bundle_v2" @@ -350,6 +349,30 @@ test("Gemini stream: safety block without candidates emits role chunk then conte assert.equal(result[1].choices[0].finish_reason, "content_filter"); }); +test("Gemini stream: grounding metadata (citations) are extracted", () => { + const state = createStreamingState(); + const result = geminiToOpenAIResponse( + { + responseId: "resp-grounding", + modelVersion: "gemini-2.0-flash", + candidates: [ + { + content: { parts: [{ text: "Today is sunny." }] }, + groundingMetadata: { + groundingChunks: [{ web: { title: "Weather Today", uri: "https://weather.com" } }], + }, + }, + ], + }, + state + ); + + assert.equal(result[1].choices[0].delta.content, "Today is sunny."); + assert.deepEqual(result[2].choices[0].delta.citations, [ + { title: "Weather Today", url: "https://weather.com" }, + ]); +}); + test("Gemini stream: null chunk is ignored", () => { assert.equal(geminiToOpenAIResponse(null, createStreamingState()), null); });