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

This commit is contained in:
Daniel 2026-01-15 23:09:54 -05:00 committed by GitHub
parent bbb6a6e4b4
commit e34d93e2cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 167 additions and 5 deletions

View file

@ -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),
})

View file

@ -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),
})

View file

@ -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)
})
})

View file

@ -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)
}