diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index 2ede21531c..daaace78ba 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -120,6 +120,8 @@ export class GeminiCLIExecutor extends BaseExecutor { } buildUrl(model, stream, urlIndex = 0) { + void model; + void urlIndex; const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; return `${this.config.baseUrl}:${action}`; } diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 986a24706c..f9fd2795bf 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -281,8 +281,12 @@ export function translateNonStreamingResponse( const fn = toRecord(partObj.functionCall); const rawName = toString(fn.name); const restoredName = toolNameMap?.get(rawName) ?? rawName; + const nativeId = toString(fn.id); toolCalls.push({ - id: `call_${toString(restoredName, "unknown")}_${Date.now()}_${toolCalls.length}`, + id: + nativeId.length > 0 + ? nativeId + : `call_${toString(restoredName, "unknown")}_${Date.now()}_${toolCalls.length}`, type: "function", function: { name: restoredName, diff --git a/open-sse/services/geminiCliHeaders.ts b/open-sse/services/geminiCliHeaders.ts index 9db02e9d6c..0816a911bf 100644 --- a/open-sse/services/geminiCliHeaders.ts +++ b/open-sse/services/geminiCliHeaders.ts @@ -7,6 +7,16 @@ import { export const GEMINI_CLI_VERSION = "0.40.1"; export const GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION = "9.15.1"; +const GEMINI_CLI_LOAD_CODE_ASSIST_METADATA = Object.freeze({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", +}); + +export function getGeminiCliLoadCodeAssistMetadata(): Record { + return { ...GEMINI_CLI_LOAD_CODE_ASSIST_METADATA }; +} + export function geminiCliUserAgent(model: string): string { const normalizedModel = model || "unknown"; return `GeminiCLI/${GEMINI_CLI_VERSION}/${normalizedModel} (${normalizeCloudCodePlatform()}; ${normalizeCloudCodeArch()}; terminal) google-api-nodejs-client/${GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION}`; diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index a72892fe33..7142f32b50 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -21,7 +21,6 @@ import { convertOpenAIContentToParts, extractTextContent, tryParseJSON, - generateRequestId, generateSessionId, cleanJSONSchemaForAntigravity, } from "../helpers/geminiHelper.ts"; @@ -97,6 +96,7 @@ type CloudCodeEnvelope = { type GeminiToolNameOptions = { stripNamespace?: boolean; + functionResponseShape?: "result" | "output"; }; function buildChangedToolNameMap(toolNameMap: Map): Map | null { @@ -296,7 +296,10 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName functionResponse: { id: fid, name: name, - response: { result: parsedResp }, + response: + toolNameOptions.functionResponseShape === "output" + ? { output: typeof resp === "string" ? resp : JSON.stringify(resp) } + : { result: parsedResp }, }, }); } @@ -350,8 +353,16 @@ export function openaiToGeminiRequest(model, body, stream) { } // OpenAI -> Gemini CLI (Cloud Code Assist) -export function openaiToGeminiCLIRequest(model, body, stream) { - const gemini = openaiToGeminiBase(model, body, stream, { stripNamespace: true }); +export function openaiToGeminiCLIRequest( + model, + body, + stream, + options: { functionResponseShape?: "result" | "output" } = {} +) { + const gemini = openaiToGeminiBase(model, body, stream, { + stripNamespace: true, + functionResponseShape: options.functionResponseShape, + }); // Add thinking config for CLI if (body.reasoning_effort) { @@ -413,7 +424,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra : { model: cleanModel, project: projectId, - user_prompt_id: generateRequestId(), + user_prompt_id: generateUUID(), request: { contents: geminiCLI.contents, systemInstruction: geminiCLI.systemInstruction, @@ -443,7 +454,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra } } else { // Gemini CLI's native Cloud Code envelope uses snake_case identifiers. - envelope.request.session_id = generateSessionId(); + envelope.request.session_id = envelope.user_prompt_id; envelope.request.safetySettings = geminiCLI.safetySettings; } @@ -619,7 +630,11 @@ register( FORMATS.OPENAI, FORMATS.GEMINI_CLI, (model, body, stream, credentials) => - wrapInCloudCodeEnvelope(model, openaiToGeminiCLIRequest(model, body, stream), credentials), + wrapInCloudCodeEnvelope( + model, + openaiToGeminiCLIRequest(model, body, stream, { functionResponseShape: "output" }), + credentials + ), null ); register(FORMATS.OPENAI, FORMATS.ANTIGRAVITY, openaiToAntigravityRequest, null); diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index e2b909633f..e558848cde 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -2,6 +2,12 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { storeGeminiThoughtSignature } from "../../services/geminiThoughtSignatureStore.ts"; +function buildToolCallId(functionCall, toolName, toolCallIndex) { + return typeof functionCall?.id === "string" && functionCall.id.length > 0 + ? functionCall.id + : `${toolName}-${Date.now()}-${toolCallIndex}`; +} + // Convert Gemini response chunk to OpenAI format export function geminiToOpenAIResponse(chunk, state) { if (!chunk) return null; @@ -111,7 +117,7 @@ export function geminiToOpenAIResponse(chunk, state) { const toolCallIndex = state.functionIndex++; const toolCall = { - id: `${fcName}-${Date.now()}-${toolCallIndex}`, + id: buildToolCallId(part.functionCall, fcName, toolCallIndex), index: toolCallIndex, type: "function", function: { @@ -169,7 +175,7 @@ export function geminiToOpenAIResponse(chunk, state) { const toolCallIndex = state.functionIndex++; const toolCall = { - id: `${fcName}-${Date.now()}-${toolCallIndex}`, + id: buildToolCallId(part.functionCall, fcName, toolCallIndex), index: toolCallIndex, type: "function", function: { diff --git a/src/lib/oauth/providers/gemini.ts b/src/lib/oauth/providers/gemini.ts index 3ed3491303..f710badd1a 100644 --- a/src/lib/oauth/providers/gemini.ts +++ b/src/lib/oauth/providers/gemini.ts @@ -1,4 +1,5 @@ import { GEMINI_CONFIG } from "../constants/oauth"; +import { getGeminiCliLoadCodeAssistMetadata } from "@omniroute/open-sse/services/geminiCliHeaders.ts"; export const gemini = { config: GEMINI_CONFIG, @@ -72,11 +73,7 @@ export const gemini = { "Content-Type": "application/json", }, body: JSON.stringify({ - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }, + metadata: getGeminiCliLoadCodeAssistMetadata(), }), } ); diff --git a/src/lib/oauth/services/gemini.ts b/src/lib/oauth/services/gemini.ts index 34cbe62127..b6a4772369 100644 --- a/src/lib/oauth/services/gemini.ts +++ b/src/lib/oauth/services/gemini.ts @@ -1,9 +1,6 @@ import crypto from "crypto"; import open from "open"; -import { - ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT, - ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT, -} from "@omniroute/open-sse/services/antigravityHeaders.ts"; +import { getGeminiCliLoadCodeAssistMetadata } from "@omniroute/open-sse/services/geminiCliHeaders.ts"; import { GEMINI_CONFIG } from "../constants/oauth"; import { getServerCredentials } from "../config/index"; import { startLocalServer } from "../utils/server"; @@ -73,20 +70,9 @@ export class GeminiCLIService { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", - "User-Agent": ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT, - "X-Goog-Api-Client": ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT, - "Client-Metadata": JSON.stringify({ - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }), }, body: JSON.stringify({ - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }, + metadata: getGeminiCliLoadCodeAssistMetadata(), }), }); diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 85b7c4f9e3..61640335ba 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -19,9 +19,8 @@ export const FREE_PROVIDERS = { name: "Gemini CLI", icon: "terminal", color: "#4285F4", - deprecated: true, - deprecationReason: - "Google restricts third-party OAuth usage for Gemini CLI (Mar 2026). Pro models require paid plans. Use 'gemini' (API key) provider instead.", + authHint: + "Uses Gemini CLI OAuth / Cloud Code credentials. Pro models require an eligible Google account or paid plan.", }, kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" }, "amazon-q": { diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 2ef35f6d02..f1628033f1 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -22,6 +22,7 @@ const { skillExecutor } = await import("../../src/lib/skills/executor.ts"); const { handleChat } = await import("../../src/sse/handlers/chat.ts"); const { initTranslators } = await import("../../open-sse/translator/index.ts"); const { clearInflight } = await import("../../open-sse/services/requestDedup.ts"); +const { setCliCompatProviders } = await import("../../open-sse/config/cliFingerprints.ts"); const { BaseExecutor } = await import("../../open-sse/executors/base.ts"); const { getCircuitBreaker, resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); @@ -31,9 +32,40 @@ const { setCliCompatProviders } = await import("../../open-sse/config/cliFingerp const originalFetch = globalThis.fetch; const originalRetryDelayMs = BaseExecutor.RETRY_CONFIG.delayMs; -function toPlainHeaders(headers: Headers | Record | undefined | null) { +type SeedConnectionOverrides = { + name?: string; + authType?: string; + apiKey?: string; + accessToken?: string; + refreshToken?: string; + tokenType?: string; + expiresAt?: string; + tokenExpiresAt?: string; + isActive?: boolean; + testStatus?: string; + priority?: number; + rateLimitedUntil?: string | number | null; + providerSpecificData?: Record; +}; + +type FetchCall = { + url: string; + method?: string; + headers: Record; + body: Record | null; +}; + +type SeedApiKeyOptions = { + name?: string; + noLog?: boolean; + allowedConnections?: string[]; + allowedModels?: string[]; +}; + +function toPlainHeaders(headers: HeadersInit | undefined | null) { if (!headers) return {}; if (headers instanceof Headers) return Object.fromEntries(headers.entries()); + if (Array.isArray(headers)) return Object.fromEntries(headers); return Object.fromEntries( Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)]) ); @@ -344,7 +376,7 @@ async function resetStorage() { initTranslators(); } -async function seedConnection(provider, overrides = {}) { +async function seedConnection(provider, overrides: SeedConnectionOverrides = {}) { return providersDb.createProviderConnection({ provider, authType: overrides.authType || "apikey", @@ -369,9 +401,9 @@ async function seedApiKey({ noLog = false, allowedConnections, allowedModels, -} = {}) { +}: SeedApiKeyOptions = {}) { const key = await apiKeysDb.createApiKey(name, "machine-test"); - const updates = {}; + const updates: Record = {}; if (noLog) updates.noLog = true; if (allowedConnections) updates.allowedConnections = allowedConnections; if (allowedModels) updates.allowedModels = allowedModels; @@ -494,9 +526,9 @@ test.after(async () => { test("chat pipeline handles OpenAI passthrough with valid API key auth", async () => { await seedConnection("openai", { apiKey: "sk-openai-primary" }); const apiKey = await seedApiKey(); - const fetchCalls = []; + const fetchCalls: FetchCall[] = []; - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, init: RequestInit = {}) => { fetchCalls.push({ url: String(url), method: init.method || "GET", @@ -530,7 +562,7 @@ test("chat pipeline persists Codex responses cache and reasoning tokens to call await seedConnection("codex", { apiKey: "sk-codex-primary" }); const fetchCalls = []; - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, init: RequestInit = {}) => { fetchCalls.push({ url: String(url), headers: toPlainHeaders(init.headers), @@ -657,7 +689,7 @@ test("chat pipeline treats Codex /responses/compact as non-streaming JSON", asyn await seedConnection("codex", { apiKey: "sk-codex-compact" }); const fetchCalls = []; - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, init: RequestInit = {}) => { fetchCalls.push({ url: String(url), headers: toPlainHeaders(init.headers), @@ -699,7 +731,7 @@ test("chat pipeline serves repeated /v1/responses requests as MISS then HIT and await seedConnection("codex", { apiKey: "sk-codex-cache-seq" }); const fetchCalls = []; - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, init: RequestInit = {}) => { fetchCalls.push({ url: String(url), headers: toPlainHeaders(init.headers), @@ -789,7 +821,7 @@ test("chat pipeline translates OpenAI requests to Claude and returns OpenAI-shap await seedConnection("claude", { apiKey: "sk-claude-primary" }); const fetchCalls = []; - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, init: RequestInit = {}) => { fetchCalls.push({ url: String(url), headers: toPlainHeaders(init.headers), @@ -823,7 +855,7 @@ test("chat pipeline translates OpenAI requests to Gemini and returns OpenAI-shap await seedConnection("gemini", { apiKey: "sk-gemini-primary" }); const fetchCalls = []; - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, init: RequestInit = {}) => { fetchCalls.push({ url: String(url), headers: toPlainHeaders(init.headers), @@ -853,11 +885,84 @@ test("chat pipeline translates OpenAI requests to Gemini and returns OpenAI-shap assert.equal(json.choices[0].message.content, "Gemini translated reply"); }); +test("chat pipeline sends Gemini CLI OAuth requests with native Cloud Code transport", async () => { + setCliCompatProviders(["gemini-cli"]); + await seedConnection("gemini-cli", { + authType: "oauth", + apiKey: "unused-for-oauth", + accessToken: "gemini-cli-oauth-token", + providerSpecificData: { projectId: "stored-project" }, + }); + const fetchCalls = []; + + globalThis.fetch = async (url, init: RequestInit = {}) => { + fetchCalls.push({ + url: String(url), + headers: toPlainHeaders(init.headers), + body: init.body ? JSON.parse(String(init.body)) : null, + }); + + if (String(url).endsWith("loadCodeAssist")) { + return new Response(JSON.stringify({ cloudaicompanionProject: "fresh-project" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + return buildGeminiResponse("Gemini CLI translated reply", "gemini-3-flash-preview"); + }; + + const response = await handleChat( + buildRequest({ + body: { + model: "gemini-cli/gemini-3-flash-preview", + stream: false, + messages: [{ role: "user", content: "Hello Gemini CLI" }], + }, + }) + ); + + const json = (await response.json()) as any; + assert.equal(response.status, 200); + assert.equal(fetchCalls.length, 2); + + const loadCodeAssistCall = fetchCalls[0]; + assert.match(loadCodeAssistCall.url, /loadCodeAssist$/); + assert.equal(loadCodeAssistCall.headers.Authorization, "Bearer gemini-cli-oauth-token"); + assert.equal(loadCodeAssistCall.body.metadata.ideType, "IDE_UNSPECIFIED"); + + const generateCall = fetchCalls[1]; + assert.match(generateCall.url, /generateContent$/); + assert.equal(generateCall.headers.Authorization, "Bearer gemini-cli-oauth-token"); + assert.equal(generateCall.headers.Accept, "application/json"); + assert.match( + generateCall.headers["User-Agent"], + /^GeminiCLI\/0\.40\.1\/gemini-3-flash-preview .* google-api-nodejs-client\/9\.15\.1$/ + ); + assert.match(generateCall.headers["X-Goog-Api-Client"], /^gl-node\/\d+\.\d+\.\d+$/); + assert.equal(generateCall.body.project, "fresh-project"); + assert.equal(generateCall.body.model, "gemini-3-flash-preview"); + assert.equal(generateCall.body.userAgent, undefined); + assert.equal(generateCall.body.requestId, undefined); + assert.equal(generateCall.body.user_prompt_id, generateCall.body.request.session_id); + assert.deepEqual(Object.keys(generateCall.body).slice(0, 4), [ + "model", + "project", + "user_prompt_id", + "request", + ]); + assert.equal(generateCall.body.request.sessionId, undefined); + assert.match(generateCall.body.request.session_id, /^[0-9a-f-]{36}$/i); + assert.equal(generateCall.body.request.contents.at(-1).parts[0].text, "Hello Gemini CLI"); + assert.equal(json.object, "chat.completion"); + assert.equal(json.choices[0].message.content, "Gemini CLI translated reply"); +}); + test("chat pipeline translates Claude-format requests into OpenAI upstream and back to Claude", async () => { await seedConnection("openai", { apiKey: "sk-openai-claude-route" }); const fetchCalls = []; - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, init: RequestInit = {}) => { fetchCalls.push({ url: String(url), headers: toPlainHeaders(init.headers), @@ -1010,7 +1115,7 @@ test("chat pipeline supports local mode without Authorization on explicit combos }); const fetchCalls = []; - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, init: RequestInit = {}) => { fetchCalls.push({ url: String(url), headers: toPlainHeaders(init.headers), @@ -1220,7 +1325,7 @@ test("chat pipeline injects memory context before sending the upstream request", insertLegacyMemory(apiKey.id, "User prefers concise answers."); const fetchCalls = []; - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, init: RequestInit = {}) => { fetchCalls.push({ url: String(url), body: init.body ? JSON.parse(String(init.body)) : null, @@ -1281,7 +1386,7 @@ test("chat pipeline injects skills into tools and intercepts tool calls with ski }); const fetchCalls = []; - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, init: RequestInit = {}) => { fetchCalls.push({ url: String(url), body: init.body ? JSON.parse(String(init.body)) : null, @@ -1325,7 +1430,7 @@ test("chat pipeline falls back to the next account after a provider failure", as }); const seenAuthHeaders = []; - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, init: RequestInit = {}) => { const headers = toPlainHeaders(init.headers); seenAuthHeaders.push(headers.Authorization); if (seenAuthHeaders.length === 1) { @@ -1370,7 +1475,7 @@ test("chat pipeline falls back across combo models when the first provider fails }); const attempts = []; - globalThis.fetch = async (url, init = {}) => { + globalThis.fetch = async (url, init: RequestInit = {}) => { const call = { url: String(url), headers: toPlainHeaders(init.headers), diff --git a/tests/unit/executor-gemini-cli.test.ts b/tests/unit/executor-gemini-cli.test.ts index c4b014eb1b..e79037fe78 100644 --- a/tests/unit/executor-gemini-cli.test.ts +++ b/tests/unit/executor-gemini-cli.test.ts @@ -5,7 +5,14 @@ import { GeminiCLIExecutor } from "../../open-sse/executors/gemini-cli.ts"; import { setCliCompatProviders } from "../../open-sse/config/cliFingerprints.ts"; import { GEMINI_CLI_VERSION } from "../../open-sse/services/geminiCliHeaders.ts"; -type CapturedFetchCall = { url: string; body: Record }; +type CapturedFetchCall = { + url: string; + body: Record; +}; + +function parseInitBody(init: RequestInit): Record { + return init.body ? JSON.parse(String(init.body)) : {}; +} function getMetadata(body: Record): Record { return body.metadata && typeof body.metadata === "object" @@ -54,6 +61,27 @@ test("GeminiCLIExecutor.buildHeaders uses JSON accept for non-streaming requests assert.equal(headers.Accept, "application/json"); }); +test("GeminiCLIExecutor.buildHeaders derives the User-Agent from the request model", () => { + const executor = new GeminiCLIExecutor(); + + const flashHeaders = executor.buildHeaders( + { accessToken: "gcli-token" }, + true, + undefined, + "models/gemini-3-flash-preview" + ); + const proHeaders = executor.buildHeaders( + { accessToken: "gcli-token" }, + true, + undefined, + "models/gemini-3.1-pro-preview" + ); + + assert.match(flashHeaders["User-Agent"], /\/gemini-3-flash-preview /); + assert.match(proHeaders["User-Agent"], /\/gemini-3\.1-pro-preview /); + assert.notEqual(flashHeaders["User-Agent"], proHeaders["User-Agent"]); +}); + test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transformRequest updates body.project", async () => { const executor = new GeminiCLIExecutor(); const originalFetch = globalThis.fetch; @@ -177,7 +205,7 @@ test("GeminiCLIExecutor.refreshProject onboards a managed project when loadCodeA const calls: CapturedFetchCall[] = []; globalThis.fetch = async (url, init: RequestInit = {}) => { - const body = init.body ? JSON.parse(String(init.body)) : {}; + const body = parseInitBody(init); calls.push({ url: String(url), body }); if (String(url).endsWith("loadCodeAssist")) { diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 0fb11a9764..843444e3d7 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; const { openaiToAntigravityRequest, openaiToGeminiCLIRequest, openaiToGeminiRequest } = await import("../../open-sse/translator/request/openai-to-gemini.ts"); +const { getRequestTranslator } = await import("../../open-sse/translator/registry.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); const { DEFAULT_SAFETY_SETTINGS, cleanJSONSchemaForAntigravity, @@ -509,6 +511,69 @@ test("OpenAI -> Gemini helper IDs and JSON parsing stay in the expected format", assert.equal(tryParseJSON("not-json"), null as any); }); +test("OpenAI -> Gemini CLI wraps requests like native Cloud Code", () => { + const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI_CLI); + assert.ok(translate, "expected Gemini CLI translator registration"); + + const envelope = translate( + "gemini-3-flash-preview", + { + messages: [{ role: "user", content: "Hello" }], + reasoning_effort: "high", + }, + true, + { projectId: "project-1" } + ) as any; + + assert.equal(envelope.model, "gemini-3-flash-preview"); + assert.equal(envelope.userAgent, undefined); + assert.equal(envelope.requestId, undefined); + assert.equal(envelope.request.sessionId, undefined); + assert.match(envelope.request.session_id, /^[0-9a-f-]{36}$/i); + assert.equal(envelope.user_prompt_id, envelope.request.session_id); +}); + +test("OpenAI -> Gemini CLI emits native Cloud Code functionResponse output", () => { + const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI_CLI); + assert.ok(translate, "expected Gemini CLI translator registration"); + + const envelope = translate( + "gemini-3-flash-preview", + { + messages: [ + { role: "user", content: "Read fixture" }, + { + role: "assistant", + tool_calls: [ + { + id: "read_file_123_0", + type: "function", + function: { name: "read_file", arguments: '{"file_path":"fixture.txt"}' }, + }, + ], + }, + { + role: "tool", + tool_call_id: "read_file_123_0", + content: "The answer is capybara-4729.", + }, + ], + }, + true, + { projectId: "project-1" } + ) as any; + + const toolTurn = envelope.request.contents.find( + (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) + ); + assert.ok(toolTurn, "expected Gemini CLI tool response turn"); + assert.deepEqual(getFunctionResponse(toolTurn.parts[0]), { + id: "read_file_123_0", + name: "read_file", + response: { output: "The answer is capybara-4729." }, + }); +}); + test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () => { const result = openaiToAntigravityRequest( "gemini-2.5-pro", @@ -678,6 +743,7 @@ test("OpenAI -> Antigravity Claude bridge sanitizes long names and preserves res ); assert.ok(toolTurn, "expected a tool response turn"); assert.equal(getFunctionResponse(toolTurn.parts[0]).name, sanitizedToolName); + assert.deepEqual(getFunctionResponse(toolTurn.parts[0]).response, { result: { ok: true } }); }); test("OpenAI -> Antigravity Claude bridge applies Antigravity output cap without forwarding thinking", () => { diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index e483719547..42f52df891 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -64,7 +64,7 @@ test("Gemini non-stream: multiple candidates keep multimodal content, reasoning { thought: true, text: "Plan first." }, { text: "Answer:" }, { inlineData: { mimeType: "image/png", data: "abc123" } }, - { functionCall: { name: "read_file", args: { path: "/tmp/a" } } }, + { functionCall: { id: "native-read-1", name: "read_file", args: { path: "/tmp/a" } } }, ], }, finishReason: "STOP", @@ -101,6 +101,7 @@ test("Gemini non-stream: multiple candidates keep multimodal content, reasoning ((result as any).choices[0].message as any).tool_calls[0].function.arguments, JSON.stringify({ path: "/tmp/a" }) ); + assert.equal((result as any).choices[0].message.tool_calls[0].id, "native-read-1"); assert.equal(((result as any).choices[1].message as any).content, "Second option"); (assert as any).equal((result as any).choices[1].finish_reason, "length"); assert.equal((result as any).usage.prompt_tokens, 4); @@ -259,6 +260,7 @@ test("Gemini stream: reasoning, tool call, image and MAX_TOKENS finish are conve { thought: true, thoughtSignature: "sig-1", text: "Need a plan." }, { functionCall: { + id: "native-call-1", name: "weather_lookup_bundle_ab12cd34", args: { city: "Sao Paulo" }, }, @@ -281,6 +283,10 @@ 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].function.name, "mcp__filesystem__read_multiple_files_with_validation_and_metadata_bundle_v2" @@ -297,6 +303,37 @@ test("Gemini stream: reasoning, tool call, image and MAX_TOKENS finish are conve assert.equal(result[4].usage.completion_tokens_details.reasoning_tokens, 2); }); +test("Gemini stream: tool calls without native IDs keep deterministic fallback shape", () => { + const state = createStreamingState(); + const result = geminiToOpenAIResponse( + { + responseId: "resp-tool-no-id", + modelVersion: "gemini-3-flash-preview", + candidates: [ + { + content: { + parts: [ + { + thoughtSignature: "sig-2", + functionCall: { + name: "read_file", + args: { file_path: "fixture.txt" }, + }, + }, + ], + }, + }, + ], + }, + state + ); + + const toolCall = result[1].choices[0].delta.tool_calls[0]; + assert.match(toolCall.id, /^read_file-\d+-0$/); + assert.equal(toolCall.function.name, "read_file"); + assert.equal(toolCall.function.arguments, JSON.stringify({ file_path: "fixture.txt" })); +}); + test("Gemini stream: safety block without candidates emits role chunk then content_filter finish", () => { const state = createStreamingState(); const result = geminiToOpenAIResponse(