feat(providers): enhance Google Gemini, CLI, and Antigravity resilience and features (#2676)

Integrated into release/v3.8.4
This commit is contained in:
Ronaldo Davi
2026-05-24 20:41:07 -03:00
committed by GitHub
parent 1f628e83fa
commit 4df424498b
7 changed files with 367 additions and 97 deletions

View File

@@ -697,9 +697,22 @@ export const REGISTRY: Record<string, RegistryEntry> = {
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<string, RegistryEntry> = {
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<string, RegistryEntry> = {
],
},
"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",

View File

@@ -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<number, number> = {};
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;

View File

@@ -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",
});

View File

@@ -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<string, unknown>,
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<string, number> = {
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<string, string> = {};
const messages = body.messages as Array<Record<string, unknown>> | undefined;
if (messages && Array.isArray(messages)) {
for (const msg of messages) {
const toolCalls = msg.tool_calls as Array<Record<string, unknown>> | 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<string, unknown> = {};
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<unknown, string>();
const toolCalls = msg.tool_calls as Array<Record<string, unknown>> | undefined;
if (toolCalls && Array.isArray(toolCalls)) {
const toolCallIds: string[] = [];
const resolvedSignatures = new Map<string, string>();
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<Record<string, unknown>> | 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<GeminiRequest["tools"]>[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<string, unknown>,
stream: boolean,
credentials: Record<string, unknown> | null = null
) {
// Thread the signature namespace so a thinking model's thoughtSignature (cached on the
// response turn under `<connectionId>:<toolCallId>`) 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<string, unknown>)._signatureNamespace === "string"
? ((credentials as Record<string, unknown>)._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<string, unknown>,
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

View File

@@ -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") {

View File

@@ -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"
);
});

View File

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