mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
feat(providers): enhance Google Gemini, CLI, and Antigravity resilience and features (#2676)
Integrated into release/v3.8.4
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user