Przeglądaj źródła

fix: truncate call_id to 64 chars for OpenAI Responses API (#10763)

Daniel 6 dni temu
rodzic
commit
e34d93e2cb

+ 5 - 2
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),
 							})

+ 5 - 2
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<OpenAiNativeHandler["getModel"]>
 
@@ -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),
 							})

+ 108 - 1
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)
+	})
+})

+ 49 - 0
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)
+}