Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 124 additions & 3 deletions apps/gateway/src/chat/tools/transform-response-to-openai.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";

import {
applyExtendedUsageFields,
Expand All @@ -7,6 +7,23 @@ import {
withCurrentRequestMetadataOnOpenAiResponse,
} from "./transform-response-to-openai.js";

const { setexMock } = vi.hoisted(() => ({
setexMock: vi.fn().mockResolvedValue("OK"),
}));

vi.mock("@llmgateway/cache", () => ({
redisClient: {
setex: setexMock,
},
}));

vi.mock("@llmgateway/logger", () => ({
logger: {
warn: vi.fn(),
error: vi.fn(),
},
}));

describe("transformResponseToOpenai", () => {
test("includes request_id in response metadata", () => {
const response = transformResponseToOpenai(
Expand Down Expand Up @@ -259,24 +276,128 @@ describe("transformResponseToOpenai", () => {
expect(response.choices).toHaveLength(2);
expect(response.choices[0].message.tool_calls).toHaveLength(1);
expect(response.choices[0].message.tool_calls[0]).toMatchObject({
id: "get_weather_0_0",
function: {
name: "get_weather",
arguments: JSON.stringify({ city: "Paris" }),
},
});
expect(response.choices[0].message.tool_calls[0].id).toMatch(
/^get_weather_/,
);
expect(response.choices[0].finish_reason).toBe("tool_calls");
expect(response.choices[1].message.tool_calls).toHaveLength(1);
expect(response.choices[1].message.tool_calls[0]).toMatchObject({
id: "get_weather_1_0",
function: {
name: "get_weather",
arguments: JSON.stringify({ city: "Rome" }),
},
});
expect(response.choices[1].message.tool_calls[0].id).toMatch(
/^get_weather_/,
);
expect(response.choices[1].message.tool_calls[0].id).not.toBe(
response.choices[0].message.tool_calls[0].id,
);
expect(response.choices[1].finish_reason).toBe("tool_calls");
});

test("keeps multi-candidate Google tool_call ids in the thought_signature cache", () => {
setexMock.mockClear();
const json = {
candidates: [
{
content: {
parts: [
{
functionCall: { name: "get_weather", args: { city: "Paris" } },
thoughtSignature: "sig-candidate-0",
},
],
role: "model",
},
finishReason: "STOP",
index: 0,
},
{
content: {
parts: [
{
functionCall: { name: "get_weather", args: { city: "Rome" } },
thoughtSignature: "sig-candidate-1",
},
],
role: "model",
},
finishReason: "STOP",
index: 1,
},
],
};
// What parseProviderResponse emits for candidate 0: a unique
// `${name}_${shortid(24)}` id plus the inline signature, with the
// signature cached in Redis under `thought_signature:<id>`.
const parsedToolCalls = [
{
id: "get_weather_cachedid0",
type: "function",
function: {
name: "get_weather",
arguments: JSON.stringify({ city: "Paris" }),
},
extra_content: {
google: { thought_signature: "sig-candidate-0" },
},
},
];

const response = transformResponseToOpenai(
"google-ai-studio",
"gemini-2.5-flash",
json,
null,
null,
"STOP",
10,
20,
30,
null,
null,
parsedToolCalls,
[],
"gemini-2.5-flash",
null,
"gemini-2.5-flash",
null,
false,
null,
null,
"req_google_n_tools_signature",
);

expect(response.choices).toHaveLength(2);
// Choice 0 reuses the tool calls from parseProviderResponse, so the id
// the client sends back is the one the signature was cached under.
expect(response.choices[0].message.tool_calls).toEqual(parsedToolCalls);
// Candidates parse did not process get the same treatment: a unique
// id, the inline signature, and a Redis entry under the emitted id.
const choice1ToolCall = response.choices[1].message.tool_calls[0];
expect(choice1ToolCall.id).toMatch(/^get_weather_/);
expect(choice1ToolCall.id).not.toBe(parsedToolCalls[0].id);
expect(choice1ToolCall.extra_content).toEqual({
google: { thought_signature: "sig-candidate-1" },
});
expect(setexMock).toHaveBeenCalledWith(
`thought_signature:${choice1ToolCall.id}`,
86400,
"sig-candidate-1",
);
expect(setexMock).not.toHaveBeenCalledWith(
`thought_signature:${parsedToolCalls[0].id}`,
expect.anything(),
expect.anything(),
);
});

test("does not overwrite choice 0 content on multi-choice OpenAI responses", () => {
const json = {
id: "chatcmpl-multi",
Expand Down
63 changes: 53 additions & 10 deletions apps/gateway/src/chat/tools/transform-response-to-openai.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { redisClient } from "@llmgateway/cache";
import { shortid } from "@llmgateway/db";
import { logger } from "@llmgateway/logger";

import { dedupeGoogleCandidateParts } from "./google-candidates.js";
import { mapFinishReasonToOpenai } from "./map-finish-reason-to-openai.js";
import { formatUsedModelForDisplay } from "./resolve-provider-context.js";
Expand Down Expand Up @@ -450,16 +454,55 @@ export function transformResponseToOpenai(
const candidateIndex = candidate.index ?? position;
const candidateToolCalls = candidateParts
.filter((part: any) => part.functionCall)
.map((part: any, fcIndex: number) => ({
// Same id scheme as parse-provider-response so choice 0's
// ids line up with the cached thought signatures.
id: `${part.functionCall.name}_${candidateIndex}_${fcIndex}`,
type: "function",
function: {
name: part.functionCall.name,
arguments: JSON.stringify(part.functionCall.args ?? {}),
},
}));
.map((part: any, fcIndex: number) => {
// parseProviderResponse only processes candidate 0, and it
// caches the thought signature under the id it emits
// (`${name}_${shortid(24)}`, as `thought_signature:<id>` in
// Redis). Reuse those exact tool calls so the id the client
// echoes back on the next turn is the one the signature is
// cached under. Regenerating a name+index id here (the scheme
// parse abandoned in #1448) made the Redis lookup miss and
// Gemini reject the replay with "Corrupted thought signature".
// Candidates parse did not process (1+) get the same
// treatment inline: unique id, inline signature, and the
// Redis entry under the emitted id.
if (
position === 0 &&
Array.isArray(toolResults) &&
toolResults[fcIndex]
) {
return toolResults[fcIndex];
}
const toolCall: any = {
id: `${part.functionCall.name}_${shortid(24)}`,
type: "function",
function: {
name: part.functionCall.name,
arguments: JSON.stringify(part.functionCall.args ?? {}),
},
};
if (part.thoughtSignature) {
toolCall.extra_content = {
google: {
thought_signature: part.thoughtSignature,
},
};
// Same cache as parse-provider-response: the id is the
// `thought_signature:<id>` key read back on the next turn.
redisClient
.setex(
`thought_signature:${toolCall.id}`,
86400,
part.thoughtSignature,
)
.catch((err) => {
logger.error("Failed to cache thought_signature", {
err,
});
});
}
return toolCall;
});
return {
index: candidateIndex,
message: {
Expand Down
Loading