fix(ai): hash foreign responses tool item ids

This commit is contained in:
Mario Zechner
2026-03-22 05:40:26 +01:00
parent 235b247f1f
commit b21b42d032
3 changed files with 79 additions and 3 deletions

View File

@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed OpenAI Responses replay for foreign tool-call item IDs by hashing foreign `function_call.id` values into bounded `fc_<hash>` IDs instead of preserving backend-specific normalized shapes that OpenAI Codex rejects.
## [0.61.1] - 2026-03-20
### Changed

View File

@@ -97,14 +97,20 @@ export function convertResponsesMessages<TApi extends Api>(
return normalized.replace(/_+$/, "");
};
const normalizeToolCallId = (id: string): string => {
const buildForeignResponsesItemId = (itemId: string): string => {
const normalized = `fc_${shortHash(itemId)}`;
return normalized.length > 64 ? normalized.slice(0, 64) : normalized;
};
const normalizeToolCallId = (id: string, _targetModel: Model<TApi>, source: AssistantMessage): string => {
if (!allowedToolCallProviders.has(model.provider)) return normalizeIdPart(id);
if (!id.includes("|")) return normalizeIdPart(id);
const [callId, itemId] = id.split("|");
const normalizedCallId = normalizeIdPart(callId);
let normalizedItemId = normalizeIdPart(itemId);
const isForeignToolCall = source.provider !== model.provider || source.api !== model.api;
let normalizedItemId = isForeignToolCall ? buildForeignResponsesItemId(itemId) : normalizeIdPart(itemId);
// OpenAI Responses API requires item id to start with "fc"
if (!normalizedItemId.startsWith("fc")) {
if (!normalizedItemId.startsWith("fc_")) {
normalizedItemId = normalizeIdPart(`fc_${normalizedItemId}`);
}
return `${normalizedCallId}|${normalizedItemId}`;

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { convertResponsesMessages } from "../src/providers/openai-responses-shared.js";
import type { AssistantMessage, Context, ToolResultMessage, Usage } from "../src/types.js";
import { shortHash } from "../src/utils/hash.js";
const COPILOT_RAW_TOOL_CALL_ID =
"call_4VnzVawQXPB9MgYib7CiQFEY|I9b95oN1wD/cHXKTw3PpRkL6KkCtzTJhUxMouMWYwHeTo2j3htzfSk7YPx2vifiIM4g3A8XXyOj8q4Bt6SLUG7gqY1E3ELkrkVQNHglRfUmWj84lqxJY+Puieb3VKyX0FB+83TUzn91cDMF/4gzt990IzqVrc+nIb9RRscRD070Du16q1glydVjWR0SBJsE6TbY/esOjFpqplogQqrajm1eI++f3eLi73R6q7hVusY0QbeFySVxABCjhN0lXB04caBe1rzHjYzul6MAXj7uq+0r17VLq+yrtyYhN12wkmFqHeqTyEei6EFPbMy24Nc+IbJlkP0OCg02W+gOnyBFcbi2ctvJFSOhSjt1CqBdqCnnhwUqXjbWiT0wh3DmLScRgTHmGkaI+oAcQQjfic65nxj+TnEkReA==";
const usage: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
describe("OpenAI Responses foreign tool call ID normalization", () => {
it("hashes foreign Copilot tool item IDs into a bounded Codex-safe fc_<hash> shape", () => {
const model = getModel("openai-codex", "gpt-5.3-codex");
const assistant: AssistantMessage = {
role: "assistant",
content: [
{
type: "toolCall",
id: COPILOT_RAW_TOOL_CALL_ID,
name: "edit",
arguments: { path: "src/styles/app.css" },
},
],
api: "openai-responses",
provider: "github-copilot",
model: "gpt-5.3-codex",
usage,
stopReason: "toolUse",
timestamp: Date.now() - 2000,
};
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: COPILOT_RAW_TOOL_CALL_ID,
toolName: "edit",
content: [{ type: "text", text: "ok" }],
isError: false,
timestamp: Date.now() - 1000,
};
const context: Context = {
systemPrompt: "You are concise.",
messages: [{ role: "user", content: "Use the tool.", timestamp: Date.now() - 3000 }, assistant, toolResult],
};
const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"]));
const functionCall = input.find((item) => item.type === "function_call");
expect(functionCall).toBeDefined();
expect(functionCall?.type).toBe("function_call");
if (!functionCall || functionCall.type !== "function_call") {
throw new Error("Expected function_call item");
}
const expectedItemId = `fc_${shortHash(COPILOT_RAW_TOOL_CALL_ID.split("|")[1]!)}`;
expect(functionCall.id).toBe(expectedItemId);
expect(functionCall.id?.length ?? 0).toBeLessThanOrEqual(64);
expect(functionCall.id).toMatch(/^fc_[A-Za-z0-9]+$/);
});
});