Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/api/providers/openai-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
})
}
Expand All @@ -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),
})
Expand Down
7 changes: 5 additions & 2 deletions src/api/providers/openai-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]>

Expand Down Expand Up @@ -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,
})
}
Expand Down Expand Up @@ -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),
})
Expand Down
109 changes: 108 additions & 1 deletion src/utils/__tests__/tool-id.spec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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 changes: 49 additions & 0 deletions src/utils/tool-id.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,56 @@
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.
*/
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)
}
Loading