diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index 8034502c0d7..1600381f599 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -23,6 +23,7 @@ import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { isMcpTool } from "../../utils/mcp-name" +import { sanitizeOpenAiCallId } from "../../utils/tool-id" import { openAiCodexOAuthManager } from "../../integrations/openai-codex/oauth" import { t } from "../../i18n" @@ -426,7 +427,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion : block.content?.map((c) => (c.type === "text" ? c.text : "")).join("") || "" toolResults.push({ type: "function_call_output", - call_id: block.tool_use_id, + // Sanitize and truncate call_id to fit OpenAI's 64-char limit + call_id: sanitizeOpenAiCallId(block.tool_use_id), output: result, }) } @@ -453,7 +455,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } else if (block.type === "tool_use") { toolCalls.push({ type: "function_call", - call_id: block.id, + // Sanitize and truncate call_id to fit OpenAI's 64-char limit + call_id: sanitizeOpenAiCallId(block.id), name: block.name, arguments: JSON.stringify(block.input), }) diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index b028d95c1e2..61db7dd20d0 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -28,6 +28,7 @@ import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { isMcpTool } from "../../utils/mcp-name" +import { sanitizeOpenAiCallId } from "../../utils/tool-id" export type OpenAiNativeModel = ReturnType @@ -486,7 +487,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio : block.content?.map((c) => (c.type === "text" ? c.text : "")).join("") || "" toolResults.push({ type: "function_call_output", - call_id: block.tool_use_id, + // Sanitize and truncate call_id to fit OpenAI's 64-char limit + call_id: sanitizeOpenAiCallId(block.tool_use_id), output: result, }) } @@ -516,7 +518,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Map Anthropic tool_use to Responses API function_call item toolCalls.push({ type: "function_call", - call_id: block.id, + // Sanitize and truncate call_id to fit OpenAI's 64-char limit + call_id: sanitizeOpenAiCallId(block.id), name: block.name, arguments: JSON.stringify(block.input), }) diff --git a/src/utils/__tests__/tool-id.spec.ts b/src/utils/__tests__/tool-id.spec.ts index 529d3c8434b..c047184417a 100644 --- a/src/utils/__tests__/tool-id.spec.ts +++ b/src/utils/__tests__/tool-id.spec.ts @@ -1,4 +1,4 @@ -import { sanitizeToolUseId } from "../tool-id" +import { sanitizeToolUseId, truncateOpenAiCallId, sanitizeOpenAiCallId, OPENAI_CALL_ID_MAX_LENGTH } from "../tool-id" describe("sanitizeToolUseId", () => { describe("valid IDs pass through unchanged", () => { @@ -69,3 +69,110 @@ describe("sanitizeToolUseId", () => { }) }) }) + +describe("truncateOpenAiCallId", () => { + describe("IDs within limit pass through unchanged", () => { + it("should preserve short IDs", () => { + expect(truncateOpenAiCallId("toolu_01AbC")).toBe("toolu_01AbC") + }) + + it("should preserve IDs exactly at the limit", () => { + const id64Chars = "a".repeat(64) + expect(truncateOpenAiCallId(id64Chars)).toBe(id64Chars) + }) + + it("should handle empty string", () => { + expect(truncateOpenAiCallId("")).toBe("") + }) + }) + + describe("long IDs get truncated with hash suffix", () => { + it("should truncate IDs longer than 64 characters", () => { + const longId = "a".repeat(70) // 70 chars, exceeds 64 limit + const result = truncateOpenAiCallId(longId) + expect(result.length).toBe(64) + }) + + it("should produce consistent results for the same input", () => { + const longId = "toolu_mcp--linear--create_issue_12345678-1234-1234-1234-123456789012" + const result1 = truncateOpenAiCallId(longId) + const result2 = truncateOpenAiCallId(longId) + expect(result1).toBe(result2) + }) + + it("should produce different results for different inputs", () => { + const longId1 = "a".repeat(70) + "_unique1" + const longId2 = "a".repeat(70) + "_unique2" + const result1 = truncateOpenAiCallId(longId1) + const result2 = truncateOpenAiCallId(longId2) + expect(result1).not.toBe(result2) + }) + + it("should preserve the prefix and add hash suffix", () => { + const longId = "toolu_mcp--linear--create_issue_" + "x".repeat(50) + const result = truncateOpenAiCallId(longId) + // Should start with the prefix (first 55 chars) + expect(result.startsWith("toolu_mcp--linear--create_issue_")).toBe(true) + // Should contain a separator and hash + expect(result).toContain("_") + }) + + it("should handle the exact reported issue length (69 chars)", () => { + // The original error mentioned 69 characters + const id69Chars = "toolu_mcp--posthog--query_run_" + "a".repeat(39) // total 69 chars + expect(id69Chars.length).toBe(69) + const result = truncateOpenAiCallId(id69Chars) + expect(result.length).toBe(64) + }) + }) + + describe("custom max length", () => { + it("should support custom max length", () => { + const longId = "a".repeat(50) + const result = truncateOpenAiCallId(longId, 32) + expect(result.length).toBe(32) + }) + + it("should not truncate if within custom limit", () => { + const id = "short_id" + expect(truncateOpenAiCallId(id, 100)).toBe(id) + }) + }) +}) + +describe("sanitizeOpenAiCallId", () => { + it("should sanitize characters and truncate if needed", () => { + // ID with invalid chars and too long + const longIdWithInvalidChars = "toolu_mcp.server:tool/name_" + "x".repeat(50) + const result = sanitizeOpenAiCallId(longIdWithInvalidChars) + // Should be within limit + expect(result.length).toBeLessThanOrEqual(64) + // Should not contain invalid characters + expect(result).toMatch(/^[a-zA-Z0-9_-]+$/) + }) + + it("should only sanitize if length is within limit", () => { + const shortIdWithInvalidChars = "tool.with.dots" + const result = sanitizeOpenAiCallId(shortIdWithInvalidChars) + expect(result).toBe("tool_with_dots") + }) + + it("should handle real-world MCP tool IDs", () => { + // Real MCP tool ID that might exceed 64 chars + const mcpToolId = "call_mcp--posthog--dashboard_create_12345678-1234-1234-1234-123456789012" + const result = sanitizeOpenAiCallId(mcpToolId) + expect(result.length).toBeLessThanOrEqual(64) + expect(result).toMatch(/^[a-zA-Z0-9_-]+$/) + }) + + it("should preserve IDs that are already valid and within limit", () => { + const validId = "toolu_01AbC-xyz_789" + expect(sanitizeOpenAiCallId(validId)).toBe(validId) + }) +}) + +describe("OPENAI_CALL_ID_MAX_LENGTH constant", () => { + it("should be 64", () => { + expect(OPENAI_CALL_ID_MAX_LENGTH).toBe(64) + }) +}) diff --git a/src/utils/tool-id.ts b/src/utils/tool-id.ts index a9189fb7d95..feba6598f60 100644 --- a/src/utils/tool-id.ts +++ b/src/utils/tool-id.ts @@ -1,3 +1,11 @@ +import * as crypto from "crypto" + +/** + * OpenAI Responses API maximum length for call_id field. + * This limit applies to both function_call and function_call_output items. + */ +export const OPENAI_CALL_ID_MAX_LENGTH = 64 + /** * Sanitize a tool_use ID to match API validation pattern: ^[a-zA-Z0-9_-]+$ * Replaces any invalid character with underscore. @@ -5,3 +13,44 @@ export function sanitizeToolUseId(id: string): string { return id.replace(/[^a-zA-Z0-9_-]/g, "_") } + +/** + * Truncate a call_id to fit within OpenAI's 64-character limit. + * Uses a hash suffix to maintain uniqueness when truncation is needed. + * + * @param id - The original call_id + * @param maxLength - Maximum length (defaults to OpenAI's 64-char limit) + * @returns The truncated ID, or original if already within limits + */ +export function truncateOpenAiCallId(id: string, maxLength: number = OPENAI_CALL_ID_MAX_LENGTH): string { + if (id.length <= maxLength) { + return id + } + + // Use 8-char hash suffix for uniqueness (from MD5, sufficient for collision resistance in this context) + const hashSuffixLength = 8 + const separator = "_" + // Reserve space for separator + hash + const prefixMaxLength = maxLength - separator.length - hashSuffixLength + + // Create hash of the full original ID for uniqueness + const hash = crypto.createHash("md5").update(id).digest("hex").slice(0, hashSuffixLength) + + // Take the prefix and append hash + const prefix = id.slice(0, prefixMaxLength) + return `${prefix}${separator}${hash}` +} + +/** + * Sanitize and truncate a tool call ID for OpenAI's Responses API. + * This combines character sanitization with length truncation. + * + * @param id - The original call_id + * @param maxLength - Maximum length (defaults to OpenAI's 64-char limit) + * @returns The sanitized and truncated ID + */ +export function sanitizeOpenAiCallId(id: string, maxLength: number = OPENAI_CALL_ID_MAX_LENGTH): string { + // First sanitize characters, then truncate + const sanitized = sanitizeToolUseId(id) + return truncateOpenAiCallId(sanitized, maxLength) +}