diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index e8e95ad58df..1202d535f11 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -29,6 +29,7 @@ import { BaseProvider } from "./base-provider" import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index" import { handleOpenAIError } from "./utils/openai-error-handler" import { generateImageWithProvider, ImageGenerationResult } from "./utils/image-generation" +import { sanitizeReasoningDetailId } from "./utils/sanitize-reasoning-id" // Add custom interface for OpenRouter params. type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { @@ -286,18 +287,18 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH if (detail.data !== undefined) { existing.data = (existing.data || "") + detail.data } - // Update other fields if provided - if (detail.id !== undefined) existing.id = detail.id + // Update other fields if provided - sanitize ID to remove invalid characters + if (detail.id !== undefined) existing.id = sanitizeReasoningDetailId(detail.id) if (detail.format !== undefined) existing.format = detail.format if (detail.signature !== undefined) existing.signature = detail.signature } else { - // Start new reasoning detail accumulation + // Start new reasoning detail accumulation - sanitize ID to remove invalid characters reasoningDetailsAccumulator.set(key, { type: detail.type, text: detail.text, summary: detail.summary, data: detail.data, - id: detail.id, + id: sanitizeReasoningDetailId(detail.id), format: detail.format, signature: detail.signature, index, diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index f7f9a9db183..ac8957f010d 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -19,6 +19,7 @@ import { MODEL_DEFAULTS } from "../providers/fetchers/roo" import { handleOpenAIError } from "./utils/openai-error-handler" import { generateImageWithProvider, generateImageWithImagesApi, ImageGenerationResult } from "./utils/image-generation" import { t } from "../../i18n" +import { sanitizeReasoningDetailId } from "./utils/sanitize-reasoning-id" // Extend OpenAI's CompletionUsage to include Roo specific fields interface RooUsage extends OpenAI.CompletionUsage { @@ -193,18 +194,18 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { if (detail.data !== undefined) { existing.data = (existing.data || "") + detail.data } - // Update other fields if provided - if (detail.id !== undefined) existing.id = detail.id + // Update other fields if provided - sanitize ID to remove invalid characters + if (detail.id !== undefined) existing.id = sanitizeReasoningDetailId(detail.id) if (detail.format !== undefined) existing.format = detail.format if (detail.signature !== undefined) existing.signature = detail.signature } else { - // Start new reasoning detail accumulation + // Start new reasoning detail accumulation - sanitize ID to remove invalid characters reasoningDetailsAccumulator.set(key, { type: detail.type, text: detail.text, summary: detail.summary, data: detail.data, - id: detail.id, + id: sanitizeReasoningDetailId(detail.id), format: detail.format, signature: detail.signature, index, diff --git a/src/api/providers/utils/__tests__/sanitize-reasoning-id.spec.ts b/src/api/providers/utils/__tests__/sanitize-reasoning-id.spec.ts new file mode 100644 index 00000000000..2418e33c5f6 --- /dev/null +++ b/src/api/providers/utils/__tests__/sanitize-reasoning-id.spec.ts @@ -0,0 +1,59 @@ +import { sanitizeReasoningDetailId } from "../sanitize-reasoning-id" + +describe("sanitizeReasoningDetailId", () => { + it("should return null for null input", () => { + expect(sanitizeReasoningDetailId(null)).toBeNull() + }) + + it("should return undefined for undefined input", () => { + expect(sanitizeReasoningDetailId(undefined)).toBeUndefined() + }) + + it("should return empty string for empty string input", () => { + expect(sanitizeReasoningDetailId("")).toBe("") + }) + + it("should not modify IDs with only valid characters", () => { + expect(sanitizeReasoningDetailId("abc123")).toBe("abc123") + expect(sanitizeReasoningDetailId("test_id")).toBe("test_id") + expect(sanitizeReasoningDetailId("test-id")).toBe("test-id") + expect(sanitizeReasoningDetailId("ABC_123-test")).toBe("ABC_123-test") + }) + + it("should replace colons with underscores", () => { + expect(sanitizeReasoningDetailId("rs_033ca40017d1ad93016931b1d2bf7481a2969fd5c1835cb1d3:4")).toBe( + "rs_033ca40017d1ad93016931b1d2bf7481a2969fd5c1835cb1d3_4", + ) + }) + + it("should replace multiple invalid characters", () => { + expect(sanitizeReasoningDetailId("test:1:2:3")).toBe("test_1_2_3") + }) + + it("should replace other special characters with underscores", () => { + expect(sanitizeReasoningDetailId("test@id")).toBe("test_id") + expect(sanitizeReasoningDetailId("test.id")).toBe("test_id") + expect(sanitizeReasoningDetailId("test#id")).toBe("test_id") + expect(sanitizeReasoningDetailId("test$id")).toBe("test_id") + expect(sanitizeReasoningDetailId("test%id")).toBe("test_id") + expect(sanitizeReasoningDetailId("test^id")).toBe("test_id") + expect(sanitizeReasoningDetailId("test&id")).toBe("test_id") + expect(sanitizeReasoningDetailId("test*id")).toBe("test_id") + expect(sanitizeReasoningDetailId("test+id")).toBe("test_id") + expect(sanitizeReasoningDetailId("test=id")).toBe("test_id") + expect(sanitizeReasoningDetailId("test id")).toBe("test_id") + }) + + it("should handle mixed valid and invalid characters", () => { + expect(sanitizeReasoningDetailId("rs_abc:1@2#3")).toBe("rs_abc_1_2_3") + }) + + it("should handle IDs starting with valid characters followed by invalid ones", () => { + expect(sanitizeReasoningDetailId("valid_start:invalid")).toBe("valid_start_invalid") + }) + + it("should handle consecutive invalid characters", () => { + expect(sanitizeReasoningDetailId("test::id")).toBe("test__id") + expect(sanitizeReasoningDetailId("test:::id")).toBe("test___id") + }) +}) diff --git a/src/api/providers/utils/sanitize-reasoning-id.ts b/src/api/providers/utils/sanitize-reasoning-id.ts new file mode 100644 index 00000000000..03c58b4a313 --- /dev/null +++ b/src/api/providers/utils/sanitize-reasoning-id.ts @@ -0,0 +1,16 @@ +/** + * Sanitizes reasoning detail IDs to only contain allowed characters. + * The OpenAI Responses API only allows IDs containing letters, numbers, underscores, or dashes. + * This function replaces any invalid characters (like colons from IDs like "rs_xxx:4") with underscores. + * + * @param id - The original ID that may contain invalid characters + * @returns The sanitized ID with only allowed characters, or undefined if input is undefined/null + */ +export function sanitizeReasoningDetailId(id: string | null | undefined): string | null | undefined { + if (id === null || id === undefined) { + return id + } + + // Replace any character that is not a letter, number, underscore, or dash with an underscore + return id.replace(/[^a-zA-Z0-9_-]/g, "_") +}