fix(translator): preserve falsy primitive values in Gemini and Antigravity function response results (#12191)

This commit is contained in:
MSiva
2026-08-31 22:40:44 +05:30
committed by GitHub
parent 90366903c4
commit 9392bd55c2
4 changed files with 201 additions and 19 deletions

View File

@@ -220,12 +220,15 @@ function preserveRequired(obj: unknown): void {
return;
}
const record = obj as JsonRecord;
if (Array.isArray(record.required) && record.properties && typeof record.properties === "object") {
if (
Array.isArray(record.required) &&
record.properties &&
typeof record.properties === "object"
) {
const properties = record.properties as JsonRecord;
const valid = (record.required as unknown[]).filter(
(field) =>
typeof field === "string" &&
Object.prototype.hasOwnProperty.call(properties, field)
typeof field === "string" && Object.prototype.hasOwnProperty.call(properties, field)
);
if (valid.length === 0) {
delete record.required;
@@ -298,12 +301,13 @@ function convertContent(content) {
// Function response → collect all, each becomes a separate tool message
if (part.functionResponse) {
const resp = part.functionResponse.response;
const resultPayload =
resp && typeof resp === "object" && "result" in resp ? resp.result : (resp ?? {});
toolResults.push({
role: "tool",
tool_call_id: part.functionResponse.id || part.functionResponse.name,
content: JSON.stringify(
part.functionResponse.response?.result || part.functionResponse.response || {}
),
content: JSON.stringify(resultPayload),
});
}
}
@@ -316,9 +320,7 @@ function convertContent(content) {
const assistantMsg: JsonRecord = { role: "assistant" };
if (textParts.length > 0) {
assistantMsg.content =
textParts.length === 1 && textParts[0].type === "text"
? textParts[0].text
: textParts;
textParts.length === 1 && textParts[0].type === "text" ? textParts[0].text : textParts;
}
if (reasoningContent) {
assistantMsg.reasoning_content = reasoningContent;

View File

@@ -147,12 +147,13 @@ function convertGeminiContent(content) {
}
if (part.functionResponse) {
const resp = part.functionResponse.response;
const resultPayload =
resp && typeof resp === "object" && "result" in resp ? resp.result : (resp ?? {});
return {
role: "tool",
tool_call_id: part.functionResponse.id || part.functionResponse.name,
content: JSON.stringify(
part.functionResponse.response?.result || part.functionResponse.response || {}
),
content: JSON.stringify(resultPayload),
};
}
}

View File

@@ -162,7 +162,13 @@ test("Antigravity -> OpenAI keeps co-located function call and text but strips t
role: "model",
parts: [
{ text: "Let me look that up." },
{ functionResponse: { id: "call_9", name: "lookup", response: { result: { ok: true } } } },
{
functionResponse: {
id: "call_9",
name: "lookup",
response: { result: { ok: true } },
},
},
{ functionCall: { id: "call_10", name: "lookup", args: { q: "weather" } } },
],
},
@@ -249,7 +255,7 @@ test("Antigravity -> OpenAI lowers schema types recursively", () => {
false
);
assert.deepEqual((result.tools[0].function as any).parameters, {
assert.deepEqual((result.tools[0].function as Record<string, unknown>).parameters, {
type: "object",
properties: {
items: {
@@ -298,12 +304,17 @@ test("Antigravity -> OpenAI strips enumDescriptions from tool schema (top-level
false
);
const parameters = (result.tools[0].function as any).parameters;
const parameters = (result.tools[0].function as Record<string, unknown>).parameters as Record<
string,
unknown
>;
const props = parameters.properties as Record<string, unknown>;
const tags = props.tags as Record<string, unknown>;
// enumDescriptions must be removed at every level of the schema tree...
assert.equal("enumDescriptions" in parameters, false);
assert.equal("enumDescriptions" in parameters.properties.mode, false);
assert.equal("enumDescriptions" in parameters.properties.tags.items, false);
assert.equal("enumDescriptions" in (props.mode as Record<string, unknown>), false);
assert.equal("enumDescriptions" in (tags.items as Record<string, unknown>), false);
// ...while leaving the rest of the schema (incl. enum values) intact.
assert.deepEqual(parameters, {
@@ -349,7 +360,10 @@ test("Antigravity -> OpenAI preserves the required array on Draft 2020-12 tool s
false
);
const params = (result.tools[0].function as any).parameters;
const params = (result.tools[0].function as Record<string, unknown>).parameters as Record<
string,
unknown
>;
// The required array must survive so the model treats mandatory args as mandatory.
assert.deepEqual(params.required, ["path", "contents"]);
// Types are still lowered and Draft 2020-12 meta keywords are stripped.
@@ -385,6 +399,104 @@ test("Antigravity -> OpenAI drops required entries that no longer exist in prope
false
);
const params = (result.tools[0].function as any).parameters;
const params = (result.tools[0].function as Record<string, unknown>).parameters as Record<
string,
unknown
>;
assert.deepEqual(params.required, ["kept"]);
});
test("Antigravity -> OpenAI preserves falsy primitive results in function responses (false, 0, empty string, null)", () => {
const cases: Array<[unknown, string]> = [
[false, "false"],
[0, "0"],
["", '""'],
[null, "null"],
[true, "true"],
[42, "42"],
["done", '"done"'],
];
for (const [inputVal, expected] of cases) {
const result = antigravityToOpenAIRequest(
"gpt-4o",
{
request: {
contents: [
{
role: "model",
parts: [
{
functionCall: {
id: "call_test",
name: "check_condition",
args: {},
},
},
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: "call_test",
name: "check_condition",
response: { result: inputVal },
},
},
],
},
],
},
},
false
);
const toolMsg = result.messages.find((m) => m.role === "tool");
assert.ok(toolMsg, "expected role:tool message");
assert.equal(toolMsg.tool_call_id, "call_test");
assert.equal(toolMsg.content, expected);
}
});
test("Antigravity -> OpenAI preserves custom response objects without result key", () => {
const result = antigravityToOpenAIRequest(
"gpt-4o",
{
request: {
contents: [
{
role: "model",
parts: [
{
functionCall: {
id: "call_custom",
name: "custom_op",
args: {},
},
},
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: "call_custom",
name: "custom_op",
response: { output: "value", success: false },
},
},
],
},
],
},
},
false
);
const toolMsg = result.messages.find((m) => m.role === "tool");
assert.ok(toolMsg, "expected role:tool message");
assert.equal(toolMsg.content, '{"output":"value","success":false}');
});

View File

@@ -237,3 +237,70 @@ test("Gemini -> OpenAI maintains matching IDs across multi-turn tool call and re
assert.equal(toolResponseCallId, "call_calc_456");
assert.equal(assistantCallId, toolResponseCallId);
});
test("Gemini -> OpenAI preserves falsy primitive results in function responses (false, 0, empty string, null)", () => {
const cases: Array<[unknown, string]> = [
[false, "false"],
[0, "0"],
["", '""'],
[null, "null"],
[true, "true"],
[42, "42"],
["done", '"done"'],
];
for (const [inputVal, expected] of cases) {
const result = geminiToOpenAIRequest(
"gpt-4o",
{
contents: [
{
role: "user",
parts: [
{
functionResponse: {
id: "call_test",
name: "check_condition",
response: { result: inputVal },
},
},
],
},
],
},
false
);
assert.equal(result.messages.length, 1);
assert.equal(result.messages[0].role, "tool");
assert.equal(result.messages[0].tool_call_id, "call_test");
assert.equal(result.messages[0].content, expected);
}
});
test("Gemini -> OpenAI preserves custom response objects without result key", () => {
const result = geminiToOpenAIRequest(
"gpt-4o",
{
contents: [
{
role: "user",
parts: [
{
functionResponse: {
id: "call_custom",
name: "custom_op",
response: { output: "value", success: false },
},
},
],
},
],
},
false
);
assert.equal(result.messages.length, 1);
assert.equal(result.messages[0].role, "tool");
assert.equal(result.messages[0].content, '{"output":"value","success":false}');
});