Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit e34d93e

Browse files
authored
fix: truncate call_id to 64 chars for OpenAI Responses API (#10763)
1 parent bbb6a6e commit e34d93e

4 files changed

Lines changed: 167 additions & 5 deletions

File tree

src/api/providers/openai-codex.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { getModelParams } from "../transform/model-params"
2323
import { BaseProvider } from "./base-provider"
2424
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
2525
import { isMcpTool } from "../../utils/mcp-name"
26+
import { sanitizeOpenAiCallId } from "../../utils/tool-id"
2627
import { openAiCodexOAuthManager } from "../../integrations/openai-codex/oauth"
2728
import { t } from "../../i18n"
2829

@@ -426,7 +427,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
426427
: block.content?.map((c) => (c.type === "text" ? c.text : "")).join("") || ""
427428
toolResults.push({
428429
type: "function_call_output",
429-
call_id: block.tool_use_id,
430+
// Sanitize and truncate call_id to fit OpenAI's 64-char limit
431+
call_id: sanitizeOpenAiCallId(block.tool_use_id),
430432
output: result,
431433
})
432434
}
@@ -453,7 +455,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
453455
} else if (block.type === "tool_use") {
454456
toolCalls.push({
455457
type: "function_call",
456-
call_id: block.id,
458+
// Sanitize and truncate call_id to fit OpenAI's 64-char limit
459+
call_id: sanitizeOpenAiCallId(block.id),
457460
name: block.name,
458461
arguments: JSON.stringify(block.input),
459462
})

src/api/providers/openai-native.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { getModelParams } from "../transform/model-params"
2828
import { BaseProvider } from "./base-provider"
2929
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
3030
import { isMcpTool } from "../../utils/mcp-name"
31+
import { sanitizeOpenAiCallId } from "../../utils/tool-id"
3132

3233
export type OpenAiNativeModel = ReturnType<OpenAiNativeHandler["getModel"]>
3334

@@ -486,7 +487,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
486487
: block.content?.map((c) => (c.type === "text" ? c.text : "")).join("") || ""
487488
toolResults.push({
488489
type: "function_call_output",
489-
call_id: block.tool_use_id,
490+
// Sanitize and truncate call_id to fit OpenAI's 64-char limit
491+
call_id: sanitizeOpenAiCallId(block.tool_use_id),
490492
output: result,
491493
})
492494
}
@@ -516,7 +518,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
516518
// Map Anthropic tool_use to Responses API function_call item
517519
toolCalls.push({
518520
type: "function_call",
519-
call_id: block.id,
521+
// Sanitize and truncate call_id to fit OpenAI's 64-char limit
522+
call_id: sanitizeOpenAiCallId(block.id),
520523
name: block.name,
521524
arguments: JSON.stringify(block.input),
522525
})

src/utils/__tests__/tool-id.spec.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { sanitizeToolUseId } from "../tool-id"
1+
import { sanitizeToolUseId, truncateOpenAiCallId, sanitizeOpenAiCallId, OPENAI_CALL_ID_MAX_LENGTH } from "../tool-id"
22

33
describe("sanitizeToolUseId", () => {
44
describe("valid IDs pass through unchanged", () => {
@@ -69,3 +69,110 @@ describe("sanitizeToolUseId", () => {
6969
})
7070
})
7171
})
72+
73+
describe("truncateOpenAiCallId", () => {
74+
describe("IDs within limit pass through unchanged", () => {
75+
it("should preserve short IDs", () => {
76+
expect(truncateOpenAiCallId("toolu_01AbC")).toBe("toolu_01AbC")
77+
})
78+
79+
it("should preserve IDs exactly at the limit", () => {
80+
const id64Chars = "a".repeat(64)
81+
expect(truncateOpenAiCallId(id64Chars)).toBe(id64Chars)
82+
})
83+
84+
it("should handle empty string", () => {
85+
expect(truncateOpenAiCallId("")).toBe("")
86+
})
87+
})
88+
89+
describe("long IDs get truncated with hash suffix", () => {
90+
it("should truncate IDs longer than 64 characters", () => {
91+
const longId = "a".repeat(70) // 70 chars, exceeds 64 limit
92+
const result = truncateOpenAiCallId(longId)
93+
expect(result.length).toBe(64)
94+
})
95+
96+
it("should produce consistent results for the same input", () => {
97+
const longId = "toolu_mcp--linear--create_issue_12345678-1234-1234-1234-123456789012"
98+
const result1 = truncateOpenAiCallId(longId)
99+
const result2 = truncateOpenAiCallId(longId)
100+
expect(result1).toBe(result2)
101+
})
102+
103+
it("should produce different results for different inputs", () => {
104+
const longId1 = "a".repeat(70) + "_unique1"
105+
const longId2 = "a".repeat(70) + "_unique2"
106+
const result1 = truncateOpenAiCallId(longId1)
107+
const result2 = truncateOpenAiCallId(longId2)
108+
expect(result1).not.toBe(result2)
109+
})
110+
111+
it("should preserve the prefix and add hash suffix", () => {
112+
const longId = "toolu_mcp--linear--create_issue_" + "x".repeat(50)
113+
const result = truncateOpenAiCallId(longId)
114+
// Should start with the prefix (first 55 chars)
115+
expect(result.startsWith("toolu_mcp--linear--create_issue_")).toBe(true)
116+
// Should contain a separator and hash
117+
expect(result).toContain("_")
118+
})
119+
120+
it("should handle the exact reported issue length (69 chars)", () => {
121+
// The original error mentioned 69 characters
122+
const id69Chars = "toolu_mcp--posthog--query_run_" + "a".repeat(39) // total 69 chars
123+
expect(id69Chars.length).toBe(69)
124+
const result = truncateOpenAiCallId(id69Chars)
125+
expect(result.length).toBe(64)
126+
})
127+
})
128+
129+
describe("custom max length", () => {
130+
it("should support custom max length", () => {
131+
const longId = "a".repeat(50)
132+
const result = truncateOpenAiCallId(longId, 32)
133+
expect(result.length).toBe(32)
134+
})
135+
136+
it("should not truncate if within custom limit", () => {
137+
const id = "short_id"
138+
expect(truncateOpenAiCallId(id, 100)).toBe(id)
139+
})
140+
})
141+
})
142+
143+
describe("sanitizeOpenAiCallId", () => {
144+
it("should sanitize characters and truncate if needed", () => {
145+
// ID with invalid chars and too long
146+
const longIdWithInvalidChars = "toolu_mcp.server:tool/name_" + "x".repeat(50)
147+
const result = sanitizeOpenAiCallId(longIdWithInvalidChars)
148+
// Should be within limit
149+
expect(result.length).toBeLessThanOrEqual(64)
150+
// Should not contain invalid characters
151+
expect(result).toMatch(/^[a-zA-Z0-9_-]+$/)
152+
})
153+
154+
it("should only sanitize if length is within limit", () => {
155+
const shortIdWithInvalidChars = "tool.with.dots"
156+
const result = sanitizeOpenAiCallId(shortIdWithInvalidChars)
157+
expect(result).toBe("tool_with_dots")
158+
})
159+
160+
it("should handle real-world MCP tool IDs", () => {
161+
// Real MCP tool ID that might exceed 64 chars
162+
const mcpToolId = "call_mcp--posthog--dashboard_create_12345678-1234-1234-1234-123456789012"
163+
const result = sanitizeOpenAiCallId(mcpToolId)
164+
expect(result.length).toBeLessThanOrEqual(64)
165+
expect(result).toMatch(/^[a-zA-Z0-9_-]+$/)
166+
})
167+
168+
it("should preserve IDs that are already valid and within limit", () => {
169+
const validId = "toolu_01AbC-xyz_789"
170+
expect(sanitizeOpenAiCallId(validId)).toBe(validId)
171+
})
172+
})
173+
174+
describe("OPENAI_CALL_ID_MAX_LENGTH constant", () => {
175+
it("should be 64", () => {
176+
expect(OPENAI_CALL_ID_MAX_LENGTH).toBe(64)
177+
})
178+
})

src/utils/tool-id.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,56 @@
1+
import * as crypto from "crypto"
2+
3+
/**
4+
* OpenAI Responses API maximum length for call_id field.
5+
* This limit applies to both function_call and function_call_output items.
6+
*/
7+
export const OPENAI_CALL_ID_MAX_LENGTH = 64
8+
19
/**
210
* Sanitize a tool_use ID to match API validation pattern: ^[a-zA-Z0-9_-]+$
311
* Replaces any invalid character with underscore.
412
*/
513
export function sanitizeToolUseId(id: string): string {
614
return id.replace(/[^a-zA-Z0-9_-]/g, "_")
715
}
16+
17+
/**
18+
* Truncate a call_id to fit within OpenAI's 64-character limit.
19+
* Uses a hash suffix to maintain uniqueness when truncation is needed.
20+
*
21+
* @param id - The original call_id
22+
* @param maxLength - Maximum length (defaults to OpenAI's 64-char limit)
23+
* @returns The truncated ID, or original if already within limits
24+
*/
25+
export function truncateOpenAiCallId(id: string, maxLength: number = OPENAI_CALL_ID_MAX_LENGTH): string {
26+
if (id.length <= maxLength) {
27+
return id
28+
}
29+
30+
// Use 8-char hash suffix for uniqueness (from MD5, sufficient for collision resistance in this context)
31+
const hashSuffixLength = 8
32+
const separator = "_"
33+
// Reserve space for separator + hash
34+
const prefixMaxLength = maxLength - separator.length - hashSuffixLength
35+
36+
// Create hash of the full original ID for uniqueness
37+
const hash = crypto.createHash("md5").update(id).digest("hex").slice(0, hashSuffixLength)
38+
39+
// Take the prefix and append hash
40+
const prefix = id.slice(0, prefixMaxLength)
41+
return `${prefix}${separator}${hash}`
42+
}
43+
44+
/**
45+
* Sanitize and truncate a tool call ID for OpenAI's Responses API.
46+
* This combines character sanitization with length truncation.
47+
*
48+
* @param id - The original call_id
49+
* @param maxLength - Maximum length (defaults to OpenAI's 64-char limit)
50+
* @returns The sanitized and truncated ID
51+
*/
52+
export function sanitizeOpenAiCallId(id: string, maxLength: number = OPENAI_CALL_ID_MAX_LENGTH): string {
53+
// First sanitize characters, then truncate
54+
const sanitized = sanitizeToolUseId(id)
55+
return truncateOpenAiCallId(sanitized, maxLength)
56+
}

0 commit comments

Comments
 (0)