1541 lines
53 KiB
TypeScript
1541 lines
53 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const { openaiToAntigravityRequest, openaiToCloudCodeGeminiRequest, 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,
|
|
convertOpenAIContentToParts,
|
|
generateRequestId,
|
|
generateSessionId,
|
|
tryParseJSON,
|
|
} = await import("../../open-sse/translator/helpers/geminiHelper.ts");
|
|
const { ANTIGRAVITY_DEFAULT_SYSTEM } = await import("../../open-sse/config/constants.ts");
|
|
const { clearGeminiThoughtSignatures } =
|
|
await import("../../open-sse/services/geminiThoughtSignatureStore.ts");
|
|
|
|
type UnknownRecord = Record<string, unknown>;
|
|
type GeminiRequestWithConfig = { generationConfig: UnknownRecord };
|
|
type GeminiRequestWithSystem = { systemInstruction: { role?: unknown; parts?: unknown } };
|
|
|
|
test.beforeEach(() => {
|
|
clearGeminiThoughtSignatures();
|
|
});
|
|
|
|
function getFunctionCall(part: unknown) {
|
|
assert.ok(part && typeof part === "object", "expected Gemini functionCall part");
|
|
const functionCall = (part as UnknownRecord).functionCall;
|
|
assert.ok(functionCall && typeof functionCall === "object", "expected functionCall payload");
|
|
return functionCall as { id?: string; name: string; args?: unknown };
|
|
}
|
|
|
|
function getFunctionResponse(part: unknown) {
|
|
assert.ok(part && typeof part === "object", "expected Gemini functionResponse part");
|
|
const functionResponse = (part as UnknownRecord).functionResponse;
|
|
assert.ok(
|
|
functionResponse && typeof functionResponse === "object",
|
|
"expected functionResponse payload"
|
|
);
|
|
return functionResponse as { id?: string; name: string; response?: unknown };
|
|
}
|
|
|
|
function getFunctionDeclarationParameters(parameters: unknown) {
|
|
assert.ok(
|
|
parameters && typeof parameters === "object",
|
|
"expected function declaration parameters"
|
|
);
|
|
return parameters as UnknownRecord & {
|
|
properties?: Record<string, UnknownRecord>;
|
|
examples?: unknown;
|
|
$schema?: unknown;
|
|
};
|
|
}
|
|
|
|
test("OpenAI -> Gemini helper converts text, images and files into Gemini parts", () => {
|
|
const parts = convertOpenAIContentToParts([
|
|
{ type: "text", text: "Hello" },
|
|
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
|
|
{ type: "file_url", file_url: { url: "data:application/pdf;base64,Zm9v" } },
|
|
{ type: "document", document: { url: "data:text/plain;base64,YmFy" } },
|
|
{ type: "image_url", image_url: { url: "https://example.com/skip.png" } },
|
|
{ type: "file_url", file_url: { url: "not-a-data-url" } },
|
|
]);
|
|
|
|
// Remote http(s) URLs are no longer dropped — they pass through as Gemini
|
|
// `fileData: { fileUri }` so the model fetches the asset itself (#4373, ported
|
|
// from upstream PR #344). A non-data, non-http string ("not-a-data-url") is
|
|
// still dropped (no inlineData/fileData part).
|
|
assert.deepEqual(parts, [
|
|
{ text: "Hello" },
|
|
{ inlineData: { mimeType: "image/png", data: "abc" } },
|
|
{ inlineData: { mimeType: "application/pdf", data: "Zm9v" } },
|
|
{ inlineData: { mimeType: "text/plain", data: "YmFy" } },
|
|
{ fileData: { fileUri: "https://example.com/skip.png", mimeType: "image/*" } },
|
|
]);
|
|
assert.deepEqual(convertOpenAIContentToParts("raw text"), [{ text: "raw text" }]);
|
|
});
|
|
|
|
test("OpenAI -> Gemini does not inject default maxOutputTokens for unknown caps", () => {
|
|
const withoutRequestLimit = openaiToGeminiRequest(
|
|
"gemini-2.5-pro",
|
|
{ messages: [{ role: "user", content: "Hello" }] },
|
|
false
|
|
);
|
|
assert.equal(
|
|
(withoutRequestLimit as GeminiRequestWithConfig).generationConfig.maxOutputTokens,
|
|
undefined
|
|
);
|
|
|
|
const withRequestLimit = openaiToGeminiRequest(
|
|
"gemini-2.5-pro",
|
|
{ messages: [{ role: "user", content: "Hello" }], max_tokens: 32000 },
|
|
false
|
|
);
|
|
assert.equal(
|
|
(withRequestLimit as GeminiRequestWithConfig).generationConfig.maxOutputTokens,
|
|
32000
|
|
);
|
|
});
|
|
|
|
test("OpenAI -> Gemini helper cleans complex JSON Schema structures for Gemini compatibility", () => {
|
|
const cleaned = cleanJSONSchemaForAntigravity({
|
|
type: "object",
|
|
title: "Root schema",
|
|
properties: {
|
|
mode: { const: "fast" },
|
|
retries: { type: "integer", enum: [1, 2, 3] },
|
|
payload: {
|
|
anyOf: [
|
|
{ type: "null" },
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
id: { type: ["string", "null"], minLength: 1 },
|
|
nested: {
|
|
allOf: [
|
|
{
|
|
properties: {
|
|
a: { type: "string" },
|
|
},
|
|
required: ["a"],
|
|
},
|
|
{
|
|
properties: {
|
|
b: { type: "number" },
|
|
},
|
|
required: ["missing", "b"],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
required: ["id", "missing"],
|
|
},
|
|
],
|
|
},
|
|
emptyObject: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
required: ["mode", "payload", "missingRoot"],
|
|
});
|
|
|
|
assert.equal(cleaned.properties.mode.type, "string");
|
|
assert.deepEqual(cleaned.properties.mode.enum, ["fast"]);
|
|
assert.equal(cleaned.properties.retries.enum, undefined);
|
|
assert.equal(cleaned.properties.payload.type, "object");
|
|
assert.equal(cleaned.properties.payload.properties.id.type, "string");
|
|
assert.equal("minLength" in cleaned.properties.payload.properties.id, false);
|
|
assert.deepEqual(cleaned.properties.payload.required, ["id"]);
|
|
assert.deepEqual(cleaned.properties.payload.properties.nested.required.sort(), ["a", "b"]);
|
|
assert.deepEqual(cleaned.required.sort(), ["mode", "payload"]);
|
|
assert.deepEqual(cleaned.properties.emptyObject.required, ["reason"]);
|
|
assert.equal(cleaned.properties.emptyObject.properties.reason.type, "string");
|
|
});
|
|
|
|
test("OpenAI -> Gemini helper inlines local refs and preserves only additionalProperties=true", () => {
|
|
const cleaned = cleanJSONSchemaForAntigravity({
|
|
type: "object",
|
|
$defs: {
|
|
Address: {
|
|
type: "object",
|
|
properties: {
|
|
street: { type: "string", minLength: 1 },
|
|
},
|
|
required: ["street"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
properties: {
|
|
shipping: { $ref: "#/$defs/Address" },
|
|
metadata: {
|
|
type: "object",
|
|
additionalProperties: true,
|
|
},
|
|
options: {
|
|
type: "object",
|
|
additionalProperties: { type: "string" },
|
|
},
|
|
},
|
|
required: ["shipping"],
|
|
});
|
|
|
|
assert.equal(cleaned.$defs, undefined);
|
|
assert.equal(cleaned.properties.shipping.$ref, undefined);
|
|
assert.equal(cleaned.properties.shipping.properties.street.type, "string");
|
|
assert.equal(cleaned.properties.shipping.properties.street.minLength, undefined);
|
|
assert.deepEqual(cleaned.properties.shipping.required, ["street"]);
|
|
assert.equal(cleaned.properties.shipping.additionalProperties, undefined);
|
|
assert.equal(cleaned.properties.metadata.additionalProperties, undefined);
|
|
assert.equal(cleaned.properties.options.additionalProperties, undefined);
|
|
});
|
|
|
|
test("OpenAI -> Gemini request maps messages, merged system instructions, tools and response schema", () => {
|
|
const result = openaiToGeminiRequest(
|
|
"gemini-2.5-pro",
|
|
{
|
|
messages: [
|
|
{ role: "system", content: "Rule A" },
|
|
{ role: "system", content: [{ type: "text", text: "Rule B" }] },
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "What is the weather?" },
|
|
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
|
|
],
|
|
},
|
|
{
|
|
role: "assistant",
|
|
reasoning_content: "Need live data",
|
|
content: "Calling a tool",
|
|
tool_calls: [
|
|
{
|
|
id: "call_1",
|
|
type: "function",
|
|
function: { name: "weather", arguments: '{"city":"Tokyo"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_1",
|
|
content: '{"temp":20}',
|
|
},
|
|
],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "weather",
|
|
description: "Fetch weather",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
city: { type: ["string", "null"] },
|
|
},
|
|
required: ["city"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
max_completion_tokens: 2222,
|
|
temperature: 0.3,
|
|
top_p: 0.9,
|
|
stop: ["DONE"],
|
|
response_format: {
|
|
type: "json_schema",
|
|
json_schema: {
|
|
schema: {
|
|
type: "object",
|
|
properties: {
|
|
answer: { const: "ok" },
|
|
},
|
|
required: ["answer"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
false
|
|
);
|
|
|
|
const systemInstruction = (result as GeminiRequestWithSystem).systemInstruction;
|
|
assert.equal(systemInstruction.role, "system");
|
|
assert.deepEqual(systemInstruction.parts, [{ text: "Rule A" }, { text: "Rule B" }]);
|
|
assert.equal(result.contents[0].role, "user");
|
|
assert.deepEqual(result.contents[0].parts, [
|
|
{ text: "What is the weather?" },
|
|
{ inlineData: { mimeType: "image/png", data: "abc" } },
|
|
]);
|
|
|
|
const modelTurn = result.contents.find(
|
|
(content) => content.role === "model" && content.parts.some((part) => part.functionCall)
|
|
);
|
|
assert.ok(modelTurn, "expected a model turn with functionCall");
|
|
const modelTurnThought = modelTurn.parts[0] as { thought?: boolean; text?: string };
|
|
const modelTurnFunctionCall = getFunctionCall(modelTurn.parts[2]);
|
|
assert.equal(modelTurn.parts[0].thought, true);
|
|
assert.equal(modelTurnThought.text, "Need live data");
|
|
assert.equal(modelTurn.parts[1].text, "Calling a tool");
|
|
assert.equal(modelTurnFunctionCall.name, "weather");
|
|
assert.deepEqual(modelTurnFunctionCall.args, { city: "Tokyo" });
|
|
|
|
const toolResponseTurn = result.contents.find(
|
|
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
|
|
);
|
|
assert.ok(toolResponseTurn, "expected a tool response turn");
|
|
assert.deepEqual(getFunctionResponse(toolResponseTurn.parts[0]), {
|
|
id: "call_1",
|
|
name: "weather",
|
|
response: { result: { temp: 20 } },
|
|
});
|
|
|
|
const generationConfig = (result as GeminiRequestWithConfig).generationConfig;
|
|
assert.equal(generationConfig.maxOutputTokens, 2222);
|
|
assert.equal(generationConfig.temperature, 0.3);
|
|
assert.equal(generationConfig.topP, 0.9);
|
|
assert.deepEqual(generationConfig.stopSequences, ["DONE"]);
|
|
assert.equal(generationConfig.responseMimeType, "application/json");
|
|
const responseSchema = generationConfig.responseSchema as {
|
|
properties: { answer: { type: string; enum?: string[] } };
|
|
};
|
|
assert.equal(responseSchema.properties.answer.type, "string");
|
|
assert.deepEqual(responseSchema.properties.answer.enum, ["ok"]);
|
|
const parameters = getFunctionDeclarationParameters(
|
|
(result as any).tools[0].functionDeclarations[0].parameters
|
|
);
|
|
assert.deepEqual(parameters, {
|
|
type: "object",
|
|
properties: {
|
|
city: { type: "string" },
|
|
},
|
|
required: ["city"],
|
|
});
|
|
assert.deepEqual(result.safetySettings, DEFAULT_SAFETY_SETTINGS);
|
|
});
|
|
|
|
test("OpenAI -> Gemini request preserves custom safety settings and handles system-only requests", () => {
|
|
const customSafety = [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }];
|
|
|
|
const result = openaiToGeminiRequest(
|
|
"gemini-2.5-flash",
|
|
{
|
|
messages: [{ role: "system", content: "Only rules" }],
|
|
safetySettings: customSafety,
|
|
},
|
|
false
|
|
);
|
|
|
|
assert.deepEqual(result.safetySettings, customSafety);
|
|
assert.equal((result as any).systemInstruction, undefined);
|
|
assert.equal(result.contents.length, 1);
|
|
assert.equal(result.contents[0].role, "user");
|
|
assert.deepEqual(result.contents[0].parts, [{ text: "Only rules" }]);
|
|
});
|
|
|
|
test("OpenAI -> Cloud Code Gemini adds thinking config and normalizes namespaced tool names", () => {
|
|
const result = openaiToCloudCodeGeminiRequest(
|
|
"gemini-2.5-pro",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "Check weather" },
|
|
{
|
|
role: "assistant",
|
|
tool_calls: [
|
|
{
|
|
id: "call_1",
|
|
type: "function",
|
|
function: { name: "ns:weather", arguments: '{"city":"Tokyo"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_1",
|
|
content: '{"temp":20}',
|
|
},
|
|
],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "ns:weather",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
reasoning_effort: "high",
|
|
},
|
|
false
|
|
);
|
|
|
|
assert.equal((result as any).generationConfig.thinkingConfig.includeThoughts, true);
|
|
assert.ok((result as any).generationConfig.thinkingConfig.thinkingBudget > 0);
|
|
assert.equal((result as any).tools[0].functionDeclarations[0].name, "weather");
|
|
assert.equal((result as any)._toolNameMap.get("weather"), "ns:weather");
|
|
|
|
const modelTurn = result.contents.find((content) => content.role === "model");
|
|
assert.ok(modelTurn, "expected a model turn");
|
|
assert.equal(getFunctionCall(modelTurn.parts[0]).name, "weather");
|
|
|
|
const responseTurn = result.contents.find(
|
|
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
|
|
);
|
|
assert.ok(responseTurn, "expected a function response turn");
|
|
assert.equal(getFunctionResponse(responseTurn.parts[0]).name, "weather");
|
|
});
|
|
|
|
test("OpenAI -> Gemini request sanitizes long MCP tool names and strips unsupported schema fields", () => {
|
|
const longToolName =
|
|
"mcp__filesystem__read_multiple_files_with_validation_and_metadata_bundle_v2";
|
|
const result = openaiToGeminiRequest(
|
|
"gemini-2.5-pro",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "Read the file set" },
|
|
{
|
|
role: "assistant",
|
|
tool_calls: [
|
|
{
|
|
id: "call_long_1",
|
|
type: "function",
|
|
function: { name: longToolName, arguments: '{"paths":["/tmp/a","/tmp/b"]}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_long_1",
|
|
content: '{"ok":true}',
|
|
},
|
|
],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: longToolName,
|
|
parameters: {
|
|
type: "object",
|
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
examples: [{ paths: ["/tmp/a"] }],
|
|
properties: {
|
|
paths: {
|
|
type: "array",
|
|
items: { type: "string", "x-ui": "hidden" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
false
|
|
);
|
|
|
|
const sanitizedToolName = (result as any).tools[0].functionDeclarations[0].name;
|
|
assert.ok(longToolName.length > 64);
|
|
assert.equal(sanitizedToolName.length, 64);
|
|
assert.match(sanitizedToolName, /_[a-f0-9]{8}$/);
|
|
assert.equal((result as any)._toolNameMap.get(sanitizedToolName), longToolName);
|
|
|
|
const modelTurn = result.contents.find((content) => content.role === "model");
|
|
assert.ok(modelTurn, "expected a model turn");
|
|
assert.equal(getFunctionCall(modelTurn.parts[0]).name, sanitizedToolName);
|
|
|
|
const toolTurn = result.contents.find(
|
|
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
|
|
);
|
|
assert.ok(toolTurn, "expected a tool response turn");
|
|
assert.equal(getFunctionResponse(toolTurn.parts[0]).name, sanitizedToolName);
|
|
const longToolParameters = getFunctionDeclarationParameters(
|
|
(result as any).tools[0].functionDeclarations[0].parameters
|
|
) as UnknownRecord & {
|
|
properties?: {
|
|
paths?: {
|
|
items?: UnknownRecord;
|
|
};
|
|
};
|
|
};
|
|
assert.equal(longToolParameters.$schema, undefined);
|
|
assert.equal(longToolParameters.examples, undefined);
|
|
assert.equal(longToolParameters.properties?.paths?.items?.["x-ui"], undefined);
|
|
});
|
|
|
|
test("OpenAI -> Gemini request gives googleSearch precedence over function tools", () => {
|
|
const result = openaiToGeminiRequest(
|
|
"gemini-2.5-pro",
|
|
{
|
|
messages: [{ role: "user", content: "Search the web" }],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "weather",
|
|
description: "Fetch weather",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
{ type: "web_search" },
|
|
],
|
|
},
|
|
false
|
|
);
|
|
|
|
assert.deepEqual((result as any).tools, [{ googleSearch: {} }]);
|
|
});
|
|
|
|
test("OpenAI -> Antigravity keeps googleSearch without function calling config", () => {
|
|
const result = openaiToAntigravityRequest(
|
|
"gemini-2.5-pro",
|
|
{
|
|
messages: [{ role: "user", content: "Search the web" }],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "weather",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
{ type: "web_search_preview" },
|
|
],
|
|
},
|
|
false,
|
|
{ projectId: "proj-search" } as any
|
|
);
|
|
|
|
assert.deepEqual((result as any).request?.tools, [{ googleSearch: {} }]);
|
|
assert.equal(result.request.toolConfig, undefined);
|
|
});
|
|
|
|
test("OpenAI -> Gemini helper IDs and JSON parsing stay in the expected format", () => {
|
|
assert.match(generateRequestId(), /^agent-/);
|
|
assert.match(generateSessionId(), /^-\d+$/);
|
|
assert.deepEqual(tryParseJSON('{"ok":true}'), { ok: true });
|
|
assert.equal(tryParseJSON("not-json"), null as any);
|
|
});
|
|
|
|
test("OpenAI -> Cloud Code Gemini applies native request defaults", () => {
|
|
const request = openaiToCloudCodeGeminiRequest(
|
|
"gemini-3-flash-preview",
|
|
{
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
reasoning_effort: "high",
|
|
},
|
|
true
|
|
) as any;
|
|
|
|
assert.equal(request.model, "gemini-3-flash-preview");
|
|
assert.equal(request.generationConfig.thinkingConfig.includeThoughts, true);
|
|
assert.equal(request.generationConfig.topK, undefined);
|
|
assert.equal(request.contents.at(-1).parts[0].text, "Hello");
|
|
});
|
|
|
|
test("OpenAI -> Cloud Code Gemini emits native functionResponse result", () => {
|
|
const request = openaiToCloudCodeGeminiRequest(
|
|
"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
|
|
) as any;
|
|
|
|
const toolTurn = request.contents.find(
|
|
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
|
|
);
|
|
assert.ok(toolTurn, "expected Cloud Code Gemini tool response turn");
|
|
assert.deepEqual(getFunctionResponse(toolTurn.parts[0]), {
|
|
id: "read_file_123_0",
|
|
name: "read_file",
|
|
response: { result: { result: "The answer is capybara-4729." } },
|
|
});
|
|
});
|
|
|
|
test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () => {
|
|
const result = openaiToAntigravityRequest(
|
|
"gemini-2.5-pro",
|
|
{
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "weather",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
reasoning_effort: "medium",
|
|
},
|
|
false,
|
|
{ projectId: "proj-1" } as any
|
|
);
|
|
|
|
assert.equal(result.project, "proj-1");
|
|
assert.deepEqual(Object.keys(result), [
|
|
"project",
|
|
"requestId",
|
|
"request",
|
|
"model",
|
|
"userAgent",
|
|
"requestType",
|
|
"enabledCreditTypes",
|
|
]);
|
|
assert.equal(result.userAgent, "antigravity");
|
|
assert.equal(result.requestType, "agent");
|
|
assert.match(result.requestId, /^agent\/\d+\/[0-9a-f]{8}$/);
|
|
assert.match(result.request.sessionId, /^-?\d+$/);
|
|
assert.deepEqual(result.enabledCreditTypes, ["GOOGLE_ONE_AI"]);
|
|
assert.equal(result.request.generationConfig.topK, 40);
|
|
assert.equal(result.request.generationConfig.topP, 1.0);
|
|
assert.equal(
|
|
(result as any).request?.systemInstruction.parts[0].text,
|
|
ANTIGRAVITY_DEFAULT_SYSTEM
|
|
);
|
|
assert.deepEqual(result.request.toolConfig, {
|
|
functionCallingConfig: { mode: "VALIDATED" },
|
|
});
|
|
});
|
|
|
|
test("OpenAI -> Antigravity Gemini omits signature-less historical tool calls and keeps response context", () => {
|
|
const result = openaiToAntigravityRequest(
|
|
"gemini-3.5-flash-low",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "Update todo" },
|
|
{
|
|
role: "assistant",
|
|
tool_calls: [
|
|
{
|
|
id: "call_synthetic_1",
|
|
type: "function",
|
|
function: { name: "default_api:todowrite_ide", arguments: '{"todos":[]}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_synthetic_1",
|
|
content: "[]",
|
|
},
|
|
],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "default_api:todowrite_ide",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{ projectId: "proj-antigravity-gemini" } as any
|
|
);
|
|
|
|
const modelTurn = result.request.contents.find((content) => content.role === "model");
|
|
assert.ok(
|
|
!modelTurn ||
|
|
!modelTurn.parts.some(
|
|
(part) =>
|
|
typeof part.text === "string" && part.text.includes("Historical tool-call record only")
|
|
),
|
|
"signature-less historical call must not be emitted as visible historical text"
|
|
);
|
|
assert.equal(
|
|
modelTurn?.parts.some(
|
|
(part) => typeof part.text === "string" && part.text.includes("[Tool call:")
|
|
) ?? false,
|
|
false,
|
|
"signature-less historical call must not use executable textual tool-call markers"
|
|
);
|
|
// With skip_thought_signature_validator bypass, functionCall IS emitted natively
|
|
assert.equal(
|
|
modelTurn?.parts.some((part) => part.functionCall) ?? false,
|
|
true,
|
|
"signature-less historical call MUST be emitted as native functionCall (bypass applied)"
|
|
);
|
|
assert.equal(
|
|
modelTurn?.parts.some((part) => part.thoughtSignature === "skip_thought_signature_validator") ??
|
|
false,
|
|
true,
|
|
"the bypass sentinel must be injected as thoughtSignature"
|
|
);
|
|
|
|
const toolTurn = result.request.contents.find(
|
|
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
|
|
);
|
|
assert.ok(
|
|
toolTurn,
|
|
"expected signature-less tool response to be preserved as native functionResponse (bypass applied)"
|
|
);
|
|
assert.equal(
|
|
toolTurn.parts.some((part) => part.functionResponse),
|
|
true,
|
|
"signature-less historical response MUST be emitted as native functionResponse (bypass applied)"
|
|
);
|
|
});
|
|
|
|
test("OpenAI -> Antigravity preserves multiple signature-less historical tool responses as context", () => {
|
|
const result = openaiToAntigravityRequest(
|
|
"gemini-3.5-flash-low",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "Inspect OmniRoute config" },
|
|
{
|
|
role: "assistant",
|
|
tool_calls: [
|
|
{
|
|
id: "call_missing_db",
|
|
type: "function",
|
|
function: { name: "terminal", arguments: '{"command":"cat data/db.json"}' },
|
|
},
|
|
{
|
|
id: "call_list_dir",
|
|
type: "function",
|
|
function: { name: "terminal", arguments: '{"command":"ls ~/.omniroute"}' },
|
|
},
|
|
],
|
|
},
|
|
{ role: "tool", tool_call_id: "call_missing_db", content: "data/db.json: No such file" },
|
|
{ role: "tool", tool_call_id: "call_list_dir", content: "storage.sqlite" },
|
|
],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "terminal",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{ projectId: "proj-antigravity-gemini" } as any
|
|
);
|
|
|
|
// With skip_thought_signature_validator bypass: native functionCall + functionResponse expected
|
|
const modelTurn = result.request.contents.find(
|
|
(c) => c.role === "model" && c.parts.some((p) => p.functionCall)
|
|
);
|
|
assert.ok(modelTurn, "expected native functionCall turns");
|
|
assert.equal(
|
|
modelTurn.parts.filter((p) => p.functionCall).length,
|
|
2,
|
|
"expected 2 functionCall parts"
|
|
);
|
|
assert.equal(
|
|
result.request.contents.some((c) => c.parts.some((p) => p.functionResponse)),
|
|
true,
|
|
"signature-less historical responses MUST be emitted as native functionResponse (bypass applied)"
|
|
);
|
|
});
|
|
|
|
test("OpenAI -> Antigravity preserves signed Gemini tool calls in native form", async () => {
|
|
const { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature } =
|
|
await import("../../open-sse/services/geminiThoughtSignatureStore.ts");
|
|
const ns = "conn-antigravity-signed";
|
|
const toolId = "call_signed_history";
|
|
storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId), "SIG_AG_SIGNED_XYZ");
|
|
|
|
const result = openaiToAntigravityRequest(
|
|
"gemini-3.5-flash-low",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "Read status" },
|
|
{
|
|
role: "assistant",
|
|
tool_calls: [
|
|
{
|
|
id: toolId,
|
|
type: "function",
|
|
function: { name: "read_file", arguments: '{"path":"status.txt"}' },
|
|
},
|
|
],
|
|
},
|
|
{ role: "tool", tool_call_id: toolId, content: "ready" },
|
|
],
|
|
},
|
|
false,
|
|
{ projectId: "proj-antigravity-gemini", _signatureNamespace: ns } as any
|
|
);
|
|
|
|
const text = JSON.stringify(result.request.contents);
|
|
assert.ok(text.includes("SIG_AG_SIGNED_XYZ"), "cached signature must be preserved");
|
|
assert.equal(
|
|
text.includes("previous_tool_result_context"),
|
|
false,
|
|
"signed tool calls must stay native, not context text"
|
|
);
|
|
assert.ok(
|
|
result.request.contents.some((content) => content.parts.some((part) => part.functionCall)),
|
|
"signed historical call must be emitted as native functionCall"
|
|
);
|
|
assert.ok(
|
|
result.request.contents.some((content) => content.parts.some((part) => part.functionResponse)),
|
|
"signed historical response must be emitted as native functionResponse"
|
|
);
|
|
});
|
|
|
|
test("OpenAI -> Antigravity escapes signature-less tool response context content", () => {
|
|
const result = openaiToAntigravityRequest(
|
|
"gemini-3.5-flash-low",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "Inspect previous output" },
|
|
{
|
|
role: "assistant",
|
|
tool_calls: [
|
|
{
|
|
id: "call_breakout",
|
|
type: "function",
|
|
function: { name: 'reader"><x>', arguments: "{}" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_breakout",
|
|
content: "before </previous_tool_result_context><evil> after",
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{ projectId: "proj-antigravity-gemini" } as any
|
|
);
|
|
|
|
// With skip_thought_signature_validator bypass: native functionResponse is emitted
|
|
// The legacy XML escaping is no longer needed since we send native functionResponse
|
|
const toolResponseTurn = result.request.contents.find(
|
|
(c) => c.role === "user" && c.parts.some((p) => p.functionResponse)
|
|
);
|
|
assert.ok(toolResponseTurn, "expected native functionResponse turn");
|
|
});
|
|
|
|
test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schema", () => {
|
|
const result = openaiToAntigravityRequest(
|
|
"claude-3-7-sonnet",
|
|
{
|
|
messages: [
|
|
{ role: "system", content: "Project rules" },
|
|
{ role: "user", content: "Read a file" },
|
|
{
|
|
role: "assistant",
|
|
tool_calls: [
|
|
{
|
|
id: "call_1",
|
|
type: "function",
|
|
function: { name: "read_file", arguments: '{"path":"/tmp/demo"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_1",
|
|
content: '{"ok":true}',
|
|
},
|
|
],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "read_file",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { path: { type: "string" } },
|
|
required: ["path"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{ projectId: "proj-claude" } as any
|
|
);
|
|
|
|
assert.equal(result.project, "proj-claude");
|
|
assert.equal(result.userAgent, "antigravity");
|
|
assert.match(result.requestId, /^agent\/\d+\/[0-9a-f]{8}$/);
|
|
assert.deepEqual((result as any).enabledCreditTypes, ["GOOGLE_ONE_AI"]);
|
|
assert.equal(result.request.systemInstruction.parts[0].text, ANTIGRAVITY_DEFAULT_SYSTEM);
|
|
assert.equal(result.request.systemInstruction.parts[1].text, "Project rules");
|
|
assert.equal((result as any).request?.generationConfig.maxOutputTokens, undefined);
|
|
assert.equal((result as any).request?.messages, undefined);
|
|
assert.equal((result as any).request?.system, undefined);
|
|
assert.equal((result as any).request?.max_tokens, undefined);
|
|
assert.equal((result as any).request?.stream, undefined);
|
|
|
|
const modelTurn = result.request.contents.find(
|
|
(content) => content.role === "model" && content.parts.some((part) => part.functionCall)
|
|
);
|
|
assert.ok(modelTurn, "expected a Gemini-compatible model turn");
|
|
const bridgeFunctionCall = getFunctionCall(modelTurn.parts[0]);
|
|
assert.equal(bridgeFunctionCall.name, "read_file");
|
|
assert.deepEqual(bridgeFunctionCall.args, { path: "/tmp/demo" });
|
|
|
|
const toolTurn = result.request.contents.find(
|
|
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
|
|
);
|
|
assert.ok(toolTurn, "expected a Gemini-compatible tool response turn");
|
|
const toolResultBlock = getFunctionResponse(toolTurn.parts[0]);
|
|
assert.equal(toolResultBlock.id, "call_1");
|
|
assert.equal((result as any).request?.tools[0].functionDeclarations[0].name, "read_file");
|
|
});
|
|
|
|
test("OpenAI -> Antigravity Claude path sanitizes tool names for Gemini schema", () => {
|
|
const longToolName =
|
|
"ns:mcp__filesystem__read_multiple_files_with_validation_and_metadata_bundle";
|
|
const result = openaiToAntigravityRequest(
|
|
"claude-3-7-sonnet",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "Read a file" },
|
|
{
|
|
role: "assistant",
|
|
tool_calls: [
|
|
{
|
|
id: "call_long_2",
|
|
type: "function",
|
|
function: { name: longToolName, arguments: '{"path":"/tmp/demo"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_long_2",
|
|
content: '{"ok":true}',
|
|
},
|
|
],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: longToolName,
|
|
parameters: {
|
|
type: "object",
|
|
properties: { path: { type: "string", "x-ui": "hidden" } },
|
|
required: ["path"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
{ projectId: "proj-claude-map" } as any
|
|
);
|
|
|
|
const sanitizedToolName = (result as any).request?.tools[0].functionDeclarations[0].name;
|
|
assert.notEqual(sanitizedToolName, longToolName);
|
|
assert.match(
|
|
sanitizedToolName,
|
|
/^mcp_filesystem_read_multiple_files_with_validation_and__\w{8}$/
|
|
);
|
|
|
|
const modelTurn = result.request.contents.find(
|
|
(content) => content.role === "model" && content.parts.some((part) => part.functionCall)
|
|
);
|
|
assert.ok(modelTurn, "expected a model turn");
|
|
const toolUseBlock = getFunctionCall(modelTurn.parts[0]);
|
|
assert.equal(toolUseBlock.name, sanitizedToolName);
|
|
|
|
const toolTurn = result.request.contents.find(
|
|
(content) => content.role === "user" && content.parts.some((part) => part.functionResponse)
|
|
);
|
|
assert.ok(toolTurn, "expected a tool response turn");
|
|
const toolResultBlock = getFunctionResponse(toolTurn.parts[0]);
|
|
assert.equal(toolResultBlock.id, "call_long_2");
|
|
assert.equal(toolResultBlock.name, sanitizedToolName);
|
|
assert.deepEqual(toolResultBlock.response, { result: { ok: true } });
|
|
});
|
|
|
|
test("OpenAI -> Antigravity Claude path applies output cap and strips thinkingConfig", () => {
|
|
// For Claude on Antigravity, applyAntigravityGenerationDefaults must bump
|
|
// maxOutputTokens to thinkingBudget+1 BEFORE the envelope strips thinkingConfig
|
|
// (because Claude on Cloud Code does not understand Gemini's thinkingConfig
|
|
// shape but still benefits from the larger output cap derived from it).
|
|
const result = openaiToAntigravityRequest(
|
|
"claude-3-7-sonnet",
|
|
{
|
|
messages: [{ role: "user", content: "Summarize this" }],
|
|
max_completion_tokens: 32000,
|
|
reasoning_effort: "high",
|
|
},
|
|
false,
|
|
{ projectId: "proj-claude-thinking" } as any
|
|
);
|
|
|
|
assert.equal((result as any).request?.generationConfig.maxOutputTokens, 32769);
|
|
// thinkingConfig must be stripped for Claude — Cloud Code Claude endpoint
|
|
// does not understand the Gemini-shape thinkingConfig field.
|
|
assert.equal((result as any).request?.generationConfig.thinkingConfig, undefined);
|
|
assert.equal((result as any).request?.max_tokens, undefined);
|
|
assert.equal((result as any).request?.thinking, undefined);
|
|
});
|
|
|
|
test("OpenAI -> Antigravity Claude path preserves lower requested output and strips thinkingConfig", () => {
|
|
const result = openaiToAntigravityRequest(
|
|
"claude-3-7-sonnet",
|
|
{
|
|
messages: [{ role: "user", content: "Short answer" }],
|
|
max_completion_tokens: 1000,
|
|
reasoning_effort: "high",
|
|
},
|
|
false,
|
|
{ projectId: "proj-claude-short" } as any
|
|
);
|
|
|
|
assert.equal((result as any).request?.generationConfig.maxOutputTokens, 32769);
|
|
assert.equal((result as any).request?.generationConfig.thinkingConfig, undefined);
|
|
assert.equal((result as any).request?.max_tokens, undefined);
|
|
assert.equal((result as any).request?.thinking, undefined);
|
|
});
|
|
|
|
test("OpenAI -> Antigravity Gemini path preserves thinkingConfig (only Claude is stripped)", () => {
|
|
// Negative-control for the Claude-thinkingConfig-strip behavior. Gemini models
|
|
// on Antigravity must still receive thinkingConfig — only Claude needs it removed
|
|
// (Cloud Code Claude endpoint does not understand the Gemini-shape field).
|
|
const result = openaiToAntigravityRequest(
|
|
"gemini-2.5-pro",
|
|
{
|
|
messages: [{ role: "user", content: "Summarize this" }],
|
|
max_completion_tokens: 32000,
|
|
reasoning_effort: "high",
|
|
},
|
|
false,
|
|
{ projectId: "proj-gemini-thinking" } as any
|
|
);
|
|
|
|
// For Gemini, thinkingConfig must remain in place because the Cloud Code
|
|
// Gemini endpoint understands and uses it.
|
|
assert.ok(
|
|
(result as any).request?.generationConfig.thinkingConfig,
|
|
"thinkingConfig must be preserved for Gemini models on Antigravity"
|
|
);
|
|
assert.equal((result as any).request?.generationConfig.thinkingConfig.thinkingBudget > 0, true);
|
|
assert.equal((result as any).request?.generationConfig.thinkingConfig.includeThoughts, true);
|
|
});
|
|
|
|
// Regression for #2480: when projectId is stored in providerSpecificData rather than at
|
|
// the top level of the credential record, the Antigravity Cloud Code envelope must still
|
|
// pick it up — otherwise the /v1beta path 422s with "Missing Google projectId".
|
|
test("openaiToAntigravityRequest falls back to providerSpecificData.projectId (#2480)", () => {
|
|
const result = openaiToAntigravityRequest(
|
|
"gemini-3.1-flash-lite",
|
|
{ messages: [{ role: "user", content: "Hello" }] },
|
|
false,
|
|
{ providerSpecificData: { projectId: "proj-from-psd" } } as any
|
|
);
|
|
assert.equal(result.project, "proj-from-psd");
|
|
});
|
|
|
|
test("openaiToAntigravityRequest prefers top-level projectId over providerSpecificData (#2480)", () => {
|
|
const result = openaiToAntigravityRequest(
|
|
"gemini-3.1-flash-lite",
|
|
{ messages: [{ role: "user", content: "Hello" }] },
|
|
false,
|
|
{ projectId: "proj-top", providerSpecificData: { projectId: "proj-psd" } } as any
|
|
);
|
|
assert.equal(result.project, "proj-top");
|
|
});
|
|
|
|
// Regression for #2515: a PDF sent in the Responses-API `input_file` shape must reach
|
|
// Gemini as inlineData instead of being silently dropped.
|
|
test("convertOpenAIContentToParts handles input_file file_data (#2515)", () => {
|
|
const parts = convertOpenAIContentToParts([
|
|
{ type: "input_file", file_data: "JVBERi0xLjcKJ", filename: "doc.pdf" },
|
|
]);
|
|
const inline = parts.find((p) => (p as any).inlineData);
|
|
assert.ok(inline, "input_file with file_data must produce an inlineData part");
|
|
assert.equal((inline as any).inlineData.data, "JVBERi0xLjcKJ");
|
|
});
|
|
|
|
test("convertOpenAIContentToParts handles input_file file_url data URI (#2515)", () => {
|
|
const parts = convertOpenAIContentToParts([
|
|
{ type: "input_file", file_url: "data:application/pdf;base64,QUJD", filename: "d.pdf" },
|
|
]);
|
|
const inline = parts.find((p) => (p as any).inlineData);
|
|
assert.ok(inline, "input_file with file_url data URI must produce an inlineData part");
|
|
assert.equal((inline as any).inlineData.data, "QUJD");
|
|
assert.equal((inline as any).inlineData.mimeType, "application/pdf");
|
|
});
|
|
|
|
test("convertOpenAIContentToParts handles rec.image with nested {url} as base64 data URI (#2807)", () => {
|
|
const parts = convertOpenAIContentToParts([
|
|
{ type: "text", text: "What's this?" },
|
|
{ type: "image", image: { url: "data:image/png;base64,iVBORw0KGgo=" } },
|
|
]);
|
|
const inline = parts.find((p) => (p as any).inlineData);
|
|
assert.ok(
|
|
inline,
|
|
"rec.image with nested {url} must produce an inlineData part (was previously silently dropped)"
|
|
);
|
|
assert.equal((inline as any).inlineData.data, "iVBORw0KGgo=");
|
|
assert.equal((inline as any).inlineData.mimeType, "image/png");
|
|
});
|
|
|
|
test("convertOpenAIContentToParts passes remote http(s) image_url URLs through as fileData (#4373; was warn-and-drop #2807)", () => {
|
|
const parts = convertOpenAIContentToParts([
|
|
{ type: "image_url", image_url: { url: "https://example.com/cat.png" } },
|
|
]);
|
|
// Remote URLs cannot be base64-inlined by this synchronous function, but Gemini's
|
|
// Part schema accepts `fileData: { fileUri }` for HTTP/HTTPS sources — so the URL
|
|
// is passed through (the model fetches it) instead of being dropped (#4373).
|
|
assert.equal(
|
|
parts.find((p) => (p as any).inlineData),
|
|
undefined,
|
|
"remote URL is not base64-inlined (sync function)"
|
|
);
|
|
assert.deepEqual(parts, [
|
|
{ fileData: { fileUri: "https://example.com/cat.png", mimeType: "image/*" } },
|
|
]);
|
|
});
|
|
|
|
test("convertOpenAIContentToParts passes remote rec.image http(s) URLs through as fileData (#4373; was warn-and-drop #2807)", () => {
|
|
// rec.image is the alternative content shape emitted by MCP tool wrappers and
|
|
// LangChain shim layers. Remote URLs in this shape now also pass through as
|
|
// Gemini `fileData: { fileUri }` (#4373) instead of being dropped.
|
|
const parts = convertOpenAIContentToParts([
|
|
{ type: "image", image: { url: "https://example.com/remote.png" } },
|
|
]);
|
|
assert.equal(
|
|
parts.find((p) => (p as any).inlineData),
|
|
undefined,
|
|
"rec.image remote URL is not base64-inlined (sync function cannot fetch)"
|
|
);
|
|
assert.deepEqual(parts, [
|
|
{ fileData: { fileUri: "https://example.com/remote.png", mimeType: "image/*" } },
|
|
]);
|
|
});
|
|
|
|
// Regression for #2504: with credentials._signatureNamespace set, a previously-cached
|
|
// Gemini thoughtSignature must be re-attached to the functionCall on the follow-up turn.
|
|
test("openaiToGeminiRequest re-attaches cached thoughtSignature for FORMATS.GEMINI (#2504)", async () => {
|
|
const { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature } =
|
|
await import("../../open-sse/services/geminiThoughtSignatureStore.ts");
|
|
const ns = "conn-2504";
|
|
const toolId = "call_2504_abc";
|
|
storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId), "SIG_2504_XYZ");
|
|
|
|
const result: any = openaiToGeminiRequest(
|
|
"gemini-2.5-pro-preview",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "run a tool" },
|
|
{
|
|
role: "assistant",
|
|
tool_calls: [
|
|
{ id: toolId, type: "function", function: { name: "Bash", arguments: '{"cmd":"ls"}' } },
|
|
],
|
|
},
|
|
{ role: "tool", tool_call_id: toolId, content: "ok" },
|
|
],
|
|
},
|
|
false,
|
|
{ _signatureNamespace: ns }
|
|
);
|
|
|
|
const json = JSON.stringify(result);
|
|
assert.ok(
|
|
json.includes("SIG_2504_XYZ"),
|
|
"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);
|
|
// gemini-2.0-flash-thinking carries no registry cap, so the raw 32768 base is
|
|
// passed through unchanged (capThinkingBudget no-ops without a thinkingBudgetCap).
|
|
assert.equal((result as any).generationConfig.thinkingConfig.thinkingBudget, 32768);
|
|
});
|
|
|
|
// Regression for #3842: reasoning_effort=high must not exceed a Gemini model's real
|
|
// thinking-budget cap. gemini-2.5-flash's true upstream max is 24576; sending 32768
|
|
// makes the upstream return HTTP 400. The modelSpecs thinkingBudgetCap now clamps it
|
|
// at the capThinkingBudget chokepoint, matching the thinkingLevel=high path (24576).
|
|
test("OpenAI -> Gemini reasoning_effort=high stays within gemini-2.5-flash cap (#3842)", () => {
|
|
const result = openaiToGeminiRequest(
|
|
"gemini-2.5-flash",
|
|
{
|
|
messages: [{ role: "user", content: "Solve this complex puzzle" }],
|
|
reasoning_effort: "high",
|
|
},
|
|
false
|
|
) as any;
|
|
const budget = result.generationConfig.thinkingConfig.thinkingBudget;
|
|
assert.ok(budget <= 24576, `expected <= 24576 (real cap), got ${budget}`);
|
|
assert.equal(budget, 24576);
|
|
});
|
|
|
|
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"
|
|
);
|
|
});
|
|
|
|
// Regression: historical tool-call text must use compact [tool_history_call:] format,
|
|
// not the old multi-line "Historical tool-call record only..." that leaked into output.
|
|
test("text-mode assistant tool_calls produce [tool_history_call:] format, not the old leaky text", () => {
|
|
const result = openaiToGeminiRequest(
|
|
"gemini-2.0-flash",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "What is the weather?" },
|
|
{
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
id: "call_tc001",
|
|
type: "function",
|
|
function: { name: "get_weather", arguments: '{"location":"Tokyo"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_tc001",
|
|
content: '{"temp":"22°C"}',
|
|
},
|
|
{ role: "user", content: "Summarize" },
|
|
],
|
|
},
|
|
false,
|
|
null,
|
|
{ signaturelessToolCallMode: "text" }
|
|
);
|
|
|
|
const body = JSON.stringify(result);
|
|
assert.ok(
|
|
body.includes("[tool_history_call: get_weather]"),
|
|
"expected compact [tool_history_call:] format for text-mode tool calls"
|
|
);
|
|
assert.ok(
|
|
body.includes("[tool_history_result: get_weather]"),
|
|
"expected compact [tool_history_result:] format for text-mode tool responses"
|
|
);
|
|
assert.equal(
|
|
body.includes("Historical tool-call record only"),
|
|
false,
|
|
"old leaky 'Historical tool-call record only' text must NOT appear"
|
|
);
|
|
assert.equal(
|
|
body.includes("Historical tool-response record only"),
|
|
false,
|
|
"old leaky 'Historical tool-response record only' text must NOT appear"
|
|
);
|
|
});
|
|
|
|
test("text-mode tool calls without credentials produce compact format, no thoughtSignature derail", () => {
|
|
const result = openaiToGeminiRequest(
|
|
"gemini-2.0-flash",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "Run a tool" },
|
|
{
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
id: "call_tc002",
|
|
type: "function",
|
|
function: { name: "search_web", arguments: '{"q":"latest news"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_tc002",
|
|
content: "Some results",
|
|
},
|
|
{ role: "user", content: "Tell me more" },
|
|
],
|
|
},
|
|
false,
|
|
null, // no credentials — no thought signatures at all
|
|
{ signaturelessToolCallMode: "text" }
|
|
);
|
|
|
|
const body = JSON.stringify(result);
|
|
assert.ok(body.includes("[tool_history_call: search_web]"), "compact format without credentials");
|
|
assert.ok(
|
|
body.includes("[tool_history_result: search_web]"),
|
|
"compact format for tool response without credentials"
|
|
);
|
|
});
|
|
|
|
test("native-mode assistant tool_calls produce functionCall parts, not text labels", () => {
|
|
const result = openaiToGeminiRequest(
|
|
"gemini-2.0-flash",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "Get weather" },
|
|
{
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
id: "call_nat001",
|
|
type: "function",
|
|
function: { name: "get_weather", arguments: '{"location":"Paris"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_nat001",
|
|
content: '{"temp":"18°C"}',
|
|
},
|
|
{ role: "user", content: "Now what?" },
|
|
],
|
|
},
|
|
false,
|
|
null,
|
|
{ signaturelessToolCallMode: "native" }
|
|
);
|
|
|
|
const modelTurn = result.contents.find(
|
|
(c) => c.role === "model" && c.parts?.some((p) => p.functionCall)
|
|
);
|
|
assert.ok(modelTurn, "expected model turn with functionCall in native mode");
|
|
const body = JSON.stringify(result);
|
|
assert.equal(
|
|
body.includes("[tool_history_call:"),
|
|
false,
|
|
"native mode must NOT produce text labels"
|
|
);
|
|
});
|
|
|
|
// Integration: registered translator (OPENAI -> GEMINI) uses context-mode for
|
|
// signature-less tool calls (post-#3688 fix: "native" → "context" registration).
|
|
// A signature-less functionCall must be omitted from native parts and represented
|
|
// as context text so the standard Gemini API does not return HTTP 400.
|
|
// For a SIGNED call (signature in store) native parts must still be emitted — see
|
|
// the "keeps native functionCall+thoughtSignature when signature is present" test.
|
|
test("registered OPENAI->GEMINI translator uses context-mode for signature-less tool calls, not text labels or native bare functionCall", () => {
|
|
const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI);
|
|
assert.ok(typeof translate === "function", "registered translator must be a function");
|
|
|
|
// No signature in store (cleared by beforeEach) — context-mode fallback applies.
|
|
const result = translate(
|
|
"gemini-2.0-flash",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "Look up weather" },
|
|
{
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
id: "call_reg001",
|
|
type: "function",
|
|
function: { name: "get_weather", arguments: '{"location":"Berlin"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_reg001",
|
|
content: '{"temp":"15°C","condition":"cloudy"}',
|
|
},
|
|
{ role: "user", content: "Short summary" },
|
|
],
|
|
},
|
|
false
|
|
) as any;
|
|
|
|
const body = JSON.stringify(result);
|
|
// Context mode: signature-less functionCall must NOT appear as a native part.
|
|
assert.equal(
|
|
body.includes('"functionCall"'),
|
|
false,
|
|
"registered translator must NOT emit bare native functionCall parts for signature-less calls (would trigger HTTP 400)"
|
|
);
|
|
assert.equal(
|
|
body.includes('"functionResponse"'),
|
|
false,
|
|
"registered translator must NOT emit native functionResponse for signature-less calls"
|
|
);
|
|
// Old leaky text labels must never appear.
|
|
assert.equal(
|
|
body.includes("Historical tool-call record only"),
|
|
false,
|
|
"old leaky format must NOT appear in registered translator output"
|
|
);
|
|
assert.equal(
|
|
body.includes("Historical tool-response record only"),
|
|
false,
|
|
"old leaky response format must NOT appear"
|
|
);
|
|
assert.equal(
|
|
body.includes("[tool_history_call:"),
|
|
false,
|
|
"text-label format must NOT be used by registered GEMINI translator"
|
|
);
|
|
// Context-mode: tool result must appear as <previous_tool_result_context> block.
|
|
assert.ok(
|
|
body.includes("previous_tool_result_context"),
|
|
"signature-less tool result must be represented as context text block"
|
|
);
|
|
});
|
|
|
|
// Regression for #3688: standard Gemini (AI Studio) returns HTTP 400
|
|
// "Function call is missing a thought_signature" when a multi-turn conversation
|
|
// includes a functionCall whose signature was not captured (process restart / TTL
|
|
// expiry / never stored). The standard GEMINI registration must use "context" mode
|
|
// so signature-less tool calls are omitted from the native parts and represented as
|
|
// context text, avoiding the 400 while preserving conversational continuity.
|
|
test("registered OPENAI->GEMINI translator falls back to context mode for signatureless tool calls (#3688)", () => {
|
|
// Signature store is cleared by beforeEach — no stored signature for call_3688.
|
|
const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI);
|
|
assert.ok(typeof translate === "function", "registered translator must be a function");
|
|
|
|
const result = translate(
|
|
"gemini-2.5-pro-preview",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "Run a tool" },
|
|
{
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
id: "call_3688_missing_sig",
|
|
type: "function",
|
|
function: { name: "bash", arguments: '{"cmd":"ls /tmp"}' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
tool_call_id: "call_3688_missing_sig",
|
|
content: "file-a.txt\nfile-b.txt",
|
|
},
|
|
{ role: "user", content: "What files did you see?" },
|
|
],
|
|
},
|
|
false,
|
|
{ _signatureNamespace: "conn-3688-test" }
|
|
) as any;
|
|
|
|
const body = JSON.stringify(result);
|
|
|
|
// (a) NO functionCall part lacking a thoughtSignature must appear.
|
|
// A functionCall without a thoughtSignature is the exact payload that triggers
|
|
// Gemini's HTTP 400 "Function call is missing a thought_signature".
|
|
const modelTurn = result.contents.find(
|
|
(c: any) => c.role === "model" && c.parts?.some((p: any) => p.functionCall)
|
|
);
|
|
assert.equal(
|
|
modelTurn,
|
|
undefined,
|
|
"standard GEMINI translator must NOT emit a functionCall part when thoughtSignature is absent (would trigger HTTP 400)"
|
|
);
|
|
|
|
// (b) The tool call/result must be represented as context/text fallback instead.
|
|
// In "context" mode the response is wrapped in <previous_tool_result_context> tags.
|
|
assert.ok(
|
|
body.includes("previous_tool_result_context"),
|
|
"signature-less tool result must be represented as a context text block when signature is absent"
|
|
);
|
|
assert.ok(
|
|
body.includes("file-a.txt"),
|
|
"context text block must contain the tool response content"
|
|
);
|
|
});
|
|
|
|
// Happy-path: when thoughtSignature IS present in the store, the registered
|
|
// OPENAI->GEMINI translator must still emit native functionCall + thoughtSignature
|
|
// (no regression on the signed-signature path fixed by #2504).
|
|
test("registered OPENAI->GEMINI translator keeps native functionCall+thoughtSignature when signature is present (#2504 no-regression)", async () => {
|
|
const { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature } =
|
|
await import("../../open-sse/services/geminiThoughtSignatureStore.ts");
|
|
const ns = "conn-3688-signed-happy";
|
|
const toolId = "call_3688_signed";
|
|
storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId), "SIG_3688_HAPPY_PATH");
|
|
|
|
const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI);
|
|
const result = translate(
|
|
"gemini-2.5-pro-preview",
|
|
{
|
|
messages: [
|
|
{ role: "user", content: "Run a tool" },
|
|
{
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
id: toolId,
|
|
type: "function",
|
|
function: { name: "bash", arguments: '{"cmd":"echo hi"}' },
|
|
},
|
|
],
|
|
},
|
|
{ role: "tool", tool_call_id: toolId, content: "hi" },
|
|
{ role: "user", content: "What did it say?" },
|
|
],
|
|
},
|
|
false,
|
|
{ _signatureNamespace: ns }
|
|
) as any;
|
|
|
|
const body = JSON.stringify(result);
|
|
|
|
// The functionCall part WITH the thoughtSignature must be present.
|
|
assert.ok(
|
|
body.includes("SIG_3688_HAPPY_PATH"),
|
|
"cached thoughtSignature must be re-attached to the functionCall (happy-path regression check)"
|
|
);
|
|
assert.ok(
|
|
body.includes('"functionCall"'),
|
|
"signed tool call must be emitted as native functionCall (not context text)"
|
|
);
|
|
assert.ok(
|
|
body.includes('"functionResponse"'),
|
|
"signed tool response must be emitted as native functionResponse"
|
|
);
|
|
assert.equal(
|
|
body.includes("previous_tool_result_context"),
|
|
false,
|
|
"signed tool call must NOT fall back to context text"
|
|
);
|
|
});
|