diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 6d50270ed04..f9a8ad1f230 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -37,8 +37,11 @@ vitest.mock("@ai-sdk/google-vertex/anthropic", () => ({ })) // Mock ai-sdk transform utilities +vitest.mock("../../transform/sanitize-messages", () => ({ + sanitizeMessagesForProvider: vitest.fn().mockImplementation((msgs: any[]) => msgs), +})) + vitest.mock("../../transform/ai-sdk", () => ({ - convertToAiSdkMessages: vitest.fn().mockReturnValue([{ role: "user", content: [{ type: "text", text: "Hello" }] }]), convertToolsForAiSdk: vitest.fn().mockReturnValue(undefined), processAiSdkStreamPart: vitest.fn().mockImplementation(function* (part: any) { if (part.type === "text-delta") { @@ -59,7 +62,7 @@ vitest.mock("../../transform/ai-sdk", () => ({ })) // Import mocked modules -import { convertToAiSdkMessages, convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" +import { convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" import { Anthropic } from "@anthropic-ai/sdk" // Helper: create a mock provider function diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index d65506135b9..3a92061eb68 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -32,8 +32,11 @@ vitest.mock("@ai-sdk/anthropic", () => ({ })) // Mock ai-sdk transform utilities +vitest.mock("../../transform/sanitize-messages", () => ({ + sanitizeMessagesForProvider: vitest.fn().mockImplementation((msgs: any[]) => msgs), +})) + vitest.mock("../../transform/ai-sdk", () => ({ - convertToAiSdkMessages: vitest.fn().mockReturnValue([{ role: "user", content: [{ type: "text", text: "Hello" }] }]), convertToolsForAiSdk: vitest.fn().mockReturnValue(undefined), processAiSdkStreamPart: vitest.fn().mockImplementation(function* (part: any) { if (part.type === "text-delta") { @@ -54,7 +57,8 @@ vitest.mock("../../transform/ai-sdk", () => ({ })) // Import mocked modules -import { convertToAiSdkMessages, convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" +import { convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" +import { sanitizeMessagesForProvider } from "../../transform/sanitize-messages" import { Anthropic } from "@anthropic-ai/sdk" // Helper: create a mock provider function @@ -82,9 +86,6 @@ describe("AnthropicHandler", () => { // Re-set mock defaults after clearAllMocks mockCreateAnthropic.mockReturnValue(mockProviderFn) - vitest - .mocked(convertToAiSdkMessages) - .mockReturnValue([{ role: "user", content: [{ type: "text", text: "Hello" }] }]) vitest.mocked(convertToolsForAiSdk).mockReturnValue(undefined) vitest.mocked(mapToolChoice).mockReturnValue(undefined) }) @@ -399,6 +400,51 @@ describe("AnthropicHandler", () => { expect(endChunk).toBeDefined() }) + it("should strip reasoning_details and reasoning_content from messages before sending to API", async () => { + // Override the identity mock with the real implementation for this test + const { sanitizeMessagesForProvider: realSanitize } = await vi.importActual< + typeof import("../../transform/sanitize-messages") + >("../../transform/sanitize-messages") + vi.mocked(sanitizeMessagesForProvider).mockImplementation(realSanitize) + + setupStreamTextMock([{ type: "text-delta", text: "test" }]) + + // Simulate messages with extra legacy fields that survive JSON deserialization + const messagesWithExtraFields = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello" }], + }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Hi" }], + reasoning_details: [{ type: "thinking", thinking: "some reasoning" }], + reasoning_content: "some reasoning content", + }, + { + role: "user", + content: [{ type: "text" as const, text: "Follow up" }], + }, + ] as any + + const stream = handler.createMessage(systemPrompt, messagesWithExtraFields) + + for await (const _chunk of stream) { + // Consume stream + } + + // Verify streamText was called exactly once + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0]![0] + for (const msg of callArgs.messages) { + expect(msg).not.toHaveProperty("reasoning_details") + expect(msg).not.toHaveProperty("reasoning_content") + } + // Verify the rest of the message is preserved + expect(callArgs.messages[1].role).toBe("assistant") + expect(callArgs.messages[1].content).toEqual([{ type: "text", text: "Hi" }]) + }) + it("should pass system prompt via system param when no systemProviderOptions", async () => { setupStreamTextMock([{ type: "text-delta", text: "test" }]) diff --git a/src/api/providers/__tests__/azure.spec.ts b/src/api/providers/__tests__/azure.spec.ts index e95d7de46a6..f16f6d14e6b 100644 --- a/src/api/providers/__tests__/azure.spec.ts +++ b/src/api/providers/__tests__/azure.spec.ts @@ -335,7 +335,7 @@ describe("AzureHandler", () => { for await (const chunk of stream) { chunks.push(chunk) } - }).rejects.toThrow("Azure AI Foundry") + }).rejects.toThrow("API Error") }) }) diff --git a/src/api/providers/__tests__/baseten.spec.ts b/src/api/providers/__tests__/baseten.spec.ts index 43b21f28dc4..efbe428d6b6 100644 --- a/src/api/providers/__tests__/baseten.spec.ts +++ b/src/api/providers/__tests__/baseten.spec.ts @@ -414,7 +414,7 @@ describe("BasetenHandler", () => { for await (const _ of stream) { // consume stream } - }).rejects.toThrow("Baseten: API Error") + }).rejects.toThrow("API Error") }) it("should preserve status codes in error handling", async () => { @@ -439,7 +439,7 @@ describe("BasetenHandler", () => { } expect.fail("Should have thrown an error") } catch (error: any) { - expect(error.message).toContain("Baseten") + expect(error.message).toContain("Rate limit exceeded") expect(error.status).toBe(429) } }) diff --git a/src/api/providers/__tests__/bedrock-error-handling.spec.ts b/src/api/providers/__tests__/bedrock-error-handling.spec.ts index d217984c8da..7e61de3d6a6 100644 --- a/src/api/providers/__tests__/bedrock-error-handling.spec.ts +++ b/src/api/providers/__tests__/bedrock-error-handling.spec.ts @@ -237,11 +237,11 @@ describe("AwsBedrockHandler Error Handling", () => { }) // ----------------------------------------------------------------------- - // Non-throttling errors (createMessage) are wrapped by handleAiSdkError + // Non-throttling errors (createMessage) propagate unchanged // ----------------------------------------------------------------------- describe("Non-throttling errors (createMessage)", () => { - it("should wrap non-throttling errors with provider name via handleAiSdkError", async () => { + it("should propagate non-throttling errors unchanged", async () => { const genericError = createMockError({ message: "Something completely unexpected happened", }) @@ -256,7 +256,7 @@ describe("AwsBedrockHandler Error Handling", () => { for await (const _chunk of generator) { // should throw } - }).rejects.toThrow("Bedrock: Something completely unexpected happened") + }).rejects.toThrow("Something completely unexpected happened") }) it("should preserve status code from non-throttling API errors", async () => { @@ -277,8 +277,7 @@ describe("AwsBedrockHandler Error Handling", () => { } throw new Error("Expected error to be thrown") } catch (error: any) { - expect(error.message).toContain("Bedrock:") - expect(error.message).toContain("Internal server error occurred") + expect(error.message).toBe("Internal server error occurred") } }) @@ -298,7 +297,7 @@ describe("AwsBedrockHandler Error Handling", () => { for await (const _chunk of generator) { // should throw } - }).rejects.toThrow("Bedrock: Too many tokens in request") + }).rejects.toThrow("Too many tokens in request") }) }) @@ -334,7 +333,7 @@ describe("AwsBedrockHandler Error Handling", () => { }).rejects.toThrow("Bedrock is unable to process your request") }) - it("should wrap non-throttling errors that occur mid-stream via handleAiSdkError", async () => { + it("should propagate non-throttling errors that occur mid-stream unchanged", async () => { const genericError = createMockError({ message: "Some other error", status: 500, @@ -357,22 +356,22 @@ describe("AwsBedrockHandler Error Handling", () => { for await (const _chunk of generator) { // should throw } - }).rejects.toThrow("Bedrock: Some other error") + }).rejects.toThrow("Some other error") }) }) // ----------------------------------------------------------------------- - // completePrompt errors — all go through handleAiSdkError (no throttle check) + // completePrompt errors — propagate unchanged (no throttle check) // ----------------------------------------------------------------------- describe("completePrompt error handling", () => { - it("should wrap errors with provider name for completePrompt", async () => { + it("should propagate errors unchanged for completePrompt", async () => { mockGenerateText.mockRejectedValueOnce(new Error("Bedrock API failure")) - await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock: Bedrock API failure") + await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock API failure") }) - it("should wrap throttling-pattern errors with provider name for completePrompt", async () => { + it("should propagate throttling-pattern errors unchanged for completePrompt", async () => { const throttleError = createMockError({ message: "Bedrock is unable to process your request", status: 429, @@ -380,9 +379,9 @@ describe("AwsBedrockHandler Error Handling", () => { mockGenerateText.mockRejectedValueOnce(throttleError) - // completePrompt does NOT have the throttle-rethrow path; it always uses handleAiSdkError + // completePrompt does NOT have the throttle-rethrow path; errors propagate unchanged await expect(handler.completePrompt("test")).rejects.toThrow( - "Bedrock: Bedrock is unable to process your request", + "Bedrock is unable to process your request", ) }) @@ -396,7 +395,7 @@ describe("AwsBedrockHandler Error Handling", () => { results.forEach((result) => { expect(result.status).toBe("rejected") if (result.status === "rejected") { - expect(result.reason.message).toContain("Bedrock:") + expect(result.reason.message).toBe("API failure") } }) }) @@ -413,8 +412,7 @@ describe("AwsBedrockHandler Error Handling", () => { await handler.completePrompt("test") throw new Error("Expected error to be thrown") } catch (error: any) { - expect(error.message).toContain("Bedrock:") - expect(error.message).toContain("Service unavailable") + expect(error.message).toBe("Service unavailable") } }) }) @@ -479,7 +477,8 @@ describe("AwsBedrockHandler Error Handling", () => { it("should handle non-Error objects thrown by generateText", async () => { mockGenerateText.mockRejectedValueOnce("string error") - await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock: string error") + // Non-Error values propagate as-is + await expect(handler.completePrompt("test")).rejects.toBe("string error") }) it("should handle non-Error objects thrown by streamText", async () => { @@ -489,12 +488,12 @@ describe("AwsBedrockHandler Error Handling", () => { const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) - // Non-Error values are not detected as throttling → handleAiSdkError path + // Non-Error values are not detected as throttling → propagate as-is await expect(async () => { for await (const _chunk of generator) { // should throw } - }).rejects.toThrow("Bedrock: string error") + }).rejects.toBe("string error") }) it("should handle errors with unusual structure gracefully", async () => { @@ -505,9 +504,8 @@ describe("AwsBedrockHandler Error Handling", () => { await handler.completePrompt("test") throw new Error("Expected error to be thrown") } catch (error: any) { - // handleAiSdkError wraps with "Bedrock: ..." - expect(error.message).toContain("Bedrock:") - expect(error.message).not.toContain("undefined") + // Errors propagate unchanged — the object's message property is preserved + expect(error.message).toBe("Error with unusual structure") } }) diff --git a/src/api/providers/__tests__/lmstudio.spec.ts b/src/api/providers/__tests__/lmstudio.spec.ts index aaded984db1..337449006f9 100644 --- a/src/api/providers/__tests__/lmstudio.spec.ts +++ b/src/api/providers/__tests__/lmstudio.spec.ts @@ -168,7 +168,7 @@ describe("LmStudioHandler", () => { it("should handle API errors with handleAiSdkError", async () => { mockGenerateText.mockRejectedValueOnce(new Error("Connection refused")) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("LM Studio") + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Connection refused") }) }) diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 84ecce0f242..cbd740d0353 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -15,7 +15,6 @@ const { mockCreateAnthropic, mockModel, mockMergeEnvironmentDetailsForMiniMax, - mockHandleAiSdkError, } = vi.hoisted(() => { const mockModel = vi.fn().mockReturnValue("mock-model-instance") return { @@ -24,10 +23,6 @@ const { mockCreateAnthropic: vi.fn().mockReturnValue(mockModel), mockModel, mockMergeEnvironmentDetailsForMiniMax: vi.fn((messages: RooMessage[]) => messages), - mockHandleAiSdkError: vi.fn((error: unknown, providerName: string) => { - const message = error instanceof Error ? error.message : String(error) - return new Error(`${providerName}: ${message}`) - }), } }) @@ -44,13 +39,6 @@ vi.mock("../../transform/minimax-format", () => ({ mergeEnvironmentDetailsForMiniMax: mockMergeEnvironmentDetailsForMiniMax, })) -vi.mock("../../transform/ai-sdk", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - handleAiSdkError: mockHandleAiSdkError, - } -}) type HandlerOptions = Omit, "minimaxBaseUrl"> & { minimaxBaseUrl?: string @@ -108,10 +96,6 @@ describe("MiniMaxHandler", () => { vi.clearAllMocks() mockCreateAnthropic.mockReturnValue(mockModel) mockMergeEnvironmentDetailsForMiniMax.mockImplementation((inputMessages: RooMessage[]) => inputMessages) - mockHandleAiSdkError.mockImplementation((error: unknown, providerName: string) => { - const message = error instanceof Error ? error.message : String(error) - return new Error(`${providerName}: ${message}`) - }) }) describe("constructor", () => { @@ -359,8 +343,7 @@ describe("MiniMaxHandler", () => { await expect(async () => { await collectChunks(stream) - }).rejects.toThrow("MiniMax: API Error") - expect(mockHandleAiSdkError).toHaveBeenCalledWith(expect.any(Error), "MiniMax") + }).rejects.toThrow("API Error") }) }) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index e87e8e6f4e9..3d0d5bf2071 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -265,6 +265,33 @@ describe("NativeOllamaHandler", () => { }).rejects.toThrow("Ollama service is not running") }) + it("propagates stream error when usage resolution fails after stream error", async () => { + async function* mockFullStream() { + yield { type: "error", error: new Error("upstream provider returned 500") } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.reject(new Error("No output generated")), + }) + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + const results: any[] = [] + + await expect(async () => { + for await (const chunk of stream) { + results.push(chunk) + } + }).rejects.toThrow("upstream provider returned 500") + + // The stream error should have been yielded before the throw + expect(results).toContainEqual({ + type: "error", + error: "StreamError", + message: "upstream provider returned 500", + }) + }) + it("should handle model not found errors", async () => { const error = new Error("Not found") as any error.status = 404 diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index 8eb4fcc2653..db0483ee5cd 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -109,7 +109,7 @@ describe("OpenAiCodexHandler.completePrompt", () => { mockGenerateText.mockRejectedValue(new Error("API Error")) - await expect(handler.completePrompt("Say hello")).rejects.toThrow("OpenAI Codex") + await expect(handler.completePrompt("Say hello")).rejects.toThrow("API Error") }) it("should throw when not authenticated", async () => { diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 568ed9ce97b..dd43d9ea71c 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -307,7 +307,7 @@ describe("OpenAiNativeHandler", () => { for await (const _chunk of stream) { // drain } - }).rejects.toThrow("OpenAI Native") + }).rejects.toThrow("API Error") }) it("should pass system prompt to streamText", async () => { @@ -905,7 +905,7 @@ describe("OpenAiNativeHandler", () => { it("should handle errors in completePrompt", async () => { mockGenerateText.mockRejectedValue(new Error("API Error")) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI Native") + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API Error") }) it("should return empty string when no text in response", async () => { diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 763d0ef6068..847cd3c9e61 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -544,17 +544,12 @@ describe("OpenRouterHandler", () => { }) const generator = handler.createMessage("test", [{ role: "user", content: "test" }]) - const chunks = [] - - for await (const chunk of generator) { - chunks.push(chunk) - } - expect(chunks[0]).toEqual({ - type: "error", - error: "OpenRouterError", - message: "OpenRouter API Error: API Error", - }) + await expect(async () => { + for await (const _chunk of generator) { + // consume + } + }).rejects.toThrow("API Error") // Verify telemetry was called expect(mockCaptureException).toHaveBeenCalledTimes(1) @@ -594,6 +589,42 @@ describe("OpenRouterHandler", () => { }) }) + it("propagates stream error when usage resolution fails after stream error", async () => { + const handler = new OpenRouterHandler(mockOptions) + + const mockFullStream = (async function* () { + yield { type: "error", error: new Error("upstream provider returned 500") } + })() + + // Share one rejection so we don't create an unhandled-rejection for totalUsage + const usageRejection = Promise.reject(new Error("No output generated")) + // Prevent Node unhandled-rejection for the shared promise + usageRejection.catch(() => {}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: usageRejection, + totalUsage: usageRejection, + providerMetadata: Promise.resolve(undefined), + }) + + const generator = handler.createMessage("test", [{ role: "user", content: "test" }]) + const chunks: any[] = [] + + await expect(async () => { + for await (const chunk of generator) { + chunks.push(chunk) + } + }).rejects.toThrow("upstream provider returned 500") + + // The stream error should have been yielded before the throw + expect(chunks).toContainEqual({ + type: "error", + error: "StreamError", + message: "upstream provider returned 500", + }) + }) + it("passes tools to streamText when provided", async () => { const handler = new OpenRouterHandler(mockOptions) @@ -779,9 +810,7 @@ describe("OpenRouterHandler", () => { mockGenerateText.mockRejectedValue(new Error("API Error")) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - "OpenRouter completion error: API Error", - ) + await expect(handler.completePrompt("test prompt")).rejects.toThrow("API Error") // Verify telemetry was called expect(mockCaptureException).toHaveBeenCalledTimes(1) @@ -799,9 +828,7 @@ describe("OpenRouterHandler", () => { mockGenerateText.mockRejectedValue(new Error("Rate limit exceeded")) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - "OpenRouter completion error: Rate limit exceeded", - ) + await expect(handler.completePrompt("test prompt")).rejects.toThrow("Rate limit exceeded") // Verify telemetry was called expect(mockCaptureException).toHaveBeenCalledTimes(1) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 3e8278afb6c..31c060d292b 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -395,7 +395,7 @@ describe("RooHandler", () => { it("should handle API errors", async () => { mockGenerateText.mockRejectedValue(new Error("API Error")) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Roo Code Cloud") + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API Error") }) it("should handle empty response", async () => { diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index 6c9e9931928..447738a6bfd 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -595,7 +595,7 @@ describe("SambaNovaHandler", () => { for await (const _ of stream) { // consume stream } - }).rejects.toThrow("SambaNova: API Error") + }).rejects.toThrow("API Error") }) it("should preserve status codes in error handling", async () => { @@ -621,7 +621,7 @@ describe("SambaNovaHandler", () => { } expect.fail("Should have thrown an error") } catch (error: any) { - expect(error.message).toContain("SambaNova") + expect(error.message).toContain("Rate limit exceeded") expect(error.status).toBe(429) } }) diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 1864a6a4b5d..7fd6d5d0bb1 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -482,7 +482,7 @@ describe("VercelAiGatewayHandler", () => { mockGenerateText.mockRejectedValue(new Error(errorMessage)) - await expect(handler.completePrompt("Test")).rejects.toThrow("Vercel AI Gateway") + await expect(handler.completePrompt("Test")).rejects.toThrow("API error") }) it("returns empty string when generateText returns empty text", async () => { diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index 10c3181dfb3..9e1094a945f 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -399,9 +399,8 @@ describe("XAIHandler", () => { ;(mockError as any).name = "AI_APICallError" ;(mockError as any).status = 500 - async function* mockFullStream(): AsyncGenerator { - // This yield is unreachable but needed to satisfy the require-yield lint rule - yield undefined as never + async function* mockFullStream(): AsyncGenerator { + yield { type: "text-delta", text: "" } throw mockError } @@ -417,7 +416,7 @@ describe("XAIHandler", () => { for await (const _ of stream) { // consume stream } - }).rejects.toThrow("xAI") + }).rejects.toThrow("API error") }) }) @@ -456,7 +455,7 @@ describe("XAIHandler", () => { ;(mockError as any).name = "AI_APICallError" mockGenerateText.mockRejectedValue(mockError) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("xAI") + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API error") }) }) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index d95d5d7786d..e97c9f47dc3 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -19,14 +19,12 @@ import { shouldUseReasoningBudget } from "../../shared/api" import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" -import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" +import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { calculateApiCostAnthropic } from "../../shared/cost" import { DEFAULT_HEADERS } from "./constants" @@ -128,6 +126,8 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple metadata?.systemProviderOptions, ) + applyCacheBreakpoints(aiSdkMessages) + // Build streamText request // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values const requestOptions: Parameters[0] = { @@ -176,7 +176,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple TelemetryService.instance.captureException( new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage"), ) - throw handleAiSdkError(error, this.providerName) + throw error } } @@ -300,7 +300,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple "completePrompt", ), ) - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 81189a7868c..8bc923961a1 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -1,5 +1,5 @@ import { createAnthropic } from "@ai-sdk/anthropic" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { type ModelInfo, @@ -17,14 +17,13 @@ import { shouldUseReasoningBudget } from "../../shared/api" import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" -import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" +import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" +import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import { calculateApiCostAnthropic } from "../../shared/cost" import { DEFAULT_HEADERS } from "./constants" @@ -77,8 +76,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa ): ApiStream { const modelConfig = this.getModel() - // Convert messages to AI SDK format - const aiSdkMessages = messages as ModelMessage[] + // Sanitize messages for the provider API (allowlist: role, content, providerOptions). + const aiSdkMessages = sanitizeMessagesForProvider(messages) // Convert tools to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) @@ -116,6 +115,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa metadata?.systemProviderOptions, ) + applyCacheBreakpoints(aiSdkMessages) + // Build streamText request // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values const requestOptions: Parameters[0] = { @@ -164,7 +165,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa TelemetryService.instance.captureException( new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage"), ) - throw handleAiSdkError(error, this.providerName) + throw error } } @@ -277,7 +278,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa "completePrompt", ), ) - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/azure.ts b/src/api/providers/azure.ts index c039f0f373f..d1767c7745d 100644 --- a/src/api/providers/azure.ts +++ b/src/api/providers/azure.ts @@ -6,13 +6,7 @@ import { azureModels, azureDefaultModelInfo, type ModelInfo } from "@roo-code/ty import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -183,8 +177,7 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle yield processUsage(usage, providerMetadata as Parameters[1]) }) } catch (error) { - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) - throw handleAiSdkError(error, "Azure AI Foundry") + throw error } } diff --git a/src/api/providers/baseten.ts b/src/api/providers/baseten.ts index 601e93b6c52..a6592ab98cc 100644 --- a/src/api/providers/baseten.ts +++ b/src/api/providers/baseten.ts @@ -6,13 +6,7 @@ import { basetenModels, basetenDefaultModelId, type ModelInfo } from "@roo-code/ import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -140,7 +134,7 @@ export class BasetenHandler extends BaseProvider implements SingleCompletionHand yield processUsage(usage) }) } catch (error) { - throw handleAiSdkError(error, "Baseten") + throw error } } diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 35139719153..4f75bbacac4 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -1,6 +1,6 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { createAmazonBedrock, type AmazonBedrockProvider } from "@ai-sdk/amazon-bedrock" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { fromIni } from "@aws-sdk/credential-providers" import OpenAI from "openai" @@ -25,15 +25,14 @@ import { TelemetryService } from "@roo-code/telemetry" import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" -import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" +import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { getModelParams } from "../transform/model-params" +import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import { shouldUseReasoningBudget } from "../../shared/api" import { BaseProvider } from "./base-provider" import { DEFAULT_HEADERS } from "./constants" @@ -194,19 +193,8 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH ): ApiStream { const modelConfig = this.getModel() - // Filter out provider-specific meta entries (e.g., { type: "reasoning" }) - // that are not valid Anthropic MessageParam values - type ReasoningMetaLike = { type?: string } - const filteredMessages = messages.filter((message) => { - const meta = message as ReasoningMetaLike - if (meta.type === "reasoning") { - return false - } - return true - }) - - // Convert messages to AI SDK format - const aiSdkMessages = filteredMessages as ModelMessage[] + // Sanitize messages for the provider API (allowlist: role, content, providerOptions). + const aiSdkMessages = sanitizeMessagesForProvider(messages) // Convert tools to AI SDK format let openAiTools = this.convertToolsForOpenAI(metadata?.tools) @@ -263,6 +251,8 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH ? applySystemPromptCaching(systemPrompt, aiSdkMessages, metadata?.systemProviderOptions) : systemPrompt || undefined + applyCacheBreakpoints(aiSdkMessages) + // Strip non-Bedrock cache annotations from messages when caching is disabled, // and strip Bedrock-specific annotations when caching is disabled. if (!usePromptCache) { @@ -342,8 +332,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH throw new Error("Throttling error occurred") } - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) - throw handleAiSdkError(error, this.providerName) + throw error } } @@ -467,8 +456,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH const apiError = new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "completePrompt") TelemetryService.instance.captureException(apiError) - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 72c582dfef0..fa854b69493 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -6,13 +6,7 @@ import { deepSeekModels, deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -162,7 +156,7 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan yield processUsage(usage, providerMetadata as Parameters[1]) }) } catch (error) { - throw handleAiSdkError(error, "DeepSeek") + throw error } } diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index 468c1f1840a..146633054c6 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -6,13 +6,7 @@ import { fireworksModels, fireworksDefaultModelId, type ModelInfo } from "@roo-c import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -162,7 +156,7 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa yield processUsage(usage, providerMetadata as Parameters[1]) }) } catch (error) { - throw handleAiSdkError(error, "Fireworks") + throw error } } diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 50518b0b3b5..8679f19b43e 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -1,6 +1,6 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google" -import { streamText, generateText, NoOutputGeneratedError, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, NoOutputGeneratedError, ToolSet } from "ai" import { type ModelInfo, @@ -14,17 +14,16 @@ import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { t } from "i18next" import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { BaseProvider } from "./base-provider" @@ -77,22 +76,8 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl ? (this.options.modelTemperature ?? info.defaultTemperature ?? 1) : info.defaultTemperature - // The message list can include provider-specific meta entries such as - // `{ type: "reasoning", ... }` that are intended only for providers like - // openai-native. Gemini should never see those; they are not valid - // Anthropic.MessageParam values and will cause failures. - type ReasoningMetaLike = { type?: string } - - const filteredMessages = messages.filter((message) => { - const meta = message as ReasoningMetaLike - if (meta.type === "reasoning") { - return false - } - return true - }) - - // Convert messages to AI SDK format - const aiSdkMessages = filteredMessages as ModelMessage[] + // Sanitize messages for the provider API (allowlist: role, content, providerOptions). + const aiSdkMessages = sanitizeMessagesForProvider(messages) // Convert tools to OpenAI format first, then to AI SDK format let openAiTools = this.convertToolsForOpenAI(metadata?.tools) @@ -210,14 +195,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl yield* yieldResponseMessage(result) } catch (error) { - throw handleAiSdkError(error, this.providerName, { - onError: (msg) => { - TelemetryService.instance.captureException( - new ApiProviderError(msg, this.providerName, modelId, "createMessage"), - ) - }, - formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg }), - }) + const errorMessage = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage"), + ) + throw error } } @@ -368,14 +350,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl return text } catch (error) { - throw handleAiSdkError(error, this.providerName, { - onError: (msg) => { - TelemetryService.instance.captureException( - new ApiProviderError(msg, this.providerName, modelId, "completePrompt"), - ) - }, - formatMessage: (msg) => t("common:errors.gemini.generate_complete_prompt", { error: msg }), - }) + const errorMessage = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt"), + ) + throw error } } diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 9731174f54b..55ab8013ace 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -13,13 +13,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATU import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream } from "../transform/stream" @@ -96,7 +90,7 @@ export class LmStudioHandler extends OpenAICompatibleHandler implements SingleCo yield processUsage(usage) }) } catch (error) { - throw handleAiSdkError(error, "LM Studio") + throw error } } @@ -134,7 +128,7 @@ export class LmStudioHandler extends OpenAICompatibleHandler implements SingleCo const { text } = await generateText(options) return text } catch (error) { - throw handleAiSdkError(error, "LM Studio") + throw error } } } diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 4bb62f73afc..b8f291a3295 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -9,11 +9,9 @@ import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { mergeEnvironmentDetailsForMiniMax } from "../transform/minimax-format" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -133,7 +131,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand yield* yieldResponseMessage(result) } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } @@ -206,7 +204,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand return text } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index aa77b3a7628..e7273d28c74 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -12,7 +12,7 @@ import { import type { ApiHandlerOptions } from "../../shared/api" -import { convertToAiSdkMessages, convertToolsForAiSdk, consumeAiSdkStream, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -186,7 +186,7 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand yield processUsage(usage) }) } catch (error) { - throw handleAiSdkError(error, "Mistral") + throw error } } diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 25b7070cbea..b8b29865bf9 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -7,11 +7,9 @@ import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -117,23 +115,34 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const result = streamText(requestOptions) try { + let lastStreamError: string | undefined for await (const part of result.fullStream) { for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "error") { + lastStreamError = chunk.message + } yield chunk } } - const usage = await result.usage - if (usage) { - const inputTokens = usage.inputTokens || 0 - const outputTokens = usage.outputTokens || 0 - yield { - type: "usage", - inputTokens, - outputTokens, - totalInputTokens: inputTokens, - totalOutputTokens: outputTokens, + try { + const usage = await result.usage + if (usage) { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + yield { + type: "usage", + inputTokens, + outputTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, + } } + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError } yield* yieldResponseMessage(result) @@ -188,7 +197,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio ) } - throw handleAiSdkError(error, "Ollama") + throw error } override isAiSdkProvider(): boolean { diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index e9720049d1a..cdee2d65ed2 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -16,15 +16,14 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -165,23 +164,17 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const provider = await this.createProvider(accessToken, metadata?.taskId) const languageModel = this.getLanguageModel(provider) - // Step 1: Collect encrypted reasoning items and their positions before filtering. + // Step 1: Collect encrypted reasoning items before sanitization strips them. const encryptedReasoningItems = collectEncryptedReasoningItems(messages) - // Step 2: Filter out standalone encrypted reasoning items (they lack role). - const standardMessages = messages.filter( - (msg) => - (msg as unknown as Record).type !== "reasoning" || - !(msg as unknown as Record).encrypted_content, - ) + // Step 2: Sanitize messages for the provider API (allowlist: role, content, providerOptions). + // This also filters out standalone RooReasoningMessage items (no role field). + const sanitizedMessages = sanitizeMessagesForProvider(messages) // Step 3: Strip plain-text reasoning blocks from assistant content arrays. - const cleanedMessages = stripPlainTextReasoningBlocks(standardMessages) + const aiSdkMessages = stripPlainTextReasoningBlocks(sanitizedMessages as RooMessage[]) as ModelMessage[] - // Step 4: Convert to AI SDK messages. - const aiSdkMessages = cleanedMessages as ModelMessage[] - - // Step 5: Re-inject encrypted reasoning as properly-formed AI SDK reasoning parts. + // Step 4: Re-inject encrypted reasoning as properly-formed AI SDK reasoning parts. if (encryptedReasoningItems.length > 0) { injectEncryptedReasoning(aiSdkMessages, encryptedReasoningItems, messages as RooMessage[]) } @@ -308,7 +301,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion accessToken = refreshed continue } - throw handleAiSdkError(error, this.providerName) + throw error } } } @@ -352,7 +345,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion return text } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index 3deeba6cf96..995b33a2231 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -12,13 +12,7 @@ import type { ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -183,8 +177,7 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si yield processUsage(usage) }) } catch (error) { - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) - throw handleAiSdkError(error, this.config.providerName) + throw error } } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 5bc7ae8382d..e384f38fa3c 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -19,16 +19,11 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -38,7 +33,7 @@ export type OpenAiNativeModel = ReturnType /** * An encrypted reasoning item extracted from the conversation history. - * These are standalone items injected by `buildCleanConversationHistory` with + * These are standalone RooReasoningMessage items with * `{ type: "reasoning", encrypted_content: "...", id: "...", summary: [...] }`. */ export interface EncryptedReasoningItem { @@ -52,12 +47,13 @@ export interface EncryptedReasoningItem { * Strip plain-text reasoning blocks from assistant message content arrays. * * Plain-text reasoning blocks (`{ type: "reasoning", text: "..." }`) inside - * assistant content arrays would be converted by `convertToAiSdkMessages` - * into AI SDK reasoning parts WITHOUT `providerOptions.openai.itemId`. - * The `@ai-sdk/openai` Responses provider rejects those with console warnings. + * assistant content arrays would become AI SDK reasoning parts WITHOUT + * `providerOptions.openai.itemId`. The `@ai-sdk/openai` Responses provider + * rejects those with console warnings. * - * This function removes them BEFORE conversion. If an assistant message's - * content becomes empty after filtering, the message is removed entirely. + * This function removes them before sending to the API. If an assistant + * message's content becomes empty after filtering, the message is removed + * entirely. */ export function stripPlainTextReasoningBlocks(messages: RooMessage[]): RooMessage[] { return messages.reduce((acc, msg) => { @@ -88,9 +84,8 @@ export function stripPlainTextReasoningBlocks(messages: RooMessage[]): RooMessag /** * Collect encrypted reasoning items from the messages array. * - * These are standalone items with `type: "reasoning"` and `encrypted_content`, - * injected by `buildCleanConversationHistory` for OpenAI Responses API - * reasoning continuity. + * These are standalone RooReasoningMessage items with `type: "reasoning"` + * and `encrypted_content`, used for OpenAI Responses API reasoning continuity. */ export function collectEncryptedReasoningItems(messages: RooMessage[]): EncryptedReasoningItem[] { const items: EncryptedReasoningItem[] = [] @@ -419,26 +414,19 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio this.lastEncryptedContent = undefined this.lastServiceTier = undefined - // Step 1: Collect encrypted reasoning items and their positions before filtering. - // These are standalone items injected by buildCleanConversationHistory: - // { type: "reasoning", encrypted_content: "...", id: "...", summary: [...] } + // Step 1: Collect encrypted reasoning items before sanitization strips them. const encryptedReasoningItems = collectEncryptedReasoningItems(messages) - // Step 2: Filter out standalone encrypted reasoning items (they lack role - // and would break convertToAiSdkMessages which expects user/assistant/tool). - const standardMessages = messages.filter( - (msg) => (msg as any).type !== "reasoning" || !(msg as any).encrypted_content, - ) + // Step 2: Sanitize messages for the provider API (allowlist: role, content, providerOptions). + // This also filters out standalone RooReasoningMessage items (no role field). + const sanitizedMessages = sanitizeMessagesForProvider(messages) // Step 3: Strip plain-text reasoning blocks from assistant content arrays. // These would be converted to AI SDK reasoning parts WITHOUT // providerOptions.openai.itemId, which the Responses provider rejects. - const cleanedMessages = stripPlainTextReasoningBlocks(standardMessages) - - // Step 4: Convert to AI SDK messages. - const aiSdkMessages = cleanedMessages as ModelMessage[] + const aiSdkMessages = stripPlainTextReasoningBlocks(sanitizedMessages as RooMessage[]) as ModelMessage[] - // Step 5: Re-inject encrypted reasoning as properly-formed AI SDK reasoning + // Step 4: Re-inject encrypted reasoning as properly-formed AI SDK reasoning // parts with providerOptions.openai.itemId and reasoningEncryptedContent. if (encryptedReasoningItems.length > 0) { injectEncryptedReasoning(aiSdkMessages, encryptedReasoningItems, messages as RooMessage[]) @@ -523,7 +511,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } }) } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } @@ -559,7 +547,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio return text } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index c6b3ec489c7..b02e365f3b4 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -17,11 +17,9 @@ import type { ApiHandlerOptions } from "../../shared/api" import { TagMatcher } from "../../utils/tag-matcher" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -240,7 +238,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl yield* yieldResponseMessage(result) } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } @@ -286,7 +284,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl yield this.processUsageMetrics(usage, modelInfo, providerMetadata as any) } } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index f9a1c685a37..16bfc331b2b 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -17,13 +17,8 @@ import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" import { getModelParams } from "../transform/model-params" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - processAiSdkStreamPart, - yieldResponseMessage, -} from "../transform/ai-sdk" -import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" +import { convertToolsForAiSdk, processAiSdkStreamPart, yieldResponseMessage } from "../transform/ai-sdk" +import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { BaseProvider } from "./base-provider" import { getModels, getModelsFromCache } from "./fetchers/modelCache" @@ -187,6 +182,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH metadata?.systemProviderOptions, ) + applyCacheBreakpoints(aiSdkMessages) + try { const result = streamText({ model: openrouter.chat(modelId), @@ -200,35 +197,44 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH providerOptions, }) + let lastStreamError: string | undefined for await (const part of result.fullStream) { - yield* processAiSdkStreamPart(part) + for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "error") { + lastStreamError = chunk.message + } + yield chunk + } } - const providerMetadata = - (await result.providerMetadata) ?? (await (result as any).experimental_providerMetadata) - - const usage = await result.usage - const totalUsage = await result.totalUsage - const usageChunk = this.normalizeUsage( - { - inputTokens: totalUsage.inputTokens ?? usage.inputTokens ?? 0, - outputTokens: totalUsage.outputTokens ?? usage.outputTokens ?? 0, - }, - providerMetadata, - model.info, - ) - yield usageChunk + try { + const providerMetadata = + (await result.providerMetadata) ?? (await (result as any).experimental_providerMetadata) + + const usage = await result.usage + const totalUsage = await result.totalUsage + const usageChunk = this.normalizeUsage( + { + inputTokens: totalUsage.inputTokens ?? usage.inputTokens ?? 0, + outputTokens: totalUsage.outputTokens ?? usage.outputTokens ?? 0, + }, + providerMetadata, + model.info, + ) + yield usageChunk + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError + } yield* yieldResponseMessage(result) } catch (error: any) { const errorMessage = error instanceof Error ? error.message : String(error) const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") TelemetryService.instance.captureException(apiError) - yield { - type: "error", - error: "OpenRouterError", - message: `${this.providerName} API Error: ${errorMessage}`, - } + throw error } } @@ -325,7 +331,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const errorMessage = error instanceof Error ? error.message : String(error) const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") TelemetryService.instance.captureException(apiError) - throw new Error(`${this.providerName} completion error: ${errorMessage}`) + throw error } } diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 4bc05751aa1..bf730d00ec4 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -7,14 +7,8 @@ import { type ModelInfo, type ModelRecord, requestyDefaultModelId, requestyDefau import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" -import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" +import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -211,6 +205,8 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan metadata?.systemProviderOptions, ) + applyCacheBreakpoints(aiSdkMessages) + const requestOptions: Parameters[0] = { model: languageModel, system: effectiveSystemPrompt, @@ -231,7 +227,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan yield processUsage(usage, info, providerMetadata as RequestyProviderMetadata) }) } catch (error) { - throw handleAiSdkError(error, "Requesty") + throw error } } @@ -252,7 +248,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan return text } catch (error) { - throw handleAiSdkError(error, "Requesty") + throw error } } diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index e426213a622..6faf59ee9aa 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -13,11 +13,10 @@ import { getModelParams } from "../transform/model-params" import { convertToolsForAiSdk, processAiSdkStreamPart, - handleAiSdkError, mapToolChoice, yieldResponseMessage, } from "../transform/ai-sdk" -import { applyToolCacheOptions } from "../transform/cache-breakpoints" +import { applyCacheBreakpoints, applyToolCacheOptions } from "../transform/cache-breakpoints" import type { RooReasoningParams } from "../transform/reasoning" import { getRooReasoning } from "../transform/reasoning" @@ -161,6 +160,8 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler const tools = convertToolsForAiSdk(this.convertToolsForOpenAI(metadata?.tools)) applyToolCacheOptions(tools as Parameters[0], metadata?.toolProviderOptions) + applyCacheBreakpoints(aiSdkMessages) + let lastStreamError: string | undefined try { @@ -265,7 +266,7 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler console.error(`[RooHandler] Error during message streaming: ${JSON.stringify(errorContext)}`) - throw handleAiSdkError(error, "Roo Code Cloud") + throw error } } @@ -281,7 +282,7 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler }) return result.text } catch (error) { - throw handleAiSdkError(error, "Roo Code Cloud") + throw error } } diff --git a/src/api/providers/sambanova.ts b/src/api/providers/sambanova.ts index 456d2b67751..bf475239a07 100644 --- a/src/api/providers/sambanova.ts +++ b/src/api/providers/sambanova.ts @@ -7,11 +7,9 @@ import { sambaNovaModels, sambaNovaDefaultModelId, type ModelInfo } from "@roo-c import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, - handleAiSdkError, flattenAiSdkMessagesToStringContent, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -165,7 +163,7 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa yield processUsage(usage, providerMetadata as Parameters[1]) }) } catch (error) { - throw handleAiSdkError(error, "SambaNova") + throw error } } diff --git a/src/api/providers/utils/__tests__/error-handler.spec.ts b/src/api/providers/utils/__tests__/error-handler.spec.ts index 54971134dff..5f20b6b9669 100644 --- a/src/api/providers/utils/__tests__/error-handler.spec.ts +++ b/src/api/providers/utils/__tests__/error-handler.spec.ts @@ -1,4 +1,4 @@ -import { handleProviderError, handleOpenAIError } from "../error-handler" +import { handleProviderError } from "../error-handler" describe("handleProviderError", () => { const providerName = "TestProvider" @@ -259,25 +259,3 @@ describe("handleProviderError", () => { }) }) -describe("handleOpenAIError (backward compatibility)", () => { - it("should be an alias for handleProviderError with completion prefix", () => { - const error = new Error("API failed") as any - error.status = 500 - - const result = handleOpenAIError(error, "OpenAI") - - expect(result).toBeInstanceOf(Error) - expect(result.message).toContain("OpenAI completion error") - expect((result as any).status).toBe(500) - }) - - it("should preserve backward compatibility for existing callers", () => { - const error = new Error("Authentication failed") as any - error.status = 401 - - const result = handleOpenAIError(error, "Roo Code Cloud") - - expect(result.message).toBe("Roo Code Cloud completion error: Authentication failed") - expect((result as any).status).toBe(401) - }) -}) diff --git a/src/api/providers/utils/error-handler.ts b/src/api/providers/utils/error-handler.ts index 2c55b96f9cf..2352b4b79a5 100644 --- a/src/api/providers/utils/error-handler.ts +++ b/src/api/providers/utils/error-handler.ts @@ -105,10 +105,3 @@ export function handleProviderError( return wrapped } -/** - * Specialized handler for OpenAI-compatible providers - * Re-exports with OpenAI-specific defaults for backward compatibility - */ -export function handleOpenAIError(error: unknown, providerName: string): Error { - return handleProviderError(error, providerName, { messagePrefix: "completion" }) -} diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index 4c9726860fa..b17d981b954 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -12,11 +12,9 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -182,7 +180,7 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple yield* yieldResponseMessage(result) } catch (error) { - throw handleAiSdkError(error, "Vercel AI Gateway") + throw error } } @@ -204,7 +202,7 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple return text } catch (error) { - throw handleAiSdkError(error, "Vercel AI Gateway") + throw error } } diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index c46b43ecb8d..57936b9f3e2 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -14,11 +14,9 @@ import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -191,14 +189,11 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl yield* yieldResponseMessage(result) } catch (error) { - throw handleAiSdkError(error, this.providerName, { - onError: (msg) => { - TelemetryService.instance.captureException( - new ApiProviderError(msg, this.providerName, modelId, "createMessage"), - ) - }, - formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg }), - }) + const errorMessage = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage"), + ) + throw error } } @@ -349,14 +344,11 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl return text } catch (error) { - throw handleAiSdkError(error, this.providerName, { - onError: (msg) => { - TelemetryService.instance.captureException( - new ApiProviderError(msg, this.providerName, modelId, "completePrompt"), - ) - }, - formatMessage: (msg) => t("common:errors.gemini.generate_complete_prompt", { error: msg }), - }) + const errorMessage = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt"), + ) + throw error } } diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 057fbecdb28..6d2c117448a 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -6,16 +6,11 @@ import { type XAIModelId, xaiDefaultModelId, xaiModels, type ModelInfo } from "@ import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" @@ -140,8 +135,8 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler const { temperature, reasoning } = this.getModel() const languageModel = this.getLanguageModel() - // Convert messages to AI SDK format - const aiSdkMessages = messages + // Sanitize messages for the provider API (allowlist: role, content, providerOptions). + const aiSdkMessages = sanitizeMessagesForProvider(messages) // Convert tools to OpenAI format first, then to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) @@ -170,7 +165,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler yield processUsage(usage, providerMetadata as Parameters[1]) }) } catch (error) { - throw handleAiSdkError(error, "xAI") + throw error } } @@ -192,7 +187,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler return text } catch (error) { - throw handleAiSdkError(error, "xAI") + throw error } } diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index d052de842d7..bbdd003a0e2 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -14,13 +14,7 @@ import { import { type ApiHandlerOptions, shouldUseReasoningEffort } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - consumeAiSdkStream, - mapToolChoice, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -132,7 +126,7 @@ export class ZAiHandler extends BaseProvider implements SingleCompletionHandler try { yield* consumeAiSdkStream(result) } catch (error) { - throw handleAiSdkError(error, "Z.ai") + throw error } } @@ -153,7 +147,7 @@ export class ZAiHandler extends BaseProvider implements SingleCompletionHandler return text } catch (error) { - throw handleAiSdkError(error, "Z.ai") + throw error } } diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index 6d30099b08a..cb9c80c10d2 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -1,7 +1,5 @@ -import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, consumeAiSdkStream, @@ -18,619 +16,6 @@ vitest.mock("ai", () => ({ })) describe("AI SDK conversion utilities", () => { - describe("convertToAiSdkMessages", () => { - it("converts simple string messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there" }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ role: "user", content: "Hello" }) - expect(result[1]).toEqual({ role: "assistant", content: "Hi there" }) - }) - - it("converts user messages with text content blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [{ type: "text", text: "Hello world" }], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "user", - content: [{ type: "text", text: "Hello world" }], - }) - }) - - it("converts user messages with image content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "What is in this image?" }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "base64encodeddata", - }, - }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "user", - content: [ - { type: "text", text: "What is in this image?" }, - { - type: "image", - image: "data:image/png;base64,base64encodeddata", - mimeType: "image/png", - }, - ], - }) - }) - - it("converts user messages with URL image content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "What is in this image?" }, - { - type: "image", - source: { - type: "url", - url: "https://example.com/image.png", - }, - } as any, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "user", - content: [ - { type: "text", text: "What is in this image?" }, - { - type: "image", - image: "https://example.com/image.png", - }, - ], - }) - }) - - it("converts tool results into separate tool role messages with resolved tool names", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_123", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "Tool result content", - }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { - type: "tool-call", - toolCallId: "call_123", - toolName: "read_file", - input: { path: "test.ts" }, - }, - ], - }) - // Tool results now go to role: "tool" messages per AI SDK v6 schema - expect(result[1]).toEqual({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call_123", - toolName: "read_file", - output: { type: "text", value: "Tool result content" }, - }, - ], - }) - }) - - it("uses unknown_tool for tool results without matching tool call", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_orphan", - content: "Orphan result", - }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - // Tool results go to role: "tool" messages - expect(result[0]).toEqual({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call_orphan", - toolName: "unknown_tool", - output: { type: "text", value: "Orphan result" }, - }, - ], - }) - }) - - it("separates tool results and text content into different messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_123", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "File contents here", - }, - { - type: "text", - text: "Please analyze this file", - }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(3) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { - type: "tool-call", - toolCallId: "call_123", - toolName: "read_file", - input: { path: "test.ts" }, - }, - ], - }) - // Tool results go first in a "tool" message - expect(result[1]).toEqual({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call_123", - toolName: "read_file", - output: { type: "text", value: "File contents here" }, - }, - ], - }) - // Text content goes in a separate "user" message - expect(result[2]).toEqual({ - role: "user", - content: [{ type: "text", text: "Please analyze this file" }], - }) - }) - - it("converts assistant messages with tool use", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "text", text: "Let me read that file" }, - { - type: "tool_use", - id: "call_456", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { type: "text", text: "Let me read that file" }, - { - type: "tool-call", - toolCallId: "call_456", - toolName: "read_file", - input: { path: "test.ts" }, - }, - ], - }) - }) - - it("handles empty assistant content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "assistant", - content: [{ type: "text", text: "" }], - }) - }) - - it("converts assistant reasoning blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "reasoning" as any, text: "Thinking..." }, - { type: "text", text: "Answer" }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { type: "reasoning", text: "Thinking..." }, - { type: "text", text: "Answer" }, - ], - }) - }) - - it("converts assistant thinking blocks to reasoning", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "thinking" as any, thinking: "Deep thought", signature: "sig" }, - { type: "text", text: "OK" }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { - type: "reasoning", - text: "Deep thought", - providerOptions: { - bedrock: { signature: "sig" }, - anthropic: { signature: "sig" }, - }, - }, - { type: "text", text: "OK" }, - ], - }) - }) - - it("converts assistant message-level reasoning_content to reasoning part", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [{ type: "text", text: "Answer" }], - reasoning_content: "Thinking...", - } as any, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { type: "reasoning", text: "Thinking..." }, - { type: "text", text: "Answer" }, - ], - }) - }) - - it("prefers message-level reasoning_content over reasoning blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "reasoning" as any, text: "BLOCK" }, - { type: "text", text: "Answer" }, - ], - reasoning_content: "MSG", - } as any, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "assistant", - content: [ - { type: "reasoning", text: "MSG" }, - { type: "text", text: "Answer" }, - ], - }) - }) - - it("attaches thoughtSignature to first tool-call part for Gemini 3 round-tripping", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "text", text: "Let me check that." }, - { - type: "tool_use", - id: "tool-1", - name: "read_file", - input: { path: "test.txt" }, - }, - { type: "thoughtSignature", thoughtSignature: "encrypted-sig-abc" } as any, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - const assistantMsg = result[0] - expect(assistantMsg.role).toBe("assistant") - - const content = assistantMsg.content as any[] - expect(content).toHaveLength(2) // text + tool-call (thoughtSignature block is consumed, not passed through) - - const toolCallPart = content.find((p: any) => p.type === "tool-call") - expect(toolCallPart).toBeDefined() - expect(toolCallPart.providerOptions).toEqual({ - google: { thoughtSignature: "encrypted-sig-abc" }, - vertex: { thoughtSignature: "encrypted-sig-abc" }, - }) - }) - - it("attaches thoughtSignature only to the first tool-call in parallel calls", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "tool-1", - name: "get_weather", - input: { city: "Paris" }, - }, - { - type: "tool_use", - id: "tool-2", - name: "get_weather", - input: { city: "London" }, - }, - { type: "thoughtSignature", thoughtSignature: "sig-parallel" } as any, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - const content = (result[0] as any).content as any[] - - const toolCalls = content.filter((p: any) => p.type === "tool-call") - expect(toolCalls).toHaveLength(2) - - // Only the first tool call should have the signature - expect(toolCalls[0].providerOptions).toEqual({ - google: { thoughtSignature: "sig-parallel" }, - vertex: { thoughtSignature: "sig-parallel" }, - }) - // Second tool call should NOT have the signature - expect(toolCalls[1].providerOptions).toBeUndefined() - }) - - it("does not attach providerOptions when no thoughtSignature block is present", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "text", text: "Using tool" }, - { - type: "tool_use", - id: "tool-1", - name: "read_file", - input: { path: "test.txt" }, - }, - ], - }, - ] - - const result = convertToAiSdkMessages(messages) - const content = (result[0] as any).content as any[] - const toolCallPart = content.find((p: any) => p.type === "tool-call") - - expect(toolCallPart).toBeDefined() - expect(toolCallPart.providerOptions).toBeUndefined() - }) - - it("attaches valid reasoning_details as providerOptions.openrouter, filtering invalid entries", () => { - const validEncrypted = { - type: "reasoning.encrypted", - data: "encrypted_blob_data", - id: "tool_call_123", - format: "google-gemini-v1", - index: 0, - } - const invalidEncrypted = { - // type is "reasoning.encrypted" but has text instead of data — - // this is a plaintext summary mislabeled as encrypted by Gemini/OpenRouter. - // The provider's ReasoningDetailEncryptedSchema requires `data: string`, - // so including this causes the entire Zod safeParse to fail. - type: "reasoning.encrypted", - text: "Plaintext reasoning summary", - id: "tool_call_123", - format: "google-gemini-v1", - index: 0, - } - const textWithSignature = { - type: "reasoning.text", - text: "Some reasoning content", - signature: "stale-signature-from-previous-model", - } - - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "text", text: "Using a tool" }, - { - type: "tool_use", - id: "tool_call_123", - name: "attempt_completion", - input: { result: "done" }, - }, - ], - reasoning_details: [validEncrypted, invalidEncrypted, textWithSignature], - } as any, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - expect(assistantMsg.role).toBe("assistant") - expect(assistantMsg.providerOptions).toBeDefined() - expect(assistantMsg.providerOptions.openrouter).toBeDefined() - const details = assistantMsg.providerOptions.openrouter.reasoning_details - // Only the valid entries should survive filtering (invalidEncrypted dropped) - expect(details).toHaveLength(2) - expect(details[0]).toEqual(validEncrypted) - // Signatures should be preserved as-is for same-model Anthropic conversations via OpenRouter - expect(details[1]).toEqual(textWithSignature) - }) - - it("does not attach providerOptions when no reasoning_details are present", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [{ type: "text", text: "Just text" }], - }, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - expect(assistantMsg.providerOptions).toBeUndefined() - }) - - it("does not attach providerOptions when reasoning_details is an empty array", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [{ type: "text", text: "Just text" }], - reasoning_details: [], - } as any, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - expect(assistantMsg.providerOptions).toBeUndefined() - }) - - it("preserves both reasoning_details and thoughtSignature providerOptions", () => { - const reasoningDetails = [ - { - type: "reasoning.encrypted", - data: "encrypted_data", - id: "tool_call_abc", - format: "google-gemini-v1", - index: 0, - }, - ] - - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "thoughtSignature", thoughtSignature: "sig-xyz" } as any, - { type: "text", text: "Using tool" }, - { - type: "tool_use", - id: "tool_call_abc", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - reasoning_details: reasoningDetails, - } as any, - ] - - const result = convertToAiSdkMessages(messages) - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - // Message-level providerOptions carries reasoning_details - expect(assistantMsg.providerOptions.openrouter.reasoning_details).toEqual(reasoningDetails) - // Part-level providerOptions carries thoughtSignature on the first tool-call - const toolCallPart = assistantMsg.content.find((p: any) => p.type === "tool-call") - expect(toolCallPart.providerOptions.google.thoughtSignature).toBe("sig-xyz") - }) - }) - describe("convertToolsForAiSdk", () => { it("returns undefined for empty tools", () => { expect(convertToolsForAiSdk(undefined)).toBeUndefined() @@ -920,7 +305,8 @@ describe("AI SDK conversion utilities", () => { } const result = extractAiSdkErrorMessage(apiError) - expect(result).toBe("API Error (429): Rate limit exceeded") + // No responseBody present — new behavior reports that instead of using error.message + expect(result).toBe("API Error (429): No response body available") }) it("should handle AI_APICallError without status", () => { @@ -930,7 +316,8 @@ describe("AI SDK conversion utilities", () => { } const result = extractAiSdkErrorMessage(apiError) - expect(result).toBe("Connection timeout") + // No responseBody, no status — new behavior reports missing body + expect(result).toBe("API Error: No response body available") }) it("should extract message from standard Error", () => { @@ -956,7 +343,7 @@ describe("AI SDK conversion utilities", () => { expect(result).not.toBe("API call failed") }) - it("should fall back to message when AI_APICallError responseBody is non-JSON", () => { + it("should include raw responseBody when AI_APICallError responseBody is non-JSON", () => { const apiError = { name: "AI_APICallError", message: "Server error", @@ -965,7 +352,8 @@ describe("AI SDK conversion utilities", () => { } const result = extractAiSdkErrorMessage(apiError) - expect(result).toContain("Server error") + // New behavior: raw responseBody is included instead of falling back to error.message + expect(result).toBe("API Error (500): Internal Server Error") }) it("should extract message from AI_RetryError lastError responseBody", () => { @@ -1545,3 +933,361 @@ describe("consumeAiSdkStream", () => { expect(error!.message).not.toContain("No output generated") }) }) + +describe("Error extraction utilities", () => { + describe("extractMessageFromResponseBody", () => { + it("extracts message from OpenRouter-style error with error.metadata.raw", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ + message: "A maximum of 4 blocks with cache_control may be provided. Found 5.", + }), + provider_name: "Amazon Bedrock", + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("[Amazon Bedrock] A maximum of 4 blocks with cache_control may be provided. Found 5.") + }) + + it("extracts message from OpenRouter-style error without provider_name", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ message: "Token limit exceeded" }), + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("Token limit exceeded") + }) + + it("falls through when error.metadata.raw is invalid JSON", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: "not valid json {{{", + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + // Should fall through to the error.message path + expect(result).toBe("[400] Provider returned error") + }) + + it("falls through when error.metadata.raw has no message field", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ status: "failed", detail: "something" }), + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + // Should fall through to the error.message path + expect(result).toBe("[400] Provider returned error") + }) + + it("extracts direct error.message format", () => { + const body = JSON.stringify({ + error: { + message: "actual error from provider", + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("actual error from provider") + }) + + it("extracts error.message with string code", () => { + const body = JSON.stringify({ + error: { + message: "rate limit exceeded", + code: "rate_limit", + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("[rate_limit] rate limit exceeded") + }) + + it("returns undefined for non-JSON input", () => { + const result = extractMessageFromResponseBody("this is not json at all") + expect(result).toBeUndefined() + }) + + it("returns undefined for empty string", () => { + const result = extractMessageFromResponseBody("") + expect(result).toBeUndefined() + }) + + it("extracts string error format", () => { + const body = JSON.stringify({ error: "something went wrong" }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("something went wrong") + }) + + it("extracts top-level message format", () => { + const body = JSON.stringify({ message: "top level error" }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("top level error") + }) + + it("extracts message from Anthropic-style error with error.type", () => { + const body = JSON.stringify({ + type: "error", + error: { + type: "overloaded_error", + message: "Overloaded", + }, + }) + const result = extractMessageFromResponseBody(body) + expect(result).toBeDefined() + expect(result).toContain("Overloaded") + }) + + it("extracts message from OpenRouter + Anthropic nested error format in metadata.raw", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + message: + "A maximum of 4 blocks with cache_control may be provided. Found 5.", + }, + }), + provider_name: "Anthropic", + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe( + "[Anthropic] [invalid_request_error] A maximum of 4 blocks with cache_control may be provided. Found 5.", + ) + }) + + it("extracts message from OpenRouter + Anthropic nested error format without provider_name", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ + type: "error", + error: { + type: "overloaded_error", + message: "Overloaded", + }, + }), + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("[overloaded_error] Overloaded") + }) + }) + + describe("extractAiSdkErrorMessage", () => { + it("returns raw responseBody when structured parsing yields nothing for AI_APICallError", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: "Some unstructured error text from provider", + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("API Error (400): Some unstructured error text from provider") + expect(result).not.toContain("Bad Request") + }) + + it("returns 'No response body available' when responseBody is absent", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("API Error (400): No response body available") + expect(result).not.toContain("Bad Request") + }) + + it("extracts structured message from responseBody for AI_APICallError", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: JSON.stringify({ + error: { + message: "Context length exceeded", + code: "context_length_exceeded", + }, + }), + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("API Error (400): [context_length_exceeded] Context length exceeded") + expect(result).not.toContain("Bad Request") + }) + + it("never returns generic 'Bad Request' when responseBody has useful info", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ + message: "A maximum of 4 blocks with cache_control may be provided. Found 5.", + }), + provider_name: "Amazon Bedrock", + }, + }, + }), + } + + const result = extractAiSdkErrorMessage(error) + expect(result).not.toBe("Bad Request") + expect(result).not.toBe("API Error (400): Bad Request") + expect(result).toContain("A maximum of 4 blocks with cache_control may be provided") + }) + + it("includes raw malformed responseBody instead of swallowing it", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: "500 Internal Server Error", + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("API Error (400): 500 Internal Server Error") + expect(result).not.toContain("Bad Request") + }) + + it("handles AI_APICallError without statusCode", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + responseBody: JSON.stringify({ error: { message: "some error" } }), + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toContain("some error") + }) + + it("should extract message from deeply nested NoOutputGeneratedError → RetryError → APICallError chain", () => { + const error = { + name: "AI_NoOutputGeneratedError", + message: "No output generated. Check the stream for errors.", + cause: { + name: "AI_RetryError", + message: "Failed after 3 attempts.", + lastError: { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: JSON.stringify({ + error: { + message: "Your credit balance is too low.", + type: "insufficient_quota", + code: "quota_exceeded", + }, + }), + }, + errors: [], + }, + } + const result = extractAiSdkErrorMessage(error) + expect(result).toContain("quota_exceeded") + expect(result).toContain("Your credit balance is too low.") + expect(result).toContain("400") + expect(result).not.toContain("No output generated") + expect(result).not.toContain("Bad Request") + }) + + it("should extract message from RetryError with nested cause chain", () => { + const error = { + name: "AI_RetryError", + message: "Failed after 2 attempts.", + lastError: { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: JSON.stringify({ + type: "error", + error: { + type: "overloaded_error", + message: "Overloaded", + }, + }), + }, + errors: [], + } + const result = extractAiSdkErrorMessage(error) + expect(result).toContain("Overloaded") + expect(result).toContain("400") + }) + + it("should handle triple-nested error chain via .errors[] array", () => { + const error = { + name: "AI_NoOutputGeneratedError", + message: "No output generated.", + cause: { + name: "AI_RetryError", + message: "Failed after 3 attempts.", + lastError: { + name: "AI_APICallError", + message: "Server Error", + statusCode: 500, + responseBody: "", // empty — not useful + }, + errors: [ + { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: JSON.stringify({ + error: { message: "Invalid model ID", code: "invalid_model" }, + }), + }, + { + name: "AI_APICallError", + message: "Server Error", + statusCode: 500, + responseBody: "", + }, + ], + }, + } + const result = extractAiSdkErrorMessage(error) + expect(result).toContain("Invalid model ID") + expect(result).toContain("400") + }) + }) +}) diff --git a/src/api/transform/__tests__/anthropic-filter.spec.ts b/src/api/transform/__tests__/anthropic-filter.spec.ts deleted file mode 100644 index 46ad1a19526..00000000000 --- a/src/api/transform/__tests__/anthropic-filter.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" - -import { filterNonAnthropicBlocks, VALID_ANTHROPIC_BLOCK_TYPES } from "../anthropic-filter" - -describe("anthropic-filter", () => { - describe("VALID_ANTHROPIC_BLOCK_TYPES", () => { - it("should contain all valid Anthropic types", () => { - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("text")).toBe(true) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("image")).toBe(true) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("tool_use")).toBe(true) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("tool_result")).toBe(true) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("thinking")).toBe(true) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("redacted_thinking")).toBe(true) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("document")).toBe(true) - }) - - it("should not contain internal or provider-specific types", () => { - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("reasoning")).toBe(false) - expect(VALID_ANTHROPIC_BLOCK_TYPES.has("thoughtSignature")).toBe(false) - }) - }) - - describe("filterNonAnthropicBlocks", () => { - it("should pass through messages with string content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there!" }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toEqual(messages) - }) - - it("should pass through messages with valid Anthropic blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [{ type: "text", text: "Hello" }], - }, - { - role: "assistant", - content: [{ type: "text", text: "Hi there!" }], - }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toEqual(messages) - }) - - it("should filter out reasoning blocks from messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "reasoning" as any, text: "Internal reasoning" }, - { type: "text", text: "Response" }, - ], - }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toHaveLength(2) - expect(result[1].content).toEqual([{ type: "text", text: "Response" }]) - }) - - it("should filter out thoughtSignature blocks from messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "thoughtSignature", thoughtSignature: "encrypted-sig" } as any, - { type: "text", text: "Response" }, - ], - }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toHaveLength(2) - expect(result[1].content).toEqual([{ type: "text", text: "Response" }]) - }) - - it("should remove messages that become empty after filtering", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [{ type: "reasoning" as any, text: "Only reasoning" }], - }, - { role: "user", content: "Continue" }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toHaveLength(2) - expect(result[0].content).toBe("Hello") - expect(result[1].content).toBe("Continue") - }) - - it("should handle mixed content with multiple invalid block types", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "reasoning", text: "Reasoning" } as any, - { type: "text", text: "Text 1" }, - { type: "thoughtSignature", thoughtSignature: "sig" } as any, - { type: "text", text: "Text 2" }, - ], - }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toHaveLength(1) - expect(result[0].content).toEqual([ - { type: "text", text: "Text 1" }, - { type: "text", text: "Text 2" }, - ]) - }) - - it("should filter out any unknown block types", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "unknown_future_type", data: "some data" } as any, - { type: "text", text: "Valid text" }, - ], - }, - ] - - const result = filterNonAnthropicBlocks(messages) - - expect(result).toHaveLength(1) - expect(result[0].content).toEqual([{ type: "text", text: "Valid text" }]) - }) - }) -}) diff --git a/src/api/transform/__tests__/cache-breakpoints.spec.ts b/src/api/transform/__tests__/cache-breakpoints.spec.ts index c1b6c207010..81e8cd5ab8b 100644 --- a/src/api/transform/__tests__/cache-breakpoints.spec.ts +++ b/src/api/transform/__tests__/cache-breakpoints.spec.ts @@ -44,21 +44,28 @@ describe("applyCacheBreakpoints", () => { expect(messages[0].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) - it("places 2 breakpoints on 2 user messages", () => { + it("places 1 breakpoint on the last of 2 user messages (default maxBreakpoints=1)", () => { const messages: TestMessage[] = [{ role: "user" }, { role: "user" }] applyCacheBreakpoints(messages) + expect(messages[0].providerOptions).toBeUndefined() + expect(messages[1].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + }) + + it("places 2 breakpoints on 2 user messages when maxBreakpoints=2", () => { + const messages: TestMessage[] = [{ role: "user" }, { role: "user" }] + applyCacheBreakpoints(messages, { maxBreakpoints: 2 }) expect(messages[0].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) expect(messages[1].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) - it("places 2 breakpoints on 2 tool messages", () => { + it("places 1 breakpoint on the last of 2 tool messages (default maxBreakpoints=1)", () => { const messages: TestMessage[] = [{ role: "tool" }, { role: "tool" }] applyCacheBreakpoints(messages) - expect(messages[0].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[0].providerOptions).toBeUndefined() expect(messages[1].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) - it("targets last 2 non-assistant messages in a mixed conversation", () => { + it("targets last non-assistant message in a mixed conversation", () => { const messages: TestMessage[] = [ { role: "user" }, { role: "assistant" }, @@ -67,15 +74,15 @@ describe("applyCacheBreakpoints", () => { { role: "tool" }, ] applyCacheBreakpoints(messages) - // Last 2 non-assistant: index 2 (user) and index 4 (tool) + // Last 1 non-assistant: index 4 (tool) expect(messages[0].providerOptions).toBeUndefined() expect(messages[1].providerOptions).toBeUndefined() - expect(messages[2].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[2].providerOptions).toBeUndefined() expect(messages[3].providerOptions).toBeUndefined() expect(messages[4].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) - it("targets indices 3 and 5 in [user, assistant, tool, user, assistant, tool]", () => { + it("targets only index 5 in [user, assistant, tool, user, assistant, tool]", () => { const messages: TestMessage[] = [ { role: "user" }, { role: "assistant" }, @@ -88,7 +95,7 @@ describe("applyCacheBreakpoints", () => { expect(messages[0].providerOptions).toBeUndefined() expect(messages[1].providerOptions).toBeUndefined() expect(messages[2].providerOptions).toBeUndefined() - expect(messages[3].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[3].providerOptions).toBeUndefined() expect(messages[4].providerOptions).toBeUndefined() expect(messages[5].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) @@ -97,7 +104,7 @@ describe("applyCacheBreakpoints", () => { const messages: TestMessage[] = [{ role: "system" }, { role: "user" }, { role: "assistant" }, { role: "user" }] applyCacheBreakpoints(messages) expect(messages[0].providerOptions).toBeUndefined() - expect(messages[1].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[1].providerOptions).toBeUndefined() expect(messages[3].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) @@ -132,7 +139,7 @@ describe("applyCacheBreakpoints", () => { it("adds anchor breakpoint at ~1/3 with useAnchor and enough messages", () => { // 6 non-assistant messages (indices 0-5 in nonAssistantIndices) // Anchor at floor(6/3) = index 2 in nonAssistantIndices -> messages index 4 - // Last 2: indices 10 and 8 + // Last 1: index 10 const messages: TestMessage[] = [ { role: "user" }, // 0 - nonAssistant[0] { role: "assistant" }, // 1 @@ -142,21 +149,21 @@ describe("applyCacheBreakpoints", () => { { role: "assistant" }, // 5 { role: "user" }, // 6 - nonAssistant[3] { role: "assistant" }, // 7 - { role: "user" }, // 8 - nonAssistant[4] <- last 2 + { role: "user" }, // 8 - nonAssistant[4] { role: "assistant" }, // 9 - { role: "user" }, // 10 - nonAssistant[5] <- last 2 + { role: "user" }, // 10 - nonAssistant[5] <- last 1 ] applyCacheBreakpoints(messages, { useAnchor: true }) - // Should have 3 breakpoints: indices 4, 8, 10 + // Should have 2 breakpoints: indices 4 (anchor) and 10 (last 1) expect(messages[4].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) - expect(messages[8].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) expect(messages[10].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) // Others should NOT have breakpoints expect(messages[0].providerOptions).toBeUndefined() expect(messages[2].providerOptions).toBeUndefined() expect(messages[6].providerOptions).toBeUndefined() + expect(messages[8].providerOptions).toBeUndefined() }) it("does not add anchor when below anchorThreshold", () => { @@ -170,9 +177,9 @@ describe("applyCacheBreakpoints", () => { // 3 non-assistant messages, below default threshold of 5 applyCacheBreakpoints(messages, { useAnchor: true }) - // Last 2 only: indices 2 and 4 + // Last 1 only: index 4 expect(messages[0].providerOptions).toBeUndefined() - expect(messages[2].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[2].providerOptions).toBeUndefined() expect(messages[4].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) diff --git a/src/api/transform/__tests__/mistral-format.spec.ts b/src/api/transform/__tests__/mistral-format.spec.ts deleted file mode 100644 index 290bea1ec50..00000000000 --- a/src/api/transform/__tests__/mistral-format.spec.ts +++ /dev/null @@ -1,341 +0,0 @@ -// npx vitest run api/transform/__tests__/mistral-format.spec.ts - -import { Anthropic } from "@anthropic-ai/sdk" - -import { convertToMistralMessages, normalizeMistralToolCallId } from "../mistral-format" - -describe("normalizeMistralToolCallId", () => { - it("should strip non-alphanumeric characters and truncate to 9 characters", () => { - // OpenAI-style tool call ID: "call_5019f900..." -> "call5019f900..." -> first 9 chars = "call5019f" - expect(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")).toBe("call5019f") - }) - - it("should handle Anthropic-style tool call IDs", () => { - // Anthropic-style tool call ID - expect(normalizeMistralToolCallId("toolu_01234567890abcdef")).toBe("toolu0123") - }) - - it("should pad short IDs to 9 characters", () => { - expect(normalizeMistralToolCallId("abc")).toBe("abc000000") - expect(normalizeMistralToolCallId("tool-1")).toBe("tool10000") - }) - - it("should handle IDs that are exactly 9 alphanumeric characters", () => { - expect(normalizeMistralToolCallId("abcd12345")).toBe("abcd12345") - }) - - it("should return consistent results for the same input", () => { - const id = "call_5019f900a247472bacde0b82" - expect(normalizeMistralToolCallId(id)).toBe(normalizeMistralToolCallId(id)) - }) - - it("should handle edge cases", () => { - // Empty string - expect(normalizeMistralToolCallId("")).toBe("000000000") - - // Only non-alphanumeric characters - expect(normalizeMistralToolCallId("---___---")).toBe("000000000") - - // Mixed special characters - expect(normalizeMistralToolCallId("a-b_c.d@e")).toBe("abcde0000") - }) -}) - -describe("convertToMistralMessages", () => { - it("should convert simple text messages for user and assistant roles", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: "Hi there!", - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(2) - expect(mistralMessages[0]).toEqual({ - role: "user", - content: "Hello", - }) - expect(mistralMessages[1]).toEqual({ - role: "assistant", - content: "Hi there!", - }) - }) - - it("should handle user messages with image content", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "What is in this image?", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64data", - }, - }, - ], - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(1) - expect(mistralMessages[0].role).toBe("user") - - const content = mistralMessages[0].content as Array<{ - type: string - text?: string - imageUrl?: { url: string } - }> - - expect(Array.isArray(content)).toBe(true) - expect(content).toHaveLength(2) - expect(content[0]).toEqual({ type: "text", text: "What is in this image?" }) - expect(content[1]).toEqual({ - type: "image_url", - imageUrl: { url: "data:image/jpeg;base64,base64data" }, - }) - }) - - it("should handle user messages with only tool results", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "weather-123", - content: "Current temperature in London: 20°C", - }, - ], - }, - ] - - // Tool results are converted to Mistral "tool" role messages - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(1) - expect(mistralMessages[0].role).toBe("tool") - expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe( - normalizeMistralToolCallId("weather-123"), - ) - expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C") - }) - - it("should handle user messages with mixed content (text, image, and tool results)", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Here's the weather data and an image:", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "imagedata123", - }, - }, - { - type: "tool_result", - tool_use_id: "weather-123", - content: "Current temperature in London: 20°C", - }, - ], - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - // Mistral doesn't allow user messages after tool messages, so only tool results are converted - // User content (text/images) is intentionally skipped when there are tool results - expect(mistralMessages).toHaveLength(1) - - // Only the tool result should be present - expect(mistralMessages[0].role).toBe("tool") - expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe( - normalizeMistralToolCallId("weather-123"), - ) - expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C") - }) - - it("should handle assistant messages with text content", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "text", - text: "I'll help you with that question.", - }, - ], - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(1) - expect(mistralMessages[0].role).toBe("assistant") - expect(mistralMessages[0].content).toBe("I'll help you with that question.") - }) - - it("should handle assistant messages with tool use", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "text", - text: "Let me check the weather for you.", - }, - { - type: "tool_use", - id: "weather-123", - name: "get_weather", - input: { city: "London" }, - }, - ], - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(1) - expect(mistralMessages[0].role).toBe("assistant") - expect(mistralMessages[0].content).toBe("Let me check the weather for you.") - }) - - it("should handle multiple text blocks in assistant messages", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "text", - text: "First paragraph of information.", - }, - { - type: "text", - text: "Second paragraph with more details.", - }, - ], - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(1) - expect(mistralMessages[0].role).toBe("assistant") - expect(mistralMessages[0].content).toBe("First paragraph of information.\nSecond paragraph with more details.") - }) - - it("should handle a conversation with mixed message types", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "What's in this image?", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "imagedata", - }, - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "This image shows a landscape with mountains.", - }, - { - type: "tool_use", - id: "search-123", - name: "search_info", - input: { query: "mountain types" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "search-123", - content: "Found information about different mountain types.", - }, - ], - }, - { - role: "assistant", - content: "Based on the search results, I can tell you more about the mountains in the image.", - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - // Tool results are now converted to tool messages - expect(mistralMessages).toHaveLength(4) - - // User message with image - expect(mistralMessages[0].role).toBe("user") - const userContent = mistralMessages[0].content as Array<{ - type: string - text?: string - imageUrl?: { url: string } - }> - expect(Array.isArray(userContent)).toBe(true) - expect(userContent).toHaveLength(2) - - // Assistant message with text and toolCalls - expect(mistralMessages[1].role).toBe("assistant") - expect(mistralMessages[1].content).toBe("This image shows a landscape with mountains.") - - // Tool result message - expect(mistralMessages[2].role).toBe("tool") - expect((mistralMessages[2] as { toolCallId?: string }).toolCallId).toBe( - normalizeMistralToolCallId("search-123"), - ) - expect(mistralMessages[2].content).toBe("Found information about different mountain types.") - - // Final assistant message - expect(mistralMessages[3]).toEqual({ - role: "assistant", - content: "Based on the search results, I can tell you more about the mountains in the image.", - }) - }) - - it("should handle empty content in assistant messages", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "search-123", - name: "search_info", - input: { query: "test query" }, - }, - ], - }, - ] - - const mistralMessages = convertToMistralMessages(anthropicMessages) - expect(mistralMessages).toHaveLength(1) - expect(mistralMessages[0].role).toBe("assistant") - expect(mistralMessages[0].content).toBeUndefined() - }) -}) diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts deleted file mode 100644 index 51628601ea0..00000000000 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ /dev/null @@ -1,1349 +0,0 @@ -// npx vitest run api/transform/__tests__/openai-format.spec.ts - -import OpenAI from "openai" -import type { RooMessage } from "../../../core/task-persistence/rooMessage" - -import { - convertToOpenAiMessages, - consolidateReasoningDetails, - sanitizeGeminiMessages, - ReasoningDetail, -} from "../openai-format" -import { normalizeMistralToolCallId } from "../mistral-format" - -describe("convertToOpenAiMessages", () => { - it("should convert simple text messages", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: "Hi there!", - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(2) - expect(openAiMessages[0]).toEqual({ - role: "user", - content: "Hello", - }) - expect(openAiMessages[1]).toEqual({ - role: "assistant", - content: "Hi there!", - }) - }) - - it("should handle messages with image content", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "What is in this image?", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64data", - }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - expect(openAiMessages[0].role).toBe("user") - - const content = openAiMessages[0].content as Array<{ - type: string - text?: string - image_url?: { url: string } - }> - - expect(Array.isArray(content)).toBe(true) - expect(content).toHaveLength(2) - expect(content[0]).toEqual({ type: "text", text: "What is in this image?" }) - expect(content[1]).toEqual({ - type: "image_url", - image_url: { url: "data:image/jpeg;base64,base64data" }, - }) - }) - - it("should preserve AI SDK image data URLs without double-prefixing", () => { - const messages: any[] = [ - { - role: "user", - content: [ - { - type: "image", - image: "data:image/png;base64,already_encoded", - mediaType: "image/png", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(messages) - const content = openAiMessages[0].content as Array<{ type: string; image_url?: { url: string } }> - expect(content[0]).toEqual({ - type: "image_url", - image_url: { url: "data:image/png;base64,already_encoded" }, - }) - }) - - it("should preserve AI SDK image http URLs without converting to data URLs", () => { - const messages: any[] = [ - { - role: "user", - content: [ - { - type: "image", - image: "https://example.com/image.png", - mediaType: "image/png", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(messages) - const content = openAiMessages[0].content as Array<{ type: string; image_url?: { url: string } }> - expect(content[0]).toEqual({ - type: "image_url", - image_url: { url: "https://example.com/image.png" }, - }) - }) - - it("should handle assistant messages with tool use (no normalization without normalizeToolCallId)", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "text", - text: "Let me check the weather.", - }, - { - type: "tool_use", - id: "weather-123", - name: "get_weather", - input: { city: "London" }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.role).toBe("assistant") - expect(assistantMessage.content).toBe("Let me check the weather.") - expect(assistantMessage.tool_calls).toHaveLength(1) - expect(assistantMessage.tool_calls![0]).toEqual({ - id: "weather-123", // Not normalized without normalizeToolCallId function - type: "function", - function: { - name: "get_weather", - arguments: JSON.stringify({ city: "London" }), - }, - }) - }) - - it("should handle user messages with tool results (no normalization without normalizeToolCallId)", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "weather-123", - content: "Current temperature in London: 20°C", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe("weather-123") // Not normalized without normalizeToolCallId function - expect(toolMessage.content).toBe("Current temperature in London: 20°C") - }) - - it("should normalize tool call IDs when normalizeToolCallId function is provided", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_5019f900a247472bacde0b82", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_5019f900a247472bacde0b82", - content: "file contents", - }, - ], - }, - ] - - // With normalizeToolCallId function - should normalize - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { - normalizeToolCallId: normalizeMistralToolCallId, - }) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.tool_calls![0].id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) - - const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) - }) - - it("should not normalize tool call IDs when normalizeToolCallId function is not provided", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_5019f900a247472bacde0b82", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_5019f900a247472bacde0b82", - content: "file contents", - }, - ], - }, - ] - - // Without normalizeToolCallId function - should NOT normalize - const openAiMessages = convertToOpenAiMessages(anthropicMessages, {}) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.tool_calls![0].id).toBe("call_5019f900a247472bacde0b82") - - const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.tool_call_id).toBe("call_5019f900a247472bacde0b82") - }) - - it("should use custom normalization function when provided", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "toolu_123", - name: "test_tool", - input: {}, - }, - ], - }, - ] - - // Custom normalization function that prefixes with "custom_" - const customNormalizer = (id: string) => `custom_${id}` - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { normalizeToolCallId: customNormalizer }) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.tool_calls![0].id).toBe("custom_toolu_123") - }) - - it("should use empty string for content when assistant message has only tool calls (Gemini compatibility)", () => { - // This test ensures that assistant messages with only tool_use blocks (no text) - // have content set to "" instead of undefined. Gemini (via OpenRouter) requires - // every message to have at least one "parts" field, which fails if content is undefined. - // See: ROO-425 - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "tool-123", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.role).toBe("assistant") - // Content should be an empty string, NOT undefined - expect(assistantMessage.content).toBe("") - expect(assistantMessage.tool_calls).toHaveLength(1) - expect(assistantMessage.tool_calls![0].id).toBe("tool-123") - }) - - it('should use "(empty)" placeholder for tool result with empty content (Gemini compatibility)', () => { - // This test ensures that tool messages with empty content get a placeholder instead - // of an empty string. Gemini (via OpenRouter) requires function responses to have - // non-empty content in the "parts" field, and an empty string causes validation failure - // with error: "Unable to submit request because it must include at least one parts field" - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "", // Empty string content - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe("tool-123") - // Content should be "(empty)" placeholder, NOT empty string - expect(toolMessage.content).toBe("(empty)") - }) - - it('should use "(empty)" placeholder for tool result with undefined content (Gemini compatibility)', () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-456", - // content is undefined/not provided - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.content).toBe("(empty)") - }) - - it('should use "(empty)" placeholder for tool result with empty array content (Gemini compatibility)', () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-789", - content: [], // Empty array - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.content).toBe("(empty)") - }) - - describe("empty text block filtering", () => { - it("should filter out empty text blocks from user messages (Gemini compatibility)", () => { - // This test ensures that user messages with empty text blocks are filtered out - // to prevent "must include at least one parts field" error from Gemini (via OpenRouter). - // Empty text blocks can occur in edge cases during message construction. - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "", // Empty text block should be filtered out - }, - { - type: "text", - text: "Hello, how are you?", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - expect(openAiMessages[0].role).toBe("user") - - const content = openAiMessages[0].content as Array<{ type: string; text?: string }> - // Should only have the non-empty text block - expect(content).toHaveLength(1) - expect(content[0]).toEqual({ type: "text", text: "Hello, how are you?" }) - }) - - it("should not create user message when all text blocks are empty (Gemini compatibility)", () => { - // If all text blocks are empty, no user message should be created - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "", // Empty - }, - { - type: "text", - text: "", // Also empty - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - // No messages should be created since all content is empty - expect(openAiMessages).toHaveLength(0) - }) - - it("should preserve image blocks when filtering empty text blocks", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "", // Empty text block should be filtered out - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "base64data", - }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - expect(openAiMessages[0].role).toBe("user") - - const content = openAiMessages[0].content as Array<{ - type: string - image_url?: { url: string } - }> - // Should only have the image block - expect(content).toHaveLength(1) - expect(content[0]).toEqual({ - type: "image_url", - image_url: { url: "data:image/png;base64,base64data" }, - }) - }) - }) - - describe("mergeToolResultText option", () => { - it("should merge text content into last tool message when mergeToolResultText is true", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result content", - }, - { - type: "text", - text: "\nSome context\n", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) - - // Should produce only one tool message with merged content - expect(openAiMessages).toHaveLength(1) - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe("tool-123") - expect(toolMessage.content).toBe( - "Tool result content\n\n\nSome context\n", - ) - }) - - it("should merge text into last tool message when multiple tool results exist", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_1", - content: "First result", - }, - { - type: "tool_result", - tool_use_id: "call_2", - content: "Second result", - }, - { - type: "text", - text: "Context", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) - - // Should produce two tool messages, with text merged into the last one - expect(openAiMessages).toHaveLength(2) - expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe("First result") - expect((openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe( - "Second result\n\nContext", - ) - }) - - it("should NOT merge text when images are present (fall back to user message)", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result content", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "base64data", - }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) - - // Should produce a tool message AND a user message (because image is present) - expect(openAiMessages).toHaveLength(2) - expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool") - expect(openAiMessages[1].role).toBe("user") - }) - - it("should create separate user message when mergeToolResultText is false", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result content", - }, - { - type: "text", - text: "\nSome context\n", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: false }) - - // Should produce a tool message AND a separate user message (default behavior) - expect(openAiMessages).toHaveLength(2) - expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool") - expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe( - "Tool result content", - ) - expect(openAiMessages[1].role).toBe("user") - }) - - it("should work with normalizeToolCallId when mergeToolResultText is true", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_5019f900a247472bacde0b82", - content: "Tool result content", - }, - { - type: "text", - text: "Context", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { - mergeToolResultText: true, - normalizeToolCallId: normalizeMistralToolCallId, - }) - - // Should merge AND normalize the ID - expect(openAiMessages).toHaveLength(1) - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) - expect(toolMessage.content).toBe( - "Tool result content\n\nContext", - ) - }) - - it("should handle user messages with only text content (no tool results)", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Hello, how are you?", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) - - // Should produce a normal user message - expect(openAiMessages).toHaveLength(1) - expect(openAiMessages[0].role).toBe("user") - }) - }) - - describe("reasoning_details transformation", () => { - it("should preserve reasoning_details when assistant content is a string", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: "Why don't scientists trust atoms? Because they make up everything!", - reasoning_details: [ - { - type: "reasoning.summary", - summary: "The user asked for a joke.", - format: "xai-responses-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "encrypted_data_here", - id: "rs_abc", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - expect(assistantMessage.role).toBe("assistant") - expect(assistantMessage.content).toBe("Why don't scientists trust atoms? Because they make up everything!") - expect(assistantMessage.reasoning_details).toHaveLength(2) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") - expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted") - expect(assistantMessage.reasoning_details[1].id).toBe("rs_abc") - }) - - it("should strip id from openai-responses-v1 blocks even when assistant content is a string", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: "Ok.", - reasoning_details: [ - { - type: "reasoning.summary", - id: "rs_should_be_stripped", - format: "openai-responses-v1", - index: 0, - summary: "internal", - data: "gAAAAA...", - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1") - expect(assistantMessage.reasoning_details[0].id).toBeUndefined() - }) - - it("should pass through all reasoning_details without extracting to top-level reasoning", () => { - // This simulates the stored format after receiving from xAI/Roo API - // The provider (roo.ts) now consolidates all reasoning into reasoning_details - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "I'll help you with that." }], - reasoning_details: [ - { - type: "reasoning.summary", - summary: '\n\n## Reviewing task progress', - format: "xai-responses-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...", - id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - expect(assistantMessage.role).toBe("assistant") - - // Should NOT have top-level reasoning field - we only use reasoning_details now - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through all reasoning_details preserving all fields - expect(assistantMessage.reasoning_details).toHaveLength(2) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") - expect(assistantMessage.reasoning_details[0].summary).toBe( - '\n\n## Reviewing task progress', - ) - expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted") - expect(assistantMessage.reasoning_details[1].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a") - expect(assistantMessage.reasoning_details[1].data).toBe( - "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...", - ) - }) - - it("should strip id from openai-responses-v1 blocks to avoid 404 errors (store: false)", () => { - // IMPORTANT: OpenAI's API returns a 404 error when we send back an `id` for - // reasoning blocks with format "openai-responses-v1" because we don't use - // `store: true` (we handle conversation state client-side). The error message is: - // "'{id}' not found. Items are not persisted when `store` is set to false." - const anthropicMessages = [ - { - role: "assistant" as const, - content: [ - { - type: "tool_use" as const, - id: "call_Tb4KVEmEpEAA8W1QcxjyD5Nh", - name: "attempt_completion", - input: { - result: "Why did the developer go broke?\n\nBecause they used up all their cache.", - }, - }, - ], - reasoning_details: [ - { - type: "reasoning.summary", - id: "rs_0de1fb80387fb36501694ad8d71c3081949934e6bb177e5ec5", - format: "openai-responses-v1", - index: 0, - summary: "It looks like I need to make sure I'm using the tool every time.", - data: "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...", - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have top-level reasoning field - we only use reasoning_details now - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through reasoning_details preserving most fields BUT stripping id - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") - // id should be STRIPPED for openai-responses-v1 format to avoid 404 errors - expect(assistantMessage.reasoning_details[0].id).toBeUndefined() - expect(assistantMessage.reasoning_details[0].summary).toBe( - "It looks like I need to make sure I'm using the tool every time.", - ) - expect(assistantMessage.reasoning_details[0].data).toBe( - "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...", - ) - expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1") - - // Should have tool_calls - expect(assistantMessage.tool_calls).toHaveLength(1) - expect(assistantMessage.tool_calls[0].id).toBe("call_Tb4KVEmEpEAA8W1QcxjyD5Nh") - }) - - it("should preserve id for non-openai-responses-v1 formats (e.g., xai-responses-v1)", () => { - // For other formats like xai-responses-v1, we should preserve the id - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "Response" }], - reasoning_details: [ - { - type: "reasoning.encrypted", - id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a", - format: "xai-responses-v1", - data: "encrypted_data_here", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should preserve id for xai-responses-v1 format - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a") - expect(assistantMessage.reasoning_details[0].format).toBe("xai-responses-v1") - }) - - it("should handle assistant messages with tool_calls and reasoning_details", () => { - // This simulates a message with both tool calls and reasoning - const anthropicMessages = [ - { - role: "assistant" as const, - content: [ - { - type: "tool_use" as const, - id: "call_62462410", - name: "read_file", - input: { files: [{ path: "alphametics.go" }] }, - }, - ], - reasoning_details: [ - { - type: "reasoning.summary", - summary: "## Reading the file to understand the structure", - format: "xai-responses-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "encrypted_data_here", - id: "rs_12345", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have top-level reasoning field - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through all reasoning_details - expect(assistantMessage.reasoning_details).toHaveLength(2) - - // Should have tool_calls - expect(assistantMessage.tool_calls).toHaveLength(1) - expect(assistantMessage.tool_calls[0].id).toBe("call_62462410") - expect(assistantMessage.tool_calls[0].function.name).toBe("read_file") - }) - - it("should pass through reasoning_details with only encrypted blocks", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "Response text" }], - reasoning_details: [ - { - type: "reasoning.encrypted", - data: "encrypted_data", - id: "rs_only_encrypted", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have reasoning field - expect(assistantMessage.reasoning).toBeUndefined() - - // Should still pass through reasoning_details - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.encrypted") - }) - - it("should pass through reasoning_details even when only summary blocks exist (no encrypted)", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "Response text" }], - reasoning_details: [ - { - type: "reasoning.summary", - summary: "Just a summary, no encrypted content", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have top-level reasoning field - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through reasoning_details preserving the summary block - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") - expect(assistantMessage.reasoning_details[0].summary).toBe("Just a summary, no encrypted content") - }) - - it("should handle messages without reasoning_details", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [{ type: "text", text: "Simple response" }], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should not have reasoning or reasoning_details - expect(assistantMessage.reasoning).toBeUndefined() - expect(assistantMessage.reasoning_details).toBeUndefined() - }) - - it("should pass through multiple reasoning_details blocks preserving all fields", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "Response" }], - reasoning_details: [ - { - type: "reasoning.summary", - summary: "First part of thinking. ", - format: "xai-responses-v1", - index: 0, - }, - { - type: "reasoning.summary", - summary: "Second part of thinking.", - format: "xai-responses-v1", - index: 1, - }, - { - type: "reasoning.encrypted", - data: "encrypted_data", - id: "rs_multi", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have top-level reasoning field - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through all reasoning_details - expect(assistantMessage.reasoning_details).toHaveLength(3) - expect(assistantMessage.reasoning_details[0].summary).toBe("First part of thinking. ") - expect(assistantMessage.reasoning_details[1].summary).toBe("Second part of thinking.") - expect(assistantMessage.reasoning_details[2].data).toBe("encrypted_data") - }) - }) -}) - -describe("consolidateReasoningDetails", () => { - it("should return empty array for empty input", () => { - expect(consolidateReasoningDetails([])).toEqual([]) - }) - - it("should return empty array for undefined input", () => { - expect(consolidateReasoningDetails(undefined as any)).toEqual([]) - }) - - it("should filter out corrupted encrypted blocks (missing data field)", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.encrypted", - // Missing data field - this should be filtered out - id: "rs_corrupted", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.text", - text: "Valid reasoning", - id: "rs_valid", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - // Should only have the text block, not the corrupted encrypted block - expect(result).toHaveLength(1) - expect(result[0].type).toBe("reasoning.text") - expect(result[0].text).toBe("Valid reasoning") - }) - - it("should concatenate text from multiple entries with same index", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.text", - text: "First part. ", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.text", - text: "Second part.", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - expect(result).toHaveLength(1) - expect(result[0].text).toBe("First part. Second part.") - }) - - it("should keep only the last encrypted block per index", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.encrypted", - data: "first_encrypted_data", - id: "rs_1", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "second_encrypted_data", - id: "rs_2", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - // Should only have one encrypted block - the last one - expect(result).toHaveLength(1) - expect(result[0].type).toBe("reasoning.encrypted") - expect(result[0].data).toBe("second_encrypted_data") - expect(result[0].id).toBe("rs_2") - }) - - it("should keep last signature and id from multiple entries", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.text", - text: "Part 1", - signature: "sig_1", - id: "id_1", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.text", - text: "Part 2", - signature: "sig_2", - id: "id_2", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - expect(result).toHaveLength(1) - expect(result[0].signature).toBe("sig_2") - expect(result[0].id).toBe("id_2") - }) - - it("should group by index correctly", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.text", - text: "Index 0 text", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.text", - text: "Index 1 text", - format: "google-gemini-v1", - index: 1, - }, - ] - - const result = consolidateReasoningDetails(details) - - expect(result).toHaveLength(2) - expect(result.find((r) => r.index === 0)?.text).toBe("Index 0 text") - expect(result.find((r) => r.index === 1)?.text).toBe("Index 1 text") - }) - - it("should handle summary blocks", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.summary", - summary: "Summary part 1", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.summary", - summary: "Summary part 2", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - // Summary should be concatenated when there's no text - expect(result).toHaveLength(1) - expect(result[0].summary).toBe("Summary part 1Summary part 2") - }) -}) - -describe("sanitizeGeminiMessages", () => { - it("should return messages unchanged for non-Gemini models", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: "You are helpful" }, - { role: "user", content: "Hello" }, - ] - - const result = sanitizeGeminiMessages(messages, "anthropic/claude-3-5-sonnet") - - expect(result).toEqual(messages) - }) - - it("should drop tool calls without reasoning_details for Gemini models", () => { - const messages = [ - { role: "system", content: "You are helpful" }, - { - role: "assistant", - content: "Let me read the file", - tool_calls: [ - { - id: "call_123", - type: "function", - function: { name: "read_file", arguments: '{"path":"test.ts"}' }, - }, - ], - // No reasoning_details - }, - { role: "tool", tool_call_id: "call_123", content: "file contents" }, - ] as OpenAI.Chat.ChatCompletionMessageParam[] - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - // Should have 2 messages: system and assistant (with content but no tool_calls) - // Tool message should be dropped - expect(result).toHaveLength(2) - expect(result[0].role).toBe("system") - expect(result[1].role).toBe("assistant") - expect((result[1] as any).tool_calls).toBeUndefined() - }) - - it("should filter reasoning_details to only include entries matching tool call IDs", () => { - const messages = [ - { - role: "assistant", - content: "", - tool_calls: [ - { - id: "call_abc", - type: "function", - function: { name: "read_file", arguments: "{}" }, - }, - ], - reasoning_details: [ - { - type: "reasoning.encrypted", - data: "valid_data", - id: "call_abc", // Matches tool call - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "mismatched_data", - id: "call_xyz", // Does NOT match any tool call - format: "google-gemini-v1", - index: 1, - }, - ], - }, - ] as any - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - expect(assistantMsg.tool_calls).toHaveLength(1) - expect(assistantMsg.reasoning_details).toHaveLength(1) - expect(assistantMsg.reasoning_details[0].id).toBe("call_abc") - }) - - it("should drop tool calls without matching reasoning_details", () => { - const messages = [ - { - role: "assistant", - content: "Some text", - tool_calls: [ - { - id: "call_abc", - type: "function", - function: { name: "tool_a", arguments: "{}" }, - }, - { - id: "call_def", - type: "function", - function: { name: "tool_b", arguments: "{}" }, - }, - ], - reasoning_details: [ - { - type: "reasoning.encrypted", - data: "data_for_abc", - id: "call_abc", // Only matches first tool call - format: "google-gemini-v1", - index: 0, - }, - ], - }, - { role: "tool", tool_call_id: "call_abc", content: "result a" }, - { role: "tool", tool_call_id: "call_def", content: "result b" }, - ] as any - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - // Should have: assistant with 1 tool_call, 1 tool message - expect(result).toHaveLength(2) - - const assistantMsg = result[0] as any - expect(assistantMsg.tool_calls).toHaveLength(1) - expect(assistantMsg.tool_calls[0].id).toBe("call_abc") - - // Only the tool result for call_abc should remain - expect(result[1].role).toBe("tool") - expect((result[1] as any).tool_call_id).toBe("call_abc") - }) - - it("should include reasoning_details without id (legacy format)", () => { - const messages = [ - { - role: "assistant", - content: "", - tool_calls: [ - { - id: "call_abc", - type: "function", - function: { name: "read_file", arguments: "{}" }, - }, - ], - reasoning_details: [ - { - type: "reasoning.text", - text: "Some reasoning without id", - format: "google-gemini-v1", - index: 0, - // No id field - }, - { - type: "reasoning.encrypted", - data: "encrypted_data", - id: "call_abc", - format: "google-gemini-v1", - index: 0, - }, - ], - }, - ] as any - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - // Both details should be included (one by matching id, one by having no id) - expect(assistantMsg.reasoning_details.length).toBeGreaterThanOrEqual(1) - }) - - it("should preserve messages without tool_calls", () => { - const messages = [ - { role: "system", content: "You are helpful" }, - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there!" }, - ] as OpenAI.Chat.ChatCompletionMessageParam[] - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - expect(result).toEqual(messages) - }) -}) diff --git a/src/api/transform/__tests__/r1-format.spec.ts b/src/api/transform/__tests__/r1-format.spec.ts deleted file mode 100644 index 3d875e9392f..00000000000 --- a/src/api/transform/__tests__/r1-format.spec.ts +++ /dev/null @@ -1,619 +0,0 @@ -// npx vitest run api/transform/__tests__/r1-format.spec.ts - -import { convertToR1Format } from "../r1-format" -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -describe("convertToR1Format", () => { - it("should convert basic text messages", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there" }, - ] - - const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there" }, - ] - - expect(convertToR1Format(input)).toEqual(expected) - }) - - it("should merge consecutive messages with same role", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "user", content: "How are you?" }, - { role: "assistant", content: "Hi!" }, - { role: "assistant", content: "I'm doing well" }, - ] - - const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "user", content: "Hello\nHow are you?" }, - { role: "assistant", content: "Hi!\nI'm doing well" }, - ] - - expect(convertToR1Format(input)).toEqual(expected) - }) - - it("should handle image content", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64data", - }, - }, - ], - }, - ] - - const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { - role: "user", - content: [ - { - type: "image_url", - image_url: { - url: "data:image/jpeg;base64,base64data", - }, - }, - ], - }, - ] - - expect(convertToR1Format(input)).toEqual(expected) - }) - - it("should handle mixed text and image content", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "Check this image:" }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64data", - }, - }, - ], - }, - ] - - const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "Check this image:" }, - { - type: "image_url", - image_url: { - url: "data:image/jpeg;base64,base64data", - }, - }, - ], - }, - ] - - expect(convertToR1Format(input)).toEqual(expected) - }) - - it("should merge mixed content messages with same role", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "First image:" }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "image1", - }, - }, - ], - }, - { - role: "user", - content: [ - { type: "text", text: "Second image:" }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "image2", - }, - }, - ], - }, - ] - - const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "First image:" }, - { - type: "image_url", - image_url: { - url: "data:image/jpeg;base64,image1", - }, - }, - { type: "text", text: "Second image:" }, - { - type: "image_url", - image_url: { - url: "data:image/png;base64,image2", - }, - }, - ], - }, - ] - - expect(convertToR1Format(input)).toEqual(expected) - }) - - it("should handle empty messages array", () => { - expect(convertToR1Format([])).toEqual([]) - }) - - it("should handle messages with empty content", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "" }, - { role: "assistant", content: "" }, - ] - - const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "user", content: "" }, - { role: "assistant", content: "" }, - ] - - expect(convertToR1Format(input)).toEqual(expected) - }) - - describe("tool calls support for DeepSeek interleaved thinking", () => { - it("should convert assistant messages with tool_use to OpenAI format", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "What's the weather?" }, - { - role: "assistant", - content: [ - { type: "text", text: "Let me check the weather for you." }, - { - type: "tool_use", - id: "call_123", - name: "get_weather", - input: { location: "San Francisco" }, - }, - ], - }, - ] - - const result = convertToR1Format(input) - - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ role: "user", content: "What's the weather?" }) - expect(result[1]).toMatchObject({ - role: "assistant", - content: "Let me check the weather for you.", - tool_calls: [ - { - id: "call_123", - type: "function", - function: { - name: "get_weather", - arguments: '{"location":"San Francisco"}', - }, - }, - ], - }) - }) - - it("should convert user messages with tool_result to OpenAI tool messages", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "What's the weather?" }, - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_123", - name: "get_weather", - input: { location: "San Francisco" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "72°F and sunny", - }, - ], - }, - ] - - const result = convertToR1Format(input) - - expect(result).toHaveLength(3) - expect(result[0]).toEqual({ role: "user", content: "What's the weather?" }) - expect(result[1]).toMatchObject({ - role: "assistant", - content: null, - tool_calls: expect.any(Array), - }) - expect(result[2]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "72°F and sunny", - }) - }) - - it("should handle tool_result with array content", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_456", - content: [ - { type: "text", text: "Line 1" }, - { type: "text", text: "Line 2" }, - ], - }, - ], - }, - ] - - const result = convertToR1Format(input) - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_456", - content: "Line 1\nLine 2", - }) - }) - - it("should preserve reasoning_content on assistant messages", () => { - const input = [ - { role: "user" as const, content: "Think about this" }, - { - role: "assistant" as const, - content: "Here's my answer", - reasoning_content: "Let me analyze step by step...", - }, - ] - - const result = convertToR1Format(input as Anthropic.Messages.MessageParam[]) - - expect(result).toHaveLength(2) - expect((result[1] as any).reasoning_content).toBe("Let me analyze step by step...") - }) - - it("should handle mixed tool_result and text in user message", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_789", - content: "Tool result", - }, - { - type: "text", - text: "Please continue", - }, - ], - }, - ] - - const result = convertToR1Format(input) - - // Should produce two messages: tool message first, then user message - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_789", - content: "Tool result", - }) - expect(result[1]).toEqual({ - role: "user", - content: "Please continue", - }) - }) - - it("should handle multiple tool calls in single assistant message", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_1", - name: "tool_a", - input: { param: "a" }, - }, - { - type: "tool_use", - id: "call_2", - name: "tool_b", - input: { param: "b" }, - }, - ], - }, - ] - - const result = convertToR1Format(input) - - expect(result).toHaveLength(1) - expect((result[0] as any).tool_calls).toHaveLength(2) - expect((result[0] as any).tool_calls[0].id).toBe("call_1") - expect((result[0] as any).tool_calls[1].id).toBe("call_2") - }) - - it("should not merge assistant messages that have tool calls", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_1", - name: "tool_a", - input: {}, - }, - ], - }, - { - role: "assistant", - content: "Follow up response", - }, - ] - - const result = convertToR1Format(input) - - // Should NOT merge because first message has tool calls - expect(result).toHaveLength(2) - expect((result[0] as any).tool_calls).toBeDefined() - expect(result[1]).toEqual({ - role: "assistant", - content: "Follow up response", - }) - }) - - describe("mergeToolResultText option for DeepSeek interleaved thinking", () => { - it("should merge text content into last tool message when mergeToolResultText is true", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "Tool result content", - }, - { - type: "text", - text: "\nSome context\n", - }, - ], - }, - ] - - const result = convertToR1Format(input, { mergeToolResultText: true }) - - // Should produce only one tool message with merged content - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "Tool result content\n\n\nSome context\n", - }) - }) - - it("should NOT merge text when mergeToolResultText is false (default behavior)", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "Tool result content", - }, - { - type: "text", - text: "Please continue", - }, - ], - }, - ] - - // Without option (default behavior) - const result = convertToR1Format(input) - - // Should produce two messages: tool message + user message - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "Tool result content", - }) - expect(result[1]).toEqual({ - role: "user", - content: "Please continue", - }) - }) - - it("should merge text into last tool message when multiple tool results exist", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_1", - content: "First result", - }, - { - type: "tool_result", - tool_use_id: "call_2", - content: "Second result", - }, - { - type: "text", - text: "Context", - }, - ], - }, - ] - - const result = convertToR1Format(input, { mergeToolResultText: true }) - - // Should produce two tool messages, with text merged into the last one - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_1", - content: "First result", - }) - expect(result[1]).toEqual({ - role: "tool", - tool_call_id: "call_2", - content: "Second result\n\nContext", - }) - }) - - it("should NOT merge when there are images (images need user message)", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_123", - content: "Tool result", - }, - { - type: "text", - text: "Check this image", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "imagedata", - }, - }, - ], - }, - ] - - const result = convertToR1Format(input, { mergeToolResultText: true }) - - // Should produce tool message + user message with image - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "Tool result", - }) - expect(result[1]).toMatchObject({ - role: "user", - content: expect.arrayContaining([ - { type: "text", text: "Check this image" }, - { type: "image_url", image_url: expect.any(Object) }, - ]), - }) - }) - - it("should NOT merge when there are no tool results (text-only should remain user message)", () => { - const input: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Just a regular message", - }, - ], - }, - ] - - const result = convertToR1Format(input, { mergeToolResultText: true }) - - // Should produce user message as normal - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ - role: "user", - content: "Just a regular message", - }) - }) - - it("should preserve reasoning_content on assistant messages in same conversation", () => { - const input = [ - { role: "user" as const, content: "Start" }, - { - role: "assistant" as const, - content: [ - { - type: "tool_use" as const, - id: "call_123", - name: "test_tool", - input: {}, - }, - ], - reasoning_content: "Let me think about this...", - }, - { - role: "user" as const, - content: [ - { - type: "tool_result" as const, - tool_use_id: "call_123", - content: "Result", - }, - { - type: "text" as const, - text: "Context", - }, - ], - }, - ] - - const result = convertToR1Format(input as Anthropic.Messages.MessageParam[], { - mergeToolResultText: true, - }) - - // Should have: user, assistant (with reasoning + tool_calls), tool - expect(result).toHaveLength(3) - expect(result[0]).toEqual({ role: "user", content: "Start" }) - expect((result[1] as any).reasoning_content).toBe("Let me think about this...") - expect((result[1] as any).tool_calls).toBeDefined() - // Tool message should have merged content - expect(result[2]).toEqual({ - role: "tool", - tool_call_id: "call_123", - content: "Result\n\nContext", - }) - // Most importantly: NO user message after tool message - expect(result.filter((m) => m.role === "user")).toHaveLength(1) - }) - }) - }) -}) diff --git a/src/api/transform/__tests__/sanitize-messages.spec.ts b/src/api/transform/__tests__/sanitize-messages.spec.ts new file mode 100644 index 00000000000..e5744ed0b64 --- /dev/null +++ b/src/api/transform/__tests__/sanitize-messages.spec.ts @@ -0,0 +1,172 @@ +import { sanitizeMessagesForProvider } from "../sanitize-messages" +import type { RooMessage } from "../../../core/task-persistence/rooMessage" + +describe("sanitizeMessagesForProvider", () => { + it("should preserve role and content on user messages", () => { + const messages: RooMessage[] = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "user", + content: [{ type: "text", text: "Hello" }], + }) + }) + + it("should preserve role, content, and providerOptions on assistant messages", () => { + const messages: RooMessage[] = [ + { + role: "assistant", + content: [{ type: "text", text: "Hi" }], + providerOptions: { openrouter: { reasoning_details: [{ type: "reasoning.text", text: "thinking" }] } }, + } as any, + ] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "assistant", + content: [{ type: "text", text: "Hi" }], + providerOptions: { openrouter: { reasoning_details: [{ type: "reasoning.text", text: "thinking" }] } }, + }) + }) + + it("should strip reasoning_details from messages", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "text", text: "Response" }], + reasoning_details: [{ type: "reasoning.encrypted", data: "encrypted_data" }], + }, + ] as any as RooMessage[] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(result[0]).not.toHaveProperty("reasoning_details") + expect(result[0]).toEqual({ + role: "assistant", + content: [{ type: "text", text: "Response" }], + }) + }) + + it("should strip reasoning_content from messages", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "text", text: "Response" }], + reasoning_content: "some reasoning content", + }, + ] as any as RooMessage[] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(result[0]).not.toHaveProperty("reasoning_content") + }) + + it("should strip metadata fields (ts, condenseId, etc.)", () => { + const messages = [ + { + role: "user", + content: "Hello", + ts: 1234567890, + condenseId: "cond-1", + condenseParent: "cond-0", + truncationId: "trunc-1", + truncationParent: "trunc-0", + isTruncationMarker: true, + isSummary: true, + }, + ] as any as RooMessage[] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "user", + content: "Hello", + }) + expect(result[0]).not.toHaveProperty("ts") + expect(result[0]).not.toHaveProperty("condenseId") + expect(result[0]).not.toHaveProperty("condenseParent") + expect(result[0]).not.toHaveProperty("truncationId") + expect(result[0]).not.toHaveProperty("truncationParent") + expect(result[0]).not.toHaveProperty("isTruncationMarker") + expect(result[0]).not.toHaveProperty("isSummary") + }) + + it("should strip any unknown extra fields", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "text", text: "Hi" }], + some_future_field: "should be stripped", + another_unknown: 42, + }, + ] as any as RooMessage[] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(result[0]).not.toHaveProperty("some_future_field") + expect(result[0]).not.toHaveProperty("another_unknown") + }) + + it("should not include providerOptions key when undefined", () => { + const messages: RooMessage[] = [{ role: "user", content: "Hello" }] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(Object.keys(result[0])).toEqual(["role", "content"]) + }) + + it("should handle mixed message types correctly", () => { + const messages = [ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + ts: 100, + }, + { + role: "assistant", + content: [{ type: "text", text: "Hi" }], + reasoning_details: [{ type: "thinking", thinking: "some reasoning" }], + reasoning_content: "some reasoning content", + ts: 200, + }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call_1", toolName: "test", result: "ok" }], + ts: 300, + }, + { + role: "user", + content: [{ type: "text", text: "Follow up" }], + ts: 400, + }, + ] as any as RooMessage[] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(4) + + for (const msg of result) { + expect(msg).not.toHaveProperty("reasoning_details") + expect(msg).not.toHaveProperty("reasoning_content") + expect(msg).not.toHaveProperty("ts") + } + + expect(result[0]).toEqual({ + role: "user", + content: [{ type: "text", text: "Hello" }], + }) + expect(result[1]).toEqual({ + role: "assistant", + content: [{ type: "text", text: "Hi" }], + }) + }) +}) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index 3f879566a09..13e727e3c07 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -3,299 +3,11 @@ * These utilities are designed to be reused across different AI SDK providers. */ -import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { tool as createTool, jsonSchema, type ModelMessage, type TextStreamPart } from "ai" import type { AssistantModelMessage } from "ai" import type { ApiStreamChunk, ApiStream } from "./stream" -/** - * Options for converting Anthropic messages to AI SDK format. - */ -export interface ConvertToAiSdkMessagesOptions { - /** - * Optional function to transform the converted messages. - * Useful for transformations like flattening message content for models that require string content. - */ - transform?: (messages: ModelMessage[]) => ModelMessage[] -} - -/** - * Convert Anthropic messages to AI SDK ModelMessage format. - * Handles text, images, tool uses, and tool results. - * - * @param messages - Array of Anthropic message parameters - * @param options - Optional conversion options including post-processing function - * @returns Array of AI SDK ModelMessage objects - */ -export function convertToAiSdkMessages( - messages: Anthropic.Messages.MessageParam[], - options?: ConvertToAiSdkMessagesOptions, -): ModelMessage[] { - const modelMessages: ModelMessage[] = [] - - // First pass: build a map of tool call IDs to tool names from assistant messages - const toolCallIdToName = new Map() - for (const message of messages) { - if (message.role === "assistant" && typeof message.content !== "string") { - for (const part of message.content) { - if (part.type === "tool_use") { - toolCallIdToName.set(part.id, part.name) - } - } - } - } - - for (const message of messages) { - if (typeof message.content === "string") { - modelMessages.push({ - role: message.role, - content: message.content, - }) - } else { - if (message.role === "user") { - const parts: Array< - { type: "text"; text: string } | { type: "image"; image: string; mimeType?: string } - > = [] - const toolResults: Array<{ - type: "tool-result" - toolCallId: string - toolName: string - output: { type: "text"; value: string } - }> = [] - - for (const part of message.content) { - if (part.type === "text") { - parts.push({ type: "text", text: part.text }) - } else if (part.type === "image") { - // Handle both base64 and URL source types - const source = part.source as { type: string; media_type?: string; data?: string; url?: string } - if (source.type === "base64" && source.media_type && source.data) { - parts.push({ - type: "image", - image: `data:${source.media_type};base64,${source.data}`, - mimeType: source.media_type, - }) - } else if (source.type === "url" && source.url) { - parts.push({ - type: "image", - image: source.url, - }) - } - } else if (part.type === "tool_result") { - // Convert tool results to string content - let content: string - if (typeof part.content === "string") { - content = part.content - } else { - content = - part.content - ?.map((c) => { - if (c.type === "text") return c.text - if (c.type === "image") return "(image)" - return "" - }) - .join("\n") ?? "" - } - // Look up the tool name from the tool call ID - const toolName = toolCallIdToName.get(part.tool_use_id) ?? "unknown_tool" - toolResults.push({ - type: "tool-result", - toolCallId: part.tool_use_id, - toolName, - output: { type: "text", value: content || "(empty)" }, - }) - } - } - - // AI SDK requires tool results in separate "tool" role messages - // UserContent only supports: string | Array - // ToolContent (for role: "tool") supports: Array - if (toolResults.length > 0) { - modelMessages.push({ - role: "tool", - content: toolResults, - } as ModelMessage) - } - - // Add user message with only text/image content (no tool results) - if (parts.length > 0) { - modelMessages.push({ - role: "user", - content: parts, - } as ModelMessage) - } - } else if (message.role === "assistant") { - const textParts: string[] = [] - const reasoningParts: string[] = [] - const reasoningContent = (() => { - const maybe = (message as unknown as { reasoning_content?: unknown }).reasoning_content - return typeof maybe === "string" && maybe.length > 0 ? maybe : undefined - })() - const toolCalls: Array<{ - type: "tool-call" - toolCallId: string - toolName: string - input: unknown - providerOptions?: Record> - }> = [] - - // Capture thinking signature for Anthropic-protocol providers (Bedrock, Anthropic). - // Task.ts stores thinking blocks as { type: "thinking", thinking: "...", signature: "..." }. - // The signature must be passed back via providerOptions on reasoning parts. - let thinkingSignature: string | undefined - - // Extract thoughtSignature from content blocks (Gemini 3 thought signature round-tripping). - // Task.ts stores these as { type: "thoughtSignature", thoughtSignature: "..." } blocks. - let thoughtSignature: string | undefined - for (const part of message.content) { - const partAny = part as unknown as { type?: string; thoughtSignature?: string } - if (partAny.type === "thoughtSignature" && partAny.thoughtSignature) { - thoughtSignature = partAny.thoughtSignature - } - } - - for (const part of message.content) { - if (part.type === "text") { - textParts.push(part.text) - continue - } - - if (part.type === "tool_use") { - const toolCall: (typeof toolCalls)[number] = { - type: "tool-call", - toolCallId: part.id, - toolName: part.name, - input: part.input, - } - - // Attach thoughtSignature as providerOptions on tool-call parts. - // The AI SDK's @ai-sdk/google provider reads providerOptions.google.thoughtSignature - // and attaches it to the Gemini functionCall part. - // Per Gemini 3 rules: only the FIRST functionCall in a parallel batch gets the signature. - if (thoughtSignature && toolCalls.length === 0) { - toolCall.providerOptions = { - google: { thoughtSignature }, - vertex: { thoughtSignature }, - } - } - - toolCalls.push(toolCall) - continue - } - - // Some providers (DeepSeek, Gemini, etc.) require reasoning to be round-tripped. - // Task stores reasoning as a content block (type: "reasoning") and Anthropic extended - // thinking as (type: "thinking"). Convert both to AI SDK's reasoning part. - if ((part as unknown as { type?: string }).type === "reasoning") { - // If message-level reasoning_content is present, treat it as canonical and - // avoid mixing it with content-block reasoning (which can cause duplication). - if (reasoningContent) continue - - const text = (part as unknown as { text?: string }).text - if (typeof text === "string" && text.length > 0) { - reasoningParts.push(text) - } - continue - } - - if ((part as unknown as { type?: string }).type === "thinking") { - if (reasoningContent) continue - - const thinkingPart = part as unknown as { thinking?: string; signature?: string } - if (typeof thinkingPart.thinking === "string" && thinkingPart.thinking.length > 0) { - reasoningParts.push(thinkingPart.thinking) - } - // Capture the signature for round-tripping (Anthropic/Bedrock thinking). - if (thinkingPart.signature) { - thinkingSignature = thinkingPart.signature - } - continue - } - } - - const content: Array< - | { type: "reasoning"; text: string; providerOptions?: Record> } - | { type: "text"; text: string } - | { - type: "tool-call" - toolCallId: string - toolName: string - input: unknown - providerOptions?: Record> - } - > = [] - - if (reasoningContent) { - content.push({ type: "reasoning", text: reasoningContent }) - } else if (reasoningParts.length > 0) { - const reasoningPart: (typeof content)[number] = { - type: "reasoning", - text: reasoningParts.join(""), - } - // Attach thinking signature for Anthropic/Bedrock round-tripping. - // The AI SDK's @ai-sdk/amazon-bedrock reads providerOptions.bedrock.signature - // and attaches it to reasoningContent.reasoningText.signature in the Bedrock request. - if (thinkingSignature) { - reasoningPart.providerOptions = { - bedrock: { signature: thinkingSignature }, - anthropic: { signature: thinkingSignature }, - } - } - content.push(reasoningPart) - } - - if (textParts.length > 0) { - content.push({ type: "text", text: textParts.join("\n") }) - } - content.push(...toolCalls) - - // Carry reasoning_details through to providerOptions for OpenRouter round-tripping - // (used by Gemini 3, xAI, etc. for encrypted reasoning chain continuity). - // The @openrouter/ai-sdk-provider reads message-level providerOptions.openrouter.reasoning_details - // and validates them against ReasoningDetailUnionSchema (a strict Zod union). - // Invalid entries (e.g. type "reasoning.encrypted" without a `data` field) must be - // filtered out here, otherwise the entire safeParse fails and NO reasoning_details - // are included in the outgoing request. - const rawReasoningDetails = (message as unknown as { reasoning_details?: Record[] }) - .reasoning_details - const validReasoningDetails = rawReasoningDetails?.filter((detail) => { - switch (detail.type) { - case "reasoning.encrypted": - return typeof detail.data === "string" && detail.data.length > 0 - case "reasoning.text": - return typeof detail.text === "string" - case "reasoning.summary": - return typeof detail.summary === "string" - default: - return false - } - }) - - const assistantMessage: Record = { - role: "assistant", - content: content.length > 0 ? content : [{ type: "text", text: "" }], - } - - if (validReasoningDetails && validReasoningDetails.length > 0) { - assistantMessage.providerOptions = { - openrouter: { reasoning_details: validReasoningDetails }, - } - } - - modelMessages.push(assistantMessage as ModelMessage) - } - } - } - - // Apply transform if provided - if (options?.transform) { - return options.transform(modelMessages) - } - - return modelMessages -} - /** * Options for flattening AI SDK messages. */ @@ -469,7 +181,7 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator + + // OpenRouter wraps the real provider error in error.metadata.raw (a JSON string). + // Check this BEFORE error.message because error.message is often just + // "Provider returned error" which is not useful. + if (typeof errorObj.metadata === "object" && errorObj.metadata !== null) { + const metadata = errorObj.metadata as Record + if (typeof metadata.raw === "string" && metadata.raw) { + try { + const rawParsed: unknown = JSON.parse(metadata.raw) + if (typeof rawParsed === "object" && rawParsed !== null) { + const rawObj = rawParsed as Record + if (typeof rawObj.message === "string" && rawObj.message) { + const providerName = + typeof metadata.provider_name === "string" ? metadata.provider_name : undefined + const prefix = providerName ? `[${providerName}] ` : "" + return `${prefix}${rawObj.message}` + } + // Anthropic format: {"type":"error","error":{"type":"invalid_request_error","message":"..."}} + if (typeof rawObj.error === "object" && rawObj.error !== null) { + const innerError = rawObj.error as Record + if (typeof innerError.message === "string" && innerError.message) { + const providerName = + typeof metadata.provider_name === "string" + ? metadata.provider_name + : undefined + const prefix = providerName ? `[${providerName}] ` : "" + const typePrefix = + typeof innerError.type === "string" ? `[${innerError.type}] ` : "" + return `${prefix}${typePrefix}${innerError.message}` + } + } + } + } catch { + // raw is not valid JSON — fall through to other patterns + } + } + } + if (typeof errorObj.message === "string" && errorObj.message) { if (typeof errorObj.code === "string" && errorObj.code) { return `[${errorObj.code}] ${errorObj.message}` @@ -646,6 +396,11 @@ export function extractMessageFromResponseBody(responseBody: string): string | u if (typeof errorObj.code === "number") { return `[${errorObj.code}] ${errorObj.message}` } + // Anthropic format: error.type instead of error.code + // e.g. {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}} + if (typeof errorObj.type === "string" && errorObj.type) { + return `[${errorObj.type}] ${errorObj.message}` + } return errorObj.message } } @@ -667,6 +422,48 @@ export function extractMessageFromResponseBody(responseBody: string): string | u } } +/** + * Recursively traverses an error chain to find the deepest APICallError + * with a non-empty responseBody. Checks .cause, .lastError, and .errors[]. + */ +function findDeepestApiCallError(error: unknown, maxDepth = 10): Record | undefined { + if (maxDepth <= 0 || typeof error !== "object" || error === null) { + return undefined + } + + const obj = error as Record + + // Recurse children FIRST so we find the DEEPEST match + // Check .cause + const fromCause = findDeepestApiCallError(obj.cause, maxDepth - 1) + if (fromCause) { + return fromCause + } + + // Check .lastError + const fromLastError = findDeepestApiCallError(obj.lastError, maxDepth - 1) + if (fromLastError) { + return fromLastError + } + + // Check .errors[] array + if (Array.isArray(obj.errors)) { + for (const element of obj.errors) { + const fromElement = findDeepestApiCallError(element, maxDepth - 1) + if (fromElement) { + return fromElement + } + } + } + + // Then check self + if (obj.name === "AI_APICallError" && typeof obj.responseBody === "string" && obj.responseBody.length > 0) { + return obj + } + + return undefined +} + /** * Extract a user-friendly error message from AI SDK errors. * The AI SDK wraps errors in types like AI_RetryError and AI_APICallError @@ -686,6 +483,22 @@ export function extractAiSdkErrorMessage(error: unknown): string { const errorObj = error as Record + // First, try to find the deepest APICallError with a responseBody in the error chain. + // This handles arbitrarily nested chains like NoOutput → Retry → APICallError. + const deepestApiError = findDeepestApiCallError(error) + if (deepestApiError) { + const responseBody = deepestApiError.responseBody as string + const extracted = extractMessageFromResponseBody(responseBody) + const statusCode = getStatusCode(deepestApiError) + if (extracted) { + return statusCode ? `API Error (${statusCode}): ${extracted}` : `API Error: ${extracted}` + } + // Fall back to raw responseBody + return statusCode + ? `API Error (${statusCode}): ${responseBody}` + : `API Error: ${responseBody}` + } + // AI_RetryError has a lastError property with the actual error if (errorObj.name === "AI_RetryError") { const retryCount = Array.isArray(errorObj.errors) ? errorObj.errors.length : 0 @@ -725,21 +538,31 @@ export function extractAiSdkErrorMessage(error: unknown): string { // AI_APICallError has message, optional status, and responseBody if (errorObj.name === "AI_APICallError") { const statusCode = getStatusCode(error) + const hasResponseBody = "responseBody" in errorObj && typeof errorObj.responseBody === "string" // Try to extract a richer message from responseBody let message: string | undefined - if ("responseBody" in errorObj && typeof errorObj.responseBody === "string") { - message = extractMessageFromResponseBody(errorObj.responseBody) + if (hasResponseBody) { + message = extractMessageFromResponseBody(errorObj.responseBody as string) } if (!message) { - message = typeof errorObj.message === "string" ? errorObj.message : "API call failed" + // Do NOT fall back to error.message — it's often just HTTP status text + // like "Bad Request" which swallows the actual error information. + if (hasResponseBody && (errorObj.responseBody as string).length > 0) { + // Include the raw response body so nothing is silently lost + message = errorObj.responseBody as string + } else if (hasResponseBody) { + message = "Empty response body" + } else { + message = "No response body available" + } } if (statusCode) { return `API Error (${statusCode}): ${message}` } - return message + return `API Error: ${message}` } // AI_NoOutputGeneratedError wraps a cause that may be an APICallError diff --git a/src/api/transform/anthropic-filter.ts b/src/api/transform/anthropic-filter.ts deleted file mode 100644 index 2bfc6dccfd0..00000000000 --- a/src/api/transform/anthropic-filter.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" - -/** - * Set of content block types that are valid for Anthropic API. - * Only these types will be passed through to the API. - * See: https://docs.anthropic.com/en/api/messages - */ -export const VALID_ANTHROPIC_BLOCK_TYPES = new Set([ - "text", - "image", - "tool_use", - "tool_result", - "thinking", - "redacted_thinking", - "document", -]) - -/** - * Filters out non-Anthropic content blocks from messages before sending to Anthropic/Vertex API. - * Uses an allowlist approach - only blocks with types in VALID_ANTHROPIC_BLOCK_TYPES are kept. - * This automatically filters out: - * - Internal "reasoning" blocks (Roo Code's internal representation) - * - Gemini's "thoughtSignature" blocks (encrypted reasoning continuity tokens) - * - Any other unknown block types - */ -export function filterNonAnthropicBlocks( - messages: Anthropic.Messages.MessageParam[], -): Anthropic.Messages.MessageParam[] { - return messages - .map((message) => { - if (typeof message.content === "string") { - return message - } - - const filteredContent = message.content.filter((block) => { - const blockType = (block as { type: string }).type - // Only keep block types that Anthropic recognizes - return VALID_ANTHROPIC_BLOCK_TYPES.has(blockType) - }) - - // If all content was filtered out, return undefined to filter the message later - if (filteredContent.length === 0) { - return undefined - } - - return { - ...message, - content: filteredContent, - } - }) - .filter((message): message is Anthropic.Messages.MessageParam => message !== undefined) -} diff --git a/src/api/transform/cache-breakpoints.ts b/src/api/transform/cache-breakpoints.ts index 9bb912f3f59..8e9a713093b 100644 --- a/src/api/transform/cache-breakpoints.ts +++ b/src/api/transform/cache-breakpoints.ts @@ -12,7 +12,7 @@ export const UNIVERSAL_CACHE_OPTIONS: Record> = * Optional targeting configuration for cache breakpoint placement. */ export interface CacheBreakpointTargeting { - /** Maximum number of message breakpoints to place. Default: 2 */ + /** Maximum number of message breakpoints to place. Default: 1 */ maxBreakpoints?: number /** Whether to add an anchor breakpoint at ~1/3 through the conversation. Default: false */ useAnchor?: boolean @@ -23,19 +23,19 @@ export interface CacheBreakpointTargeting { /** * Apply cache breakpoints to AI SDK messages with ALL provider namespaces. * - * 4-breakpoint strategy: + * 3-breakpoint strategy: * 1. System prompt — passed as first message in messages[] with providerOptions * 2. Tool definitions — handled externally via `toolProviderOptions` in `streamText()` - * 3-4. Last 2 non-assistant messages — this function handles these + * 3. Last non-assistant message — this function handles this * * @param messages - The AI SDK message array (mutated in place) - * @param targeting - Optional targeting options (defaults: 2 breakpoints, no anchor) + * @param targeting - Optional targeting options (defaults: 1 breakpoint, no anchor) */ export function applyCacheBreakpoints( messages: { role: string; providerOptions?: Record> }[], targeting: CacheBreakpointTargeting = {}, ): void { - const { maxBreakpoints = 2, useAnchor = false, anchorThreshold = 5 } = targeting + const { maxBreakpoints = 1, useAnchor = false, anchorThreshold = 5 } = targeting // 1. Collect non-assistant message indices (user | tool roles) const nonAssistantIndices: number[] = [] diff --git a/src/api/transform/mistral-format.ts b/src/api/transform/mistral-format.ts deleted file mode 100644 index d32f84d6e06..00000000000 --- a/src/api/transform/mistral-format.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage" -import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage" -import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage" -import { UserMessage } from "@mistralai/mistralai/models/components/usermessage" - -/** - * Normalizes a tool call ID to be compatible with Mistral's strict ID requirements. - * Mistral requires tool call IDs to be: - * - Only alphanumeric characters (a-z, A-Z, 0-9) - * - Exactly 9 characters in length - * - * This function extracts alphanumeric characters from the original ID and - * pads/truncates to exactly 9 characters, ensuring deterministic output. - * - * @param id - The original tool call ID (e.g., "call_5019f900a247472bacde0b82" or "toolu_123") - * @returns A normalized 9-character alphanumeric ID compatible with Mistral - */ -export function normalizeMistralToolCallId(id: string): string { - // Extract only alphanumeric characters - const alphanumeric = id.replace(/[^a-zA-Z0-9]/g, "") - - // Take first 9 characters, or pad with zeros if shorter - if (alphanumeric.length >= 9) { - return alphanumeric.slice(0, 9) - } - - // Pad with zeros to reach 9 characters - return alphanumeric.padEnd(9, "0") -} - -export type MistralMessage = - | (SystemMessage & { role: "system" }) - | (UserMessage & { role: "user" }) - | (AssistantMessage & { role: "assistant" }) - | (ToolMessage & { role: "tool" }) - -// Type for Mistral tool calls in assistant messages -type MistralToolCallMessage = { - id: string - type: "function" - function: { - name: string - arguments: string - } -} - -export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): MistralMessage[] { - const mistralMessages: MistralMessage[] = [] - - for (const anthropicMessage of anthropicMessages) { - if (typeof anthropicMessage.content === "string") { - mistralMessages.push({ - role: anthropicMessage.role, - content: anthropicMessage.content, - }) - } else { - if (anthropicMessage.role === "user") { - const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ - nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] - toolMessages: Anthropic.ToolResultBlockParam[] - }>( - (acc, part) => { - if (part.type === "tool_result") { - acc.toolMessages.push(part) - } else if (part.type === "text" || part.type === "image") { - acc.nonToolMessages.push(part) - } // user cannot send tool_use messages - return acc - }, - { nonToolMessages: [], toolMessages: [] }, - ) - - // If there are tool results, handle them - // Mistral's message order is strict: user → assistant → tool → assistant - // We CANNOT put user messages after tool messages - if (toolMessages.length > 0) { - // Convert tool_result blocks to Mistral tool messages - for (const toolResult of toolMessages) { - let resultContent: string - if (typeof toolResult.content === "string") { - resultContent = toolResult.content - } else if (Array.isArray(toolResult.content)) { - // Extract text from content blocks - resultContent = toolResult.content - .filter((block): block is Anthropic.TextBlockParam => block.type === "text") - .map((block) => block.text) - .join("\n") - } else { - resultContent = "" - } - - mistralMessages.push({ - role: "tool", - toolCallId: normalizeMistralToolCallId(toolResult.tool_use_id), - content: resultContent, - } as ToolMessage & { role: "tool" }) - } - // Note: We intentionally skip any non-tool user content when there are tool results - // because Mistral doesn't allow user messages after tool messages - } else if (nonToolMessages.length > 0) { - // Only add user content if there are NO tool results - mistralMessages.push({ - role: "user", - content: nonToolMessages.map((part) => { - if (part.type === "image") { - return { - type: "image_url", - imageUrl: { - url: `data:${part.source.media_type};base64,${part.source.data}`, - }, - } - } - return { type: "text", text: part.text } - }), - }) - } - } else if (anthropicMessage.role === "assistant") { - const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ - nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] - toolMessages: Anthropic.ToolUseBlockParam[] - }>( - (acc, part) => { - if (part.type === "tool_use") { - acc.toolMessages.push(part) - } else if (part.type === "text" || part.type === "image") { - acc.nonToolMessages.push(part) - } // assistant cannot send tool_result messages - return acc - }, - { nonToolMessages: [], toolMessages: [] }, - ) - - let content: string | undefined - if (nonToolMessages.length > 0) { - content = nonToolMessages - .map((part) => { - if (part.type === "image") { - return "" // impossible as the assistant cannot send images - } - return part.text - }) - .join("\n") - } - - // Convert tool_use blocks to Mistral toolCalls format - let toolCalls: MistralToolCallMessage[] | undefined - if (toolMessages.length > 0) { - toolCalls = toolMessages.map((toolUse) => ({ - id: normalizeMistralToolCallId(toolUse.id), - type: "function" as const, - function: { - name: toolUse.name, - arguments: - typeof toolUse.input === "string" ? toolUse.input : JSON.stringify(toolUse.input), - }, - })) - } - - // Mistral requires either content or toolCalls to be non-empty - // If we have toolCalls but no content, we need to handle this properly - const assistantMessage: AssistantMessage & { role: "assistant" } = { - role: "assistant", - content, - } - - if (toolCalls && toolCalls.length > 0) { - ;( - assistantMessage as AssistantMessage & { - role: "assistant" - toolCalls?: MistralToolCallMessage[] - } - ).toolCalls = toolCalls - } - - mistralMessages.push(assistantMessage) - } - } - } - - return mistralMessages -} diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts deleted file mode 100644 index 4a49d30e465..00000000000 --- a/src/api/transform/openai-format.ts +++ /dev/null @@ -1,555 +0,0 @@ -import OpenAI from "openai" -import { - type RooMessage, - type RooRoleMessage, - type AnyToolCallBlock, - type AnyToolResultBlock, - isRooRoleMessage, - isAnyToolCallBlock, - isAnyToolResultBlock, - getToolCallId, - getToolCallName, - getToolCallInput, - getToolResultCallId, - getToolResultContent, -} from "../../core/task-persistence/rooMessage" - -/** - * Type for OpenRouter's reasoning detail elements. - * @see https://openrouter.ai/docs/use-cases/reasoning-tokens#streaming-response - */ -export type ReasoningDetail = { - /** - * Type of reasoning detail. - * @see https://openrouter.ai/docs/use-cases/reasoning-tokens#reasoning-detail-types - */ - type: string // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text" - text?: string - summary?: string - data?: string // Encrypted reasoning data - signature?: string | null - id?: string | null // Unique identifier for the reasoning detail - /** - * Format of the reasoning detail: - * - "unknown" - Format is not specified - * - "openai-responses-v1" - OpenAI responses format version 1 - * - "anthropic-claude-v1" - Anthropic Claude format version 1 (default) - * - "google-gemini-v1" - Google Gemini format version 1 - * - "xai-responses-v1" - xAI responses format version 1 - */ - format?: string - index?: number // Sequential index of the reasoning detail -} - -/** - * Consolidates reasoning_details by grouping by index and type. - * - Filters out corrupted encrypted blocks (missing `data` field) - * - For text blocks: concatenates text, keeps last signature/id/format - * - For encrypted blocks: keeps only the last one per index - * - * @param reasoningDetails - Array of reasoning detail objects - * @returns Consolidated array of reasoning details - * @see https://github.com/cline/cline/issues/8214 - */ -export function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): ReasoningDetail[] { - if (!reasoningDetails || reasoningDetails.length === 0) { - return [] - } - - // Group by index - const groupedByIndex = new Map() - - for (const detail of reasoningDetails) { - // Drop corrupted encrypted reasoning blocks that would otherwise trigger: - // "Invalid input: expected string, received undefined" for reasoning_details.*.data - // See: https://github.com/cline/cline/issues/8214 - if (detail.type === "reasoning.encrypted" && !detail.data) { - continue - } - - const index = detail.index ?? 0 - if (!groupedByIndex.has(index)) { - groupedByIndex.set(index, []) - } - groupedByIndex.get(index)!.push(detail) - } - - // Consolidate each group - const consolidated: ReasoningDetail[] = [] - - for (const [index, details] of groupedByIndex.entries()) { - // Concatenate all text parts - let concatenatedText = "" - let concatenatedSummary = "" - let signature: string | undefined - let id: string | undefined - let format = "unknown" - let type = "reasoning.text" - - for (const detail of details) { - if (detail.text) { - concatenatedText += detail.text - } - if (detail.summary) { - concatenatedSummary += detail.summary - } - // Keep the signature from the last item that has one - if (detail.signature) { - signature = detail.signature - } - // Keep the id from the last item that has one - if (detail.id) { - id = detail.id - } - // Keep format and type from any item (they should all be the same) - if (detail.format) { - format = detail.format - } - if (detail.type) { - type = detail.type - } - } - - // Create consolidated entry for text - if (concatenatedText) { - const consolidatedEntry: ReasoningDetail = { - type: type, - text: concatenatedText, - signature: signature ?? undefined, - id: id ?? undefined, - format: format, - index: index, - } - consolidated.push(consolidatedEntry) - } - - // Create consolidated entry for summary (used by some providers) - if (concatenatedSummary && !concatenatedText) { - const consolidatedEntry: ReasoningDetail = { - type: type, - summary: concatenatedSummary, - signature: signature ?? undefined, - id: id ?? undefined, - format: format, - index: index, - } - consolidated.push(consolidatedEntry) - } - - // For encrypted chunks (data), only keep the last one - let lastDataEntry: ReasoningDetail | undefined - for (const detail of details) { - if (detail.data) { - lastDataEntry = { - type: detail.type, - data: detail.data, - signature: detail.signature ?? undefined, - id: detail.id ?? undefined, - format: detail.format, - index: index, - } - } - } - if (lastDataEntry) { - consolidated.push(lastDataEntry) - } - } - - return consolidated -} - -/** - * A RooRoleMessage that may carry `reasoning_details` from OpenAI/OpenRouter providers. - * Used to type-narrow instead of `as any` when accessing reasoning metadata. - */ -type MessageWithReasoningDetails = RooRoleMessage & { reasoning_details?: ReasoningDetail[] } - -/** - * Sanitizes OpenAI messages for Gemini models by filtering reasoning_details - * to only include entries that match the tool call IDs. - * - * Gemini models require thought signatures for tool calls. When switching providers - * mid-conversation, historical tool calls may not include Gemini reasoning details, - * which can poison the next request. This function: - * 1. Filters reasoning_details to only include entries matching tool call IDs - * 2. Drops tool_calls that lack any matching reasoning_details - * 3. Removes corresponding tool result messages for dropped tool calls - * - * @param messages - Array of OpenAI chat completion messages - * @param modelId - The model ID to check if sanitization is needed - * @returns Sanitized array of messages (unchanged if not a Gemini model) - * @see https://github.com/cline/cline/issues/8214 - */ -export function sanitizeGeminiMessages( - messages: OpenAI.Chat.ChatCompletionMessageParam[], - modelId: string, -): OpenAI.Chat.ChatCompletionMessageParam[] { - // Only sanitize for Gemini models - if (!modelId.includes("gemini")) { - return messages - } - - const droppedToolCallIds = new Set() - const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = [] - - for (const msg of messages) { - if (msg.role === "assistant") { - const anyMsg = msg as any - const toolCalls = anyMsg.tool_calls as OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined - const reasoningDetails = anyMsg.reasoning_details as ReasoningDetail[] | undefined - - if (Array.isArray(toolCalls) && toolCalls.length > 0) { - const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0 - - if (!hasReasoningDetails) { - // No reasoning_details at all - drop all tool calls - for (const tc of toolCalls) { - if (tc?.id) { - droppedToolCallIds.add(tc.id) - } - } - // Keep any textual content, but drop the tool_calls themselves - if (anyMsg.content) { - sanitized.push({ role: "assistant", content: anyMsg.content } as any) - } - continue - } - - // Filter reasoning_details to only include entries matching tool call IDs - // This prevents mismatched reasoning details from poisoning the request - const validToolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = [] - const validReasoningDetails: ReasoningDetail[] = [] - - for (const tc of toolCalls) { - // Check if there's a reasoning_detail with matching id - const matchingDetails = reasoningDetails.filter((d) => d.id === tc.id) - - if (matchingDetails.length > 0) { - validToolCalls.push(tc) - validReasoningDetails.push(...matchingDetails) - } else { - // No matching reasoning_detail - drop this tool call - if (tc?.id) { - droppedToolCallIds.add(tc.id) - } - } - } - - // Also include reasoning_details that don't have an id (legacy format) - const detailsWithoutId = reasoningDetails.filter((d) => !d.id) - validReasoningDetails.push(...detailsWithoutId) - - // Build the sanitized message - const sanitizedMsg: any = { - role: "assistant", - content: anyMsg.content ?? "", - } - - if (validReasoningDetails.length > 0) { - sanitizedMsg.reasoning_details = consolidateReasoningDetails(validReasoningDetails) - } - - if (validToolCalls.length > 0) { - sanitizedMsg.tool_calls = validToolCalls - } - - sanitized.push(sanitizedMsg) - continue - } - } - - if (msg.role === "tool") { - const anyMsg = msg as any - if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) { - // Skip tool result for dropped tool call - continue - } - } - - sanitized.push(msg) - } - - return sanitized -} - -/** - * Options for converting messages to OpenAI format. - */ -export interface ConvertToOpenAiMessagesOptions { - /** - * Optional function to normalize tool call IDs for providers with strict ID requirements. - * When provided, this function will be applied to all tool call IDs. - * This allows callers to declare provider-specific ID format requirements. - */ - normalizeToolCallId?: (id: string) => string - /** - * If true, merge text content after tool results into the last tool message - * instead of creating a separate user message. This is critical for providers - * with reasoning/thinking models (like DeepSeek-reasoner, GLM-4.7, etc.) where - * a user message after tool results causes the model to drop all previous - * reasoning_content. Default is false for backward compatibility. - */ - mergeToolResultText?: boolean -} - -/** - * Converts RooMessage[] to OpenAI chat completion messages. - * Handles both AI SDK format (tool-call/tool-result) and legacy Anthropic format - * (tool_use/tool_result) for backward compatibility with persisted data. - */ -export function convertToOpenAiMessages( - messages: RooMessage[], - options?: ConvertToOpenAiMessagesOptions, -): OpenAI.Chat.ChatCompletionMessageParam[] { - const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [] - - const mapReasoningDetails = (details: unknown): any[] | undefined => { - if (!Array.isArray(details)) { - return undefined - } - - return details.map((detail: any) => { - // Strip `id` from openai-responses-v1 blocks because OpenAI's Responses API - // requires `store: true` to persist reasoning blocks. Since we manage - // conversation state client-side, we don't use `store: true`, and sending - // back the `id` field causes a 404 error. - if (detail?.format === "openai-responses-v1" && detail?.id) { - const { id, ...rest } = detail - return rest - } - return detail - }) - } - - // Use provided normalization function or identity function - const normalizeId = options?.normalizeToolCallId ?? ((id: string) => id) - - /** Get image data URL from either AI SDK or legacy format. */ - const getImageDataUrl = (part: { - type: string - image?: string - mediaType?: string - source?: { media_type?: string; data?: string } - }): string => { - // AI SDK format: - // - raw base64 + mediaType: construct data URL - // - existing data/http(s) URL in image: pass through unchanged - if (part.image) { - const image = part.image.trim() - if (image.startsWith("data:") || /^https?:\/\//i.test(image)) { - return image - } - if (part.mediaType) { - return `data:${part.mediaType};base64,${image}` - } - } - // Legacy Anthropic format: { type: "image", source: { media_type, data } } - if (part.source?.media_type && part.source?.data) { - return `data:${part.source.media_type};base64,${part.source.data}` - } - return "" - } - - for (const message of messages) { - // Skip RooReasoningMessage (no role property) - if (!("role" in message)) { - continue - } - - if (typeof message.content === "string") { - // String content: simple text message - const messageWithDetails = message as MessageWithReasoningDetails - const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = { - role: message.role as "user" | "assistant", - content: message.content, - } - - if (message.role === "assistant") { - const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) - if (mapped) { - baseMessage.reasoning_details = mapped - } - } - - openAiMessages.push(baseMessage) - } else if (message.role === "tool") { - // RooToolMessage: each tool-result → OpenAI tool message - if (Array.isArray(message.content)) { - for (const part of message.content) { - if (isAnyToolResultBlock(part as { type: string })) { - const resultBlock = part as AnyToolResultBlock - const rawContent = getToolResultContent(resultBlock) - let content: string - if (typeof rawContent === "string") { - content = rawContent - } else if (rawContent && typeof rawContent === "object" && "value" in rawContent) { - content = String((rawContent as { value: unknown }).value) - } else { - content = rawContent ? JSON.stringify(rawContent) : "" - } - openAiMessages.push({ - role: "tool", - tool_call_id: normalizeId(getToolResultCallId(resultBlock)), - content: content || "(empty)", - }) - } - } - } - } else if (message.role === "user") { - // User message: separate tool results from text/image content - // Persisted data may contain legacy Anthropic tool_result blocks alongside AI SDK parts, - // so we widen the element type to handle all possible block shapes. - const contentArray: Array<{ type: string }> = Array.isArray(message.content) - ? (message.content as unknown as Array<{ type: string }>) - : [] - - const nonToolMessages: Array<{ type: string; text?: unknown; [k: string]: unknown }> = [] - const toolMessages: AnyToolResultBlock[] = [] - - for (const part of contentArray) { - if (isAnyToolResultBlock(part)) { - toolMessages.push(part) - } else if (part.type === "text" || part.type === "image") { - nonToolMessages.push(part as { type: string; text?: unknown; [k: string]: unknown }) - } - } - - // Process tool result messages FIRST - toolMessages.forEach((toolMessage) => { - const rawContent = getToolResultContent(toolMessage) - let content: string - - if (typeof rawContent === "string") { - content = rawContent - } else if (Array.isArray(rawContent)) { - content = - rawContent - .map((part: { type: string; text?: string }) => { - if (part.type === "image") { - return "(see following user message for image)" - } - return part.text - }) - .join("\n") ?? "" - } else if (rawContent && typeof rawContent === "object" && "value" in rawContent) { - content = String((rawContent as { value: unknown }).value) - } else { - content = rawContent ? JSON.stringify(rawContent) : "" - } - - openAiMessages.push({ - role: "tool", - tool_call_id: normalizeId(getToolResultCallId(toolMessage)), - content: content || "(empty)", - }) - }) - - // Process non-tool messages - // Filter out empty text blocks to prevent "must include at least one parts field" error - const filteredNonToolMessages = nonToolMessages.filter( - (part) => part.type === "image" || (part.type === "text" && part.text), - ) - - if (filteredNonToolMessages.length > 0) { - const hasOnlyTextContent = filteredNonToolMessages.every((part) => part.type === "text") - const hasToolMessages = toolMessages.length > 0 - const shouldMergeIntoToolMessage = options?.mergeToolResultText && hasToolMessages && hasOnlyTextContent - - if (shouldMergeIntoToolMessage) { - const lastToolMessage = openAiMessages[ - openAiMessages.length - 1 - ] as OpenAI.Chat.ChatCompletionToolMessageParam - if (lastToolMessage?.role === "tool") { - const additionalText = filteredNonToolMessages.map((part) => String(part.text ?? "")).join("\n") - lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` - } - } else { - openAiMessages.push({ - role: "user", - content: filteredNonToolMessages.map((part) => { - if (part.type === "image") { - return { - type: "image_url", - image_url: { - url: getImageDataUrl( - part as { - type: string - image?: string - mediaType?: string - source?: { media_type?: string; data?: string } - }, - ), - }, - } - } - return { type: "text", text: String(part.text ?? "") } - }), - }) - } - } - } else if (message.role === "assistant") { - // Assistant message: separate tool calls from text content - // Persisted data may contain legacy Anthropic tool_use blocks, so we widen - // the element type to accommodate both AI SDK and legacy block shapes. - const contentArray: Array<{ type: string }> = Array.isArray(message.content) - ? (message.content as unknown as Array<{ type: string }>) - : [] - - const nonToolMessages: Array<{ type: string; text?: unknown }> = [] - const toolCallMessages: AnyToolCallBlock[] = [] - - for (const part of contentArray) { - if (isAnyToolCallBlock(part)) { - toolCallMessages.push(part) - } else if (part.type === "text" || part.type === "image") { - nonToolMessages.push(part as { type: string; text?: unknown }) - } - } - - // Process non-tool messages - let content: string | undefined - if (nonToolMessages.length > 0) { - content = nonToolMessages - .map((part) => { - if (part.type === "image") { - return "" - } - return part.text as string - }) - .join("\n") - } - - // Process tool call messages - let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolCallMessages.map((tc) => ({ - id: normalizeId(getToolCallId(tc)), - type: "function" as const, - function: { - name: getToolCallName(tc), - arguments: JSON.stringify(getToolCallInput(tc)), - }, - })) - - const messageWithDetails = message as MessageWithReasoningDetails - - const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & { - reasoning_details?: any[] - } = { - role: "assistant", - content: content ?? "", - } - - const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) - if (mapped) { - baseMessage.reasoning_details = mapped - } - - if (tool_calls.length > 0) { - baseMessage.tool_calls = tool_calls - } - - openAiMessages.push(baseMessage) - } - } - - return openAiMessages -} diff --git a/src/api/transform/r1-format.ts b/src/api/transform/r1-format.ts deleted file mode 100644 index 8231e24f76f..00000000000 --- a/src/api/transform/r1-format.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -type ContentPartText = OpenAI.Chat.ChatCompletionContentPartText -type ContentPartImage = OpenAI.Chat.ChatCompletionContentPartImage -type UserMessage = OpenAI.Chat.ChatCompletionUserMessageParam -type AssistantMessage = OpenAI.Chat.ChatCompletionAssistantMessageParam -type ToolMessage = OpenAI.Chat.ChatCompletionToolMessageParam -type Message = OpenAI.Chat.ChatCompletionMessageParam -type AnthropicMessage = Anthropic.Messages.MessageParam - -/** - * Extended assistant message type to support DeepSeek's interleaved thinking. - * DeepSeek's API returns reasoning_content alongside content and tool_calls, - * and requires it to be passed back in subsequent requests within the same turn. - */ -export type DeepSeekAssistantMessage = AssistantMessage & { - reasoning_content?: string -} - -/** - * Converts Anthropic messages to OpenAI format while merging consecutive messages with the same role. - * This is required for DeepSeek Reasoner which does not support successive messages with the same role. - * - * For DeepSeek's interleaved thinking mode: - * - Preserves reasoning_content on assistant messages for tool call continuations - * - Tool result messages are converted to OpenAI tool messages - * - reasoning_content from previous assistant messages is preserved until a new user turn - * - Text content after tool_results (like environment_details) is merged into the last tool message - * to avoid creating user messages that would cause reasoning_content to be dropped - * - * @param messages Array of Anthropic messages - * @param options Optional configuration for message conversion - * @param options.mergeToolResultText If true, merge text content after tool_results into the last - * tool message instead of creating a separate user message. - * This is critical for DeepSeek's interleaved thinking mode. - * @returns Array of OpenAI messages where consecutive messages with the same role are combined - */ -export function convertToR1Format( - messages: AnthropicMessage[], - options?: { mergeToolResultText?: boolean }, -): Message[] { - const result: Message[] = [] - - for (const message of messages) { - // Check if the message has reasoning_content (for DeepSeek interleaved thinking) - const messageWithReasoning = message as AnthropicMessage & { reasoning_content?: string } - const reasoningContent = messageWithReasoning.reasoning_content - - if (message.role === "user") { - // Handle user messages - may contain tool_result blocks - if (Array.isArray(message.content)) { - const textParts: string[] = [] - const imageParts: ContentPartImage[] = [] - const toolResults: { tool_use_id: string; content: string }[] = [] - - for (const part of message.content) { - if (part.type === "text") { - textParts.push(part.text) - } else if (part.type === "image") { - imageParts.push({ - type: "image_url", - image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, - }) - } else if (part.type === "tool_result") { - // Convert tool_result to OpenAI tool message format - let content: string - if (typeof part.content === "string") { - content = part.content - } else if (Array.isArray(part.content)) { - content = - part.content - ?.map((c) => { - if (c.type === "text") return c.text - if (c.type === "image") return "(image)" - return "" - }) - .join("\n") ?? "" - } else { - content = "" - } - toolResults.push({ - tool_use_id: part.tool_use_id, - content, - }) - } - } - - // Add tool messages first (they must follow assistant tool_use) - for (const toolResult of toolResults) { - const toolMessage: ToolMessage = { - role: "tool", - tool_call_id: toolResult.tool_use_id, - content: toolResult.content, - } - result.push(toolMessage) - } - - // Handle text/image content after tool results - if (textParts.length > 0 || imageParts.length > 0) { - // For DeepSeek interleaved thinking: when mergeToolResultText is enabled and we have - // tool results followed by text, merge the text into the last tool message to avoid - // creating a user message that would cause reasoning_content to be dropped. - // This is critical because DeepSeek drops all reasoning_content when it sees a user message. - const shouldMergeIntoToolMessage = - options?.mergeToolResultText && toolResults.length > 0 && imageParts.length === 0 - - if (shouldMergeIntoToolMessage) { - // Merge text content into the last tool message - const lastToolMessage = result[result.length - 1] as ToolMessage - if (lastToolMessage?.role === "tool") { - const additionalText = textParts.join("\n") - lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` - } - } else { - // Standard behavior: add user message with text/image content - let content: UserMessage["content"] - if (imageParts.length > 0) { - const parts: (ContentPartText | ContentPartImage)[] = [] - if (textParts.length > 0) { - parts.push({ type: "text", text: textParts.join("\n") }) - } - parts.push(...imageParts) - content = parts - } else { - content = textParts.join("\n") - } - - // Check if we can merge with the last message - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "user") { - // Merge with existing user message - if (typeof lastMessage.content === "string" && typeof content === "string") { - lastMessage.content += `\n${content}` - } else { - const lastContent = Array.isArray(lastMessage.content) - ? lastMessage.content - : [{ type: "text" as const, text: lastMessage.content || "" }] - const newContent = Array.isArray(content) - ? content - : [{ type: "text" as const, text: content }] - lastMessage.content = [...lastContent, ...newContent] as UserMessage["content"] - } - } else { - result.push({ role: "user", content }) - } - } - } - } else { - // Simple string content - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "user") { - if (typeof lastMessage.content === "string") { - lastMessage.content += `\n${message.content}` - } else { - ;(lastMessage.content as (ContentPartText | ContentPartImage)[]).push({ - type: "text", - text: message.content, - }) - } - } else { - result.push({ role: "user", content: message.content }) - } - } - } else if (message.role === "assistant") { - // Handle assistant messages - may contain tool_use blocks and reasoning blocks - if (Array.isArray(message.content)) { - const textParts: string[] = [] - const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = [] - let extractedReasoning: string | undefined - - for (const part of message.content) { - if (part.type === "text") { - textParts.push(part.text) - } else if (part.type === "tool_use") { - toolCalls.push({ - id: part.id, - type: "function", - function: { - name: part.name, - arguments: JSON.stringify(part.input), - }, - }) - } else if ((part as any).type === "reasoning" && (part as any).text) { - // Extract reasoning from content blocks (Task stores it this way) - extractedReasoning = (part as any).text - } - } - - // Use reasoning from content blocks if not provided at top level - const finalReasoning = reasoningContent || extractedReasoning - - const assistantMessage: DeepSeekAssistantMessage = { - role: "assistant", - content: textParts.length > 0 ? textParts.join("\n") : null, - ...(toolCalls.length > 0 && { tool_calls: toolCalls }), - // Preserve reasoning_content for DeepSeek interleaved thinking - ...(finalReasoning && { reasoning_content: finalReasoning }), - } - - // Check if we can merge with the last message (only if no tool calls) - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "assistant" && !toolCalls.length && !(lastMessage as any).tool_calls) { - // Merge text content - if (typeof lastMessage.content === "string" && typeof assistantMessage.content === "string") { - lastMessage.content += `\n${assistantMessage.content}` - } else if (assistantMessage.content) { - const lastContent = lastMessage.content || "" - lastMessage.content = `${lastContent}\n${assistantMessage.content}` - } - // Preserve reasoning_content from the new message if present - if (finalReasoning) { - ;(lastMessage as DeepSeekAssistantMessage).reasoning_content = finalReasoning - } - } else { - result.push(assistantMessage) - } - } else { - // Simple string content - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "assistant" && !(lastMessage as any).tool_calls) { - if (typeof lastMessage.content === "string") { - lastMessage.content += `\n${message.content}` - } else { - lastMessage.content = message.content - } - // Preserve reasoning_content from the new message if present - if (reasoningContent) { - ;(lastMessage as DeepSeekAssistantMessage).reasoning_content = reasoningContent - } - } else { - const assistantMessage: DeepSeekAssistantMessage = { - role: "assistant", - content: message.content, - ...(reasoningContent && { reasoning_content: reasoningContent }), - } - result.push(assistantMessage) - } - } - } - } - - return result -} diff --git a/src/api/transform/sanitize-messages.ts b/src/api/transform/sanitize-messages.ts new file mode 100644 index 00000000000..27fe8155f40 --- /dev/null +++ b/src/api/transform/sanitize-messages.ts @@ -0,0 +1,32 @@ +import type { ModelMessage } from "ai" +import type { RooMessage, RooRoleMessage } from "../../core/task-persistence/rooMessage" +import { isRooReasoningMessage } from "../../core/task-persistence/rooMessage" + +/** + * Sanitize RooMessage[] for provider APIs by allowlisting only the fields + * that the AI SDK expects on each message. + * + * Legacy fields like `reasoning_details`, `reasoning_content`, `ts`, `condenseId`, + * etc. survive JSON deserialization round-trips and cause providers to reject + * requests with "Extra inputs are not permitted" (Anthropic 400) or similar errors. + * + * This uses an allowlist approach: only `role`, `content`, and `providerOptions` + * are forwarded, ensuring any future extraneous fields are also stripped. + * + * RooReasoningMessage items (standalone encrypted reasoning with no `role`) are + * filtered out since they have no AI SDK equivalent. + */ +export function sanitizeMessagesForProvider(messages: RooMessage[]): ModelMessage[] { + return messages + .filter((msg): msg is RooRoleMessage => !isRooReasoningMessage(msg)) + .map((msg) => { + const clean: Record = { + role: msg.role, + content: msg.content, + } + if (msg.providerOptions !== undefined) { + clean.providerOptions = msg.providerOptions + } + return clean as ModelMessage + }) +} diff --git a/src/core/condense/__tests__/nested-condense.spec.ts b/src/core/condense/__tests__/nested-condense.spec.ts index fbccc15eadd..39aebc64d53 100644 --- a/src/core/condense/__tests__/nested-condense.spec.ts +++ b/src/core/condense/__tests__/nested-condense.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { ApiMessage } from "../../task-persistence/apiMessages" +import { LegacyApiMessage } from "../../task-persistence/apiMessages" import { getEffectiveApiHistory, getMessagesSinceLastSummary } from "../index" describe("nested condensing scenarios", () => { diff --git a/src/core/condense/__tests__/rewind-after-condense.spec.ts b/src/core/condense/__tests__/rewind-after-condense.spec.ts index b5e8c4c06be..eebb7152fdd 100644 --- a/src/core/condense/__tests__/rewind-after-condense.spec.ts +++ b/src/core/condense/__tests__/rewind-after-condense.spec.ts @@ -12,7 +12,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { getEffectiveApiHistory, cleanupAfterTruncation } from "../index" -import { ApiMessage } from "../../task-persistence/apiMessages" +import { LegacyApiMessage } from "../../task-persistence/apiMessages" describe("Rewind After Condense - Issue #8295", () => { beforeEach(() => { diff --git a/src/core/context/context-management/context-error-handling.ts b/src/core/context/context-management/context-error-handling.ts index 6cfe993f955..9d94f867b50 100644 --- a/src/core/context/context-management/context-error-handling.ts +++ b/src/core/context/context-management/context-error-handling.ts @@ -1,13 +1,50 @@ +import { APICallError, RetryError } from "ai" import { APIError } from "openai" export function checkContextWindowExceededError(error: unknown): boolean { return ( + checkIsAiSdkContextWindowError(error) || checkIsOpenAIContextWindowError(error) || checkIsOpenRouterContextWindowError(error) || checkIsAnthropicContextWindowError(error) ) } +function checkIsAiSdkContextWindowError(error: unknown): boolean { + try { + // Unwrap RetryError to get the underlying APICallError + let apiError: unknown = error + if (RetryError.isInstance(error)) { + apiError = error.lastError + } + + if (!APICallError.isInstance(apiError)) { + return false + } + + if (apiError.statusCode !== 400) { + return false + } + + // Check message and responseBody for context window indicators + const textsToCheck = [apiError.message, apiError.responseBody].filter((t): t is string => typeof t === "string") + const contextWindowPatterns = [ + /\bcontext\s*(?:length|window)\b/i, + /\btoken\s*limit\b/i, + /maximum\s*(?:context\s*)?(?:length|tokens)/i, + /prompt\s*is\s*too\s*long/i, + /input\s*is\s*too\s*long/i, + /too\s*many\s*tokens/i, + /content\s*size\s*exceeds/i, + /request\s*too\s*large/i, + ] + + return textsToCheck.some((text) => contextWindowPatterns.some((pattern) => pattern.test(text))) + } catch { + return false + } +} + function checkIsOpenRouterContextWindowError(error: unknown): boolean { try { if (!error || typeof error !== "object") { diff --git a/src/core/message-manager/index.ts b/src/core/message-manager/index.ts index 71a5f3ae2de..58cc1667dca 100644 --- a/src/core/message-manager/index.ts +++ b/src/core/message-manager/index.ts @@ -1,7 +1,7 @@ import * as path from "path" import { Task } from "../task/Task" import { ClineMessage } from "@roo-code/types" -import { ApiMessage } from "../task-persistence/apiMessages" +import { LegacyApiMessage } from "../task-persistence/apiMessages" import { cleanupAfterTruncation } from "../condense" import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" import { getTaskDirectoryPath } from "../../utils/storage" diff --git a/src/core/task-persistence/__tests__/rooMessage.spec.ts b/src/core/task-persistence/__tests__/rooMessage.spec.ts index 86c415dc6a8..909a3f48fcc 100644 --- a/src/core/task-persistence/__tests__/rooMessage.spec.ts +++ b/src/core/task-persistence/__tests__/rooMessage.spec.ts @@ -42,7 +42,6 @@ const userMessageParts: RooUserMessage = { const assistantMessageString: RooAssistantMessage = { role: "assistant", content: "Sure, I can help with that.", - id: "resp_123", } const assistantMessageParts: RooAssistantMessage = { diff --git a/src/core/task-persistence/__tests__/rooMessages.spec.ts b/src/core/task-persistence/__tests__/rooMessages.spec.ts index 55f3b7c9c74..dc687dae04e 100644 --- a/src/core/task-persistence/__tests__/rooMessages.spec.ts +++ b/src/core/task-persistence/__tests__/rooMessages.spec.ts @@ -4,8 +4,8 @@ import * as os from "os" import * as path from "path" import * as fs from "fs/promises" -import { detectFormat, readRooMessages, saveRooMessages } from "../apiMessages" -import type { ApiMessage } from "../apiMessages" +import { detectFormat, readRooMessages, saveRooMessages, stripCacheProviderOptions } from "../apiMessages" +import type { LegacyApiMessage } from "../apiMessages" import type { RooMessage, RooMessageHistory } from "../rooMessage" import { ROO_MESSAGE_VERSION } from "../rooMessage" import * as safeWriteJsonModule from "../../../utils/safeWriteJson" @@ -46,7 +46,7 @@ const sampleV2Envelope: RooMessageHistory = { messages: sampleRooMessages, } -const sampleLegacyMessages: ApiMessage[] = [ +const sampleLegacyMessages: LegacyApiMessage[] = [ { role: "user", content: "Hello from legacy", ts: 1000 }, { role: "assistant", content: "Legacy response", ts: 2000 }, ] @@ -275,3 +275,198 @@ describe("round-trip", () => { expect(detectFormat(parsed)).toBe("v2") }) }) + +// ──────────────────────────────────────────────────────────────────────────── +// stripCacheProviderOptions +// ──────────────────────────────────────────────────────────────────────────── + +describe("stripCacheProviderOptions", () => { + it("returns messages unchanged when they have no providerOptions", () => { + const messages: RooMessage[] = [ + { role: "user" as const, content: [{ type: "text" as const, text: "hi" }] }, + { role: "assistant" as const, content: [{ type: "text" as const, text: "hello" }] }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual(messages) + }) + + it("strips anthropic.cacheControl and removes empty providerOptions", () => { + const messages: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, + }, + }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual([ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + }, + ]) + expect(result[0]).not.toHaveProperty("providerOptions") + }) + + it("strips bedrock.cachePoint and removes empty providerOptions", () => { + const messages: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + bedrock: { cachePoint: { type: "default" } }, + }, + }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual([ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + }, + ]) + expect(result[0]).not.toHaveProperty("providerOptions") + }) + + it("strips both anthropic.cacheControl and bedrock.cachePoint simultaneously", () => { + const messages: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, + bedrock: { cachePoint: { type: "default" } }, + }, + }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual([ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + }, + ]) + expect(result[0]).not.toHaveProperty("providerOptions") + }) + + it("preserves anthropic.signature while stripping anthropic.cacheControl", () => { + const messages: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" }, signature: "abc123" }, + }, + }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual([ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { signature: "abc123" }, + }, + }, + ]) + }) + + it("preserves openrouter.reasoning_details unchanged", () => { + const reasoningDetails = [{ type: "thinking", thinking: "hmm" }] + const messages: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + openrouter: { reasoning_details: reasoningDetails }, + }, + }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual([ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + openrouter: { reasoning_details: reasoningDetails }, + }, + }, + ]) + }) + + it("strips cache keys while preserving non-cache keys across namespaces", () => { + const reasoningDetails = [{ type: "thinking", thinking: "hmm" }] + const messages: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" }, signature: "abc123" }, + bedrock: { cachePoint: { type: "default" } }, + openrouter: { reasoning_details: reasoningDetails }, + }, + }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual([ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { signature: "abc123" }, + openrouter: { reasoning_details: reasoningDetails }, + }, + }, + ]) + // bedrock namespace should be fully removed + const resultOptions = (result[0] as unknown as Record).providerOptions as Record + expect(resultOptions).not.toHaveProperty("bedrock") + }) + + it("does not mutate the original array", () => { + const original: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" }, signature: "abc123" }, + }, + }, + ] + + // Snapshot original state before calling + const originalSnapshot = JSON.parse(JSON.stringify(original)) + + stripCacheProviderOptions(original) + + expect(original).toEqual(originalSnapshot) + // Verify the providerOptions still has cacheControl on the original + const originalOptions = (original[0] as unknown as Record).providerOptions as Record< + string, + Record + > + expect(originalOptions["anthropic"]["cacheControl"]).toEqual({ type: "ephemeral" }) + }) + + it("returns empty array for empty input", () => { + const result = stripCacheProviderOptions([]) + + expect(result).toEqual([]) + }) +}) diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 3c2149ed44e..36a2cf3330d 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -12,7 +12,10 @@ import type { RooMessage, RooMessageHistory } from "./rooMessage" import { ROO_MESSAGE_VERSION } from "./rooMessage" import { convertAnthropicToRooMessages } from "./converters/anthropicToRoo" -export type ApiMessage = Anthropic.MessageParam & { +/** + * @deprecated This is the legacy Anthropic message format. Use {@link RooMessage} for the current format. + */ +export type LegacyApiMessage = Anthropic.MessageParam & { ts?: number isSummary?: boolean id?: string @@ -40,13 +43,16 @@ export type ApiMessage = Anthropic.MessageParam & { isTruncationMarker?: boolean } +/** @deprecated Use {@link LegacyApiMessage} directly. This alias exists for backward compatibility only. */ +export type ApiMessage = LegacyApiMessage + export async function readApiMessages({ taskId, globalStoragePath, }: { taskId: string globalStoragePath: string -}): Promise { +}): Promise { const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory) @@ -114,7 +120,7 @@ export async function saveApiMessages({ taskId, globalStoragePath, }: { - messages: ApiMessage[] + messages: LegacyApiMessage[] taskId: string globalStoragePath: string }) { @@ -194,7 +200,7 @@ export async function readRooMessages({ return [] } - return convertAnthropicToRooMessages(parsedData as ApiMessage[]) + return convertAnthropicToRooMessages(parsedData as LegacyApiMessage[]) } const primaryResult = await tryParseFile(filePath) @@ -214,6 +220,48 @@ export async function readRooMessages({ return [] } +/** + * Strip transient cache-control provider options that are applied at request + * time by applyCacheBreakpoints() and should not be persisted. + * + * Removes: + * - anthropic.cacheControl + * - bedrock.cachePoint + * + * Preserves all other providerOptions (e.g. anthropic.signature, openrouter.reasoning_details). + */ +export function stripCacheProviderOptions(messages: RooMessage[]): RooMessage[] { + const cloned = structuredClone(messages) + + for (const msg of cloned) { + if (!("providerOptions" in msg) || (msg as { providerOptions?: unknown }).providerOptions == null) { + continue + } + + const providerOptions = (msg as { providerOptions: Record> }).providerOptions + + if (providerOptions["anthropic"] != null) { + delete providerOptions["anthropic"]["cacheControl"] + if (Object.keys(providerOptions["anthropic"]).length === 0) { + delete providerOptions["anthropic"] + } + } + + if (providerOptions["bedrock"] != null) { + delete providerOptions["bedrock"]["cachePoint"] + if (Object.keys(providerOptions["bedrock"]).length === 0) { + delete providerOptions["bedrock"] + } + } + + if (Object.keys(providerOptions).length === 0) { + delete (msg as { providerOptions?: unknown }).providerOptions + } + } + + return cloned +} + /** * Save `RooMessage[]` wrapped in the versioned `RooMessageHistory` envelope. * diff --git a/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts b/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts index 8cfc72a9e8e..24d60b8bc4b 100644 --- a/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts +++ b/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts @@ -1,4 +1,4 @@ -import type { ApiMessage } from "../../apiMessages" +import type { LegacyApiMessage } from "../../apiMessages" import type { RooUserMessage, RooAssistantMessage, @@ -16,9 +16,9 @@ import { convertAnthropicToRooMessages } from "../anthropicToRoo" // Helpers // ──────────────────────────────────────────────────────────────────────────── -/** Shorthand to create an ApiMessage with required fields. */ -function apiMsg(overrides: Partial & Pick): ApiMessage { - return overrides as ApiMessage +/** Shorthand to create a LegacyApiMessage with required fields. */ +function apiMsg(overrides: Partial & Pick): LegacyApiMessage { + return overrides as LegacyApiMessage } // ──────────────────────────────────────────────────────────────────────────── @@ -139,7 +139,7 @@ describe("user messages with URL image content", () => { describe("user messages with tool_result blocks", () => { test("splits tool_result into RooToolMessage before RooUserMessage", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "read_file", input: { path: "foo.ts" } }], @@ -179,7 +179,7 @@ describe("user messages with tool_result blocks", () => { }) test("handles tool_result with array content (joins text with newlines)", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "call_2", name: "list_files", input: {} }], @@ -204,7 +204,7 @@ describe("user messages with tool_result blocks", () => { }) test("handles tool_result with undefined content → (empty)", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "call_3", name: "run_command", input: {} }], @@ -220,7 +220,7 @@ describe("user messages with tool_result blocks", () => { }) test("handles tool_result with empty string content → (empty)", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "call_4", name: "run_command", input: {} }], @@ -242,7 +242,7 @@ describe("user messages with tool_result blocks", () => { describe("user messages with mixed tool_result and text", () => { test("separates tool results from text/image parts correctly", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [ @@ -287,7 +287,7 @@ describe("user messages with mixed tool_result and text", () => { }) test("only emits tool message when no text/image parts exist", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "tc_only", name: "some_tool", input: {} }], @@ -668,7 +668,6 @@ describe("standalone reasoning messages", () => { const msg = result[0] as RooReasoningMessage expect(msg.type).toBe("reasoning") expect(msg.encrypted_content).toBe("encrypted_data_blob") - expect(msg.id).toBe("resp_001") expect(msg.summary).toEqual([{ type: "summary_text", text: "I thought about X" }]) expect(msg).not.toHaveProperty("role") }) @@ -733,7 +732,7 @@ describe("metadata preservation", () => { }) test("carries over metadata on tool messages (split from user)", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "tc_meta", name: "my_tool", input: {} }], @@ -787,7 +786,7 @@ describe("metadata preservation", () => { describe("tool name resolution", () => { test("resolves tool names from preceding assistant messages", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "tc_x", name: "execute_command", input: { command: "ls" } }], @@ -803,7 +802,7 @@ describe("tool name resolution", () => { }) test("falls back to unknown_tool when tool call ID is not found", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "user", content: [{ type: "tool_result", tool_use_id: "nonexistent_id", content: "result" }], @@ -815,7 +814,7 @@ describe("tool name resolution", () => { }) test("resolves tool names across multiple assistant messages", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "tc_first", name: "tool_alpha", input: {} }], @@ -878,7 +877,7 @@ describe("empty/undefined content edge cases", () => { }) test("handles tool_result with image content blocks", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "tc_img", name: "screenshot", input: {} }], @@ -914,7 +913,7 @@ describe("empty/undefined content edge cases", () => { describe("full conversation round-trip", () => { test("converts a realistic multi-turn conversation", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ // Turn 1: user asks a question apiMsg({ role: "user", content: "Can you read my config file?", ts: 1000 }), // Turn 2: assistant uses a tool @@ -1069,7 +1068,6 @@ describe("full conversation round-trip", () => { const m7 = result[7] as RooReasoningMessage expect(m7.type).toBe("reasoning") expect(m7.encrypted_content).toBe("enc_reasoning_blob") - expect(m7.id).toBe("resp_reason") expect(m7.ts).toBe(6500) }) }) diff --git a/src/core/task-persistence/converters/anthropicToRoo.ts b/src/core/task-persistence/converters/anthropicToRoo.ts index 543bcfad1e5..55842ca398f 100644 --- a/src/core/task-persistence/converters/anthropicToRoo.ts +++ b/src/core/task-persistence/converters/anthropicToRoo.ts @@ -1,15 +1,15 @@ /** - * Converter from Anthropic-format `ApiMessage` to the new `RooMessage` format. + * Converter from Anthropic-format `LegacyApiMessage` to the new `RooMessage` format. * * This is the critical backward-compatibility piece that allows old conversation * histories stored in Anthropic format to be read and converted to the new format. * - * The conversion logic mirrors {@link ../../api/transform/ai-sdk.ts | convertToAiSdkMessages} - * but targets `RooMessage` types instead of AI SDK `ModelMessage`. + * Converts Anthropic content blocks (tool_use, tool_result, thinking, reasoning, + * thoughtSignature, etc.) into their AI SDK RooMessage equivalents. */ import type { TextPart, ImagePart, ToolCallPart, ToolResultPart, ReasoningPart } from "../rooMessage" -import type { ApiMessage } from "../apiMessages" +import type { LegacyApiMessage } from "../apiMessages" import type { RooMessage, RooUserMessage, @@ -28,10 +28,10 @@ import type { type LooseProviderOptions = Record> /** - * Extract Roo-specific metadata fields from an ApiMessage. + * Extract Roo-specific metadata fields from a LegacyApiMessage. * Only includes fields that are actually defined (avoids `undefined` keys). */ -function extractMetadata(message: ApiMessage): RooMessageMetadata { +function extractMetadata(message: LegacyApiMessage): RooMessageMetadata { const metadata: RooMessageMetadata = {} if (message.ts !== undefined) metadata.ts = message.ts if (message.condenseId !== undefined) metadata.condenseId = message.condenseId @@ -82,7 +82,7 @@ function attachReasoningDetails( } /** - * Convert an array of Anthropic-format `ApiMessage` objects to `RooMessage` format. + * Convert an array of Anthropic-format `LegacyApiMessage` objects to `RooMessage` format. * * Conversion rules: * - User string content → `RooUserMessage` with `content: string` @@ -93,10 +93,10 @@ function attachReasoningDetails( * - Standalone reasoning messages → `RooReasoningMessage` * - Metadata fields (ts, condenseId, etc.) are preserved on all output messages * - * @param messages - Array of ApiMessage (Anthropic format with metadata) + * @param messages - Array of LegacyApiMessage (Anthropic format with metadata) * @returns Array of RooMessage objects */ -export function convertAnthropicToRooMessages(messages: ApiMessage[]): RooMessage[] { +export function convertAnthropicToRooMessages(messages: LegacyApiMessage[]): RooMessage[] { const result: RooMessage[] = [] // First pass: build a map of tool call IDs to tool names from assistant messages. @@ -122,7 +122,6 @@ export function convertAnthropicToRooMessages(messages: ApiMessage[]): RooMessag encrypted_content: message.encrypted_content, ...metadata, } - if (message.id) reasoningMsg.id = message.id if (message.summary) reasoningMsg.summary = message.summary result.push(reasoningMsg) continue diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index a26fb30de55..4d73d2b0d1c 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -1,5 +1,5 @@ -export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages" -export { detectFormat, readRooMessages, saveRooMessages } from "./apiMessages" +export { type LegacyApiMessage, type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages" +export { detectFormat, readRooMessages, saveRooMessages, stripCacheProviderOptions } from "./apiMessages" export { readTaskMessages, saveTaskMessages } from "./taskMessages" export { taskMetadata } from "./taskMetadata" export type { RooMessage, RooMessageHistory, RooMessageMetadata } from "./rooMessage" diff --git a/src/core/task-persistence/rooMessage.ts b/src/core/task-persistence/rooMessage.ts index 4328ef7b928..b7083ef8a18 100644 --- a/src/core/task-persistence/rooMessage.ts +++ b/src/core/task-persistence/rooMessage.ts @@ -87,13 +87,9 @@ export type RooUserMessage = Omit & /** * An assistant-authored message. Content may be a plain string or an array of * text, tool-call, and reasoning parts. Extends AI SDK `AssistantModelMessage` - * with metadata and a provider response ID. + * with metadata. */ -export type RooAssistantMessage = AssistantModelMessage & - RooMessageMetadata & { - /** Provider response ID (e.g. OpenAI `response.id`). */ - id?: string - } +export type RooAssistantMessage = AssistantModelMessage & RooMessageMetadata /** * A tool result message containing one or more tool outputs. diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 0f0930c7942..8a564e56b34 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -61,9 +61,11 @@ import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" // api import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" import type { AssistantModelMessage } from "ai" +import { APICallError, RetryError } from "ai" import { ApiStream, GroundingSource } from "../../api/transform/stream" +import { extractAiSdkErrorMessage } from "../../api/transform/ai-sdk" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" -import { applyCacheBreakpoints, UNIVERSAL_CACHE_OPTIONS } from "../../api/transform/cache-breakpoints" +import { UNIVERSAL_CACHE_OPTIONS } from "../../api/transform/cache-breakpoints" // shared import { findLastIndex } from "../../shared/array" @@ -110,7 +112,7 @@ import { manageContext, willManageContext } from "../context-management" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { - type ApiMessage, + type LegacyApiMessage, readApiMessages, saveApiMessages, readTaskMessages, @@ -1064,13 +1066,10 @@ export class Task extends EventEmitter implements TaskLike { } const handler = this.api as ApiHandler & { - getResponseId?: () => string | undefined getEncryptedContent?: () => { encrypted_content: string; id?: string } | undefined } if (message.role === "assistant") { - const responseId = handler.getResponseId?.() - // Check if the message is already in native AI SDK format (from result.response.messages). // These messages have providerOptions on content parts (reasoning signatures, etc.) // and don't need manual block injection. @@ -1083,21 +1082,19 @@ export class Task extends EventEmitter implements TaskLike { // with providerOptions (signatures, redactedData, etc.) in the correct format. this.apiConversationHistory.push({ ...message, - ...(responseId ? { id: responseId } : {}), ts: message.ts ?? Date.now(), }) await this.saveApiConversationHistory() return } - // Fallback path: store the manually-constructed message with responseId and timestamp. + // Fallback path: store the manually-constructed message with timestamp. // This handles non-AI-SDK providers and AI SDK responses without reasoning // (text-only or text + tool calls where no content parts carry providerOptions). const reasoningData = handler.getEncryptedContent?.() const messageWithTs: RooAssistantMessage & { content: any } = { ...message, - ...(responseId ? { id: responseId } : {}), ts: Date.now(), } @@ -3084,7 +3081,7 @@ export class Task extends EventEmitter implements TaskLike { case "tool_call": { // Legacy: Handle complete tool calls (for backward compatibility) // Convert native tool call to ToolUse format - const toolUse = NativeToolCallParser.parseToolCall({ + let toolUse = NativeToolCallParser.parseToolCall({ id: chunk.id, name: chunk.name as ToolName, arguments: chunk.arguments, @@ -3092,7 +3089,14 @@ export class Task extends EventEmitter implements TaskLike { if (!toolUse) { console.error(`Failed to parse tool call for task ${this.taskId}:`, chunk) - break + // Still push a tool_use block so the didToolUse check passes + // and presentAssistantMessage's unknown-tool handler can report the error + toolUse = { + type: "tool_use" as const, + name: (chunk.name ?? "unknown_tool") as ToolName, + params: {}, + partial: false, + } } // Store the tool call ID on the ToolUse object for later reference @@ -3352,7 +3356,7 @@ export class Task extends EventEmitter implements TaskLike { // Determine cancellation reason const cancelReason: ClineApiReqCancelReason = this.abort ? "user_cancelled" : "streaming_failed" - const rawErrorMessage = error.message ?? JSON.stringify(serializeError(error), null, 2) + const rawErrorMessage = extractAiSdkErrorMessage(error) // Check auto-retry state BEFORE abortStream so we can suppress the error // message on the api_req_started row when backoffAndAnnounce will display it instead. @@ -4384,10 +4388,6 @@ export class Task extends EventEmitter implements TaskLike { // mergeConsecutiveApiMessages implementation) without mutating stored history. const mergedForApi = mergeConsecutiveApiMessages(messagesSinceLastSummary, { roles: ["user"] }) const messagesWithoutImages = maybeRemoveImageBlocks(mergedForApi, this.api) - const cleanConversationHistory = this.buildCleanConversationHistory(messagesWithoutImages) - - // Breakpoints 3-4: Apply cache breakpoints to the last 2 non-assistant messages - applyCacheBreakpoints(cleanConversationHistory.filter(isRooRoleMessage)) // Check auto-approval limits const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits( @@ -4470,7 +4470,7 @@ export class Task extends EventEmitter implements TaskLike { // Reset the flag after using it this.skipPrevResponseIdOnce = false - const stream = this.api.createMessage(systemPrompt, cleanConversationHistory, metadata) + const stream = this.api.createMessage(systemPrompt, messagesWithoutImages, metadata) const iterator = stream[Symbol.asyncIterator]() // Set up abort handling - when the signal is aborted, clean up the controller reference @@ -4538,7 +4538,7 @@ export class Task extends EventEmitter implements TaskLike { } else { const { response } = await this.ask( "api_req_failed", - error.message ?? JSON.stringify(serializeError(error), null, 2), + extractAiSdkErrorMessage(error), ) if (response !== "yesButtonClicked") { @@ -4585,35 +4585,66 @@ export class Task extends EventEmitter implements TaskLike { rateLimitDelay = Math.ceil(Math.min(rateLimit, Math.max(0, rateLimit * 1000 - elapsed) / 1000)) } - // Prefer RetryInfo on 429 if present - if (error?.status === 429) { - const retryInfo = error?.errorDetails?.find( - (d: any) => d["@type"] === "type.googleapis.com/google.rpc.RetryInfo", - ) - const match = retryInfo?.retryDelay?.match?.(/^(\d+)s$/) - if (match) { - exponentialDelay = Number(match[1]) + 1 + // Extract status code from AI SDK errors or legacy error shapes + const statusCode = APICallError.isInstance(error) + ? error.statusCode + : RetryError.isInstance(error) && APICallError.isInstance(error.lastError) + ? error.lastError.statusCode + : (error as any)?.status + + // Prefer RetryInfo on 429 if present + if (statusCode === 429) { + // Try direct errorDetails (legacy Vertex), then try parsing from responseBody + let retryDelaySec: number | undefined + const errorDetails = (error as any)?.errorDetails + if (errorDetails) { + const retryInfo = errorDetails.find( + (d: any) => d["@type"] === "type.googleapis.com/google.rpc.RetryInfo", + ) + const match = retryInfo?.retryDelay?.match?.(/^(\d+)s$/) + if (match) { + retryDelaySec = Number(match[1]) + 1 + } + } + // Also try extracting from APICallError responseBody for Vertex errors + if (!retryDelaySec) { + const responseBody = APICallError.isInstance(error) + ? error.responseBody + : RetryError.isInstance(error) && APICallError.isInstance(error.lastError) + ? error.lastError.responseBody + : undefined + if (responseBody) { + try { + const parsed = JSON.parse(responseBody) + const retryInfo = parsed?.error?.details?.find( + (d: any) => d["@type"] === "type.googleapis.com/google.rpc.RetryInfo", + ) + const match = retryInfo?.retryDelay?.match?.(/^(\d+)s$/) + if (match) { + retryDelaySec = Number(match[1]) + 1 + } + } catch { + // responseBody not parseable, skip + } + } + } + if (retryDelaySec) { + exponentialDelay = retryDelaySec + } + } + + const finalDelay = Math.max(exponentialDelay, rateLimitDelay) + if (finalDelay <= 0) { + return + } + + // Build header text; fall back to error message if none provided + let headerText: string + if (statusCode) { + headerText = `${statusCode}\n${extractAiSdkErrorMessage(error)}` + } else { + headerText = extractAiSdkErrorMessage(error) } - } - - const finalDelay = Math.max(exponentialDelay, rateLimitDelay) - if (finalDelay <= 0) { - return - } - - // Build header text; fall back to error message if none provided - let headerText - if (error.status) { - // Include both status code (for ChatRow parsing) and detailed message (for error details) - // Format: "\n" allows ChatRow to extract status via parseInt(text.substring(0,3)) - // while preserving the full error message in errorDetails for debugging - const errorMessage = error?.message || "Unknown error" - headerText = `${error.status}\n${errorMessage}` - } else if (error?.message) { - headerText = error.message - } else { - headerText = "Unknown error" - } headerText = headerText ? `${headerText}\n` : "" @@ -4640,166 +4671,6 @@ export class Task extends EventEmitter implements TaskLike { return checkpointSave(this, force, suppressMessage) } - /** - * Prepares conversation history for the API request by sanitizing stored - * RooMessage items into valid AI SDK ModelMessage format. - * - * Condense/truncation filtering is handled upstream by getEffectiveApiHistory. - * This method: - * - * - Removes RooReasoningMessage items (standalone encrypted reasoning with no `role`) - * - Converts custom content blocks in assistant messages to valid AI SDK parts: - * - `thinking` (Anthropic) → `reasoning` part with signature in providerOptions - * - `redacted_thinking` (Anthropic) → stripped (no AI SDK equivalent) - * - `thoughtSignature` (Gemini) → extracted and attached to first tool-call providerOptions - * - `reasoning` with `encrypted_content` but no `text` → stripped (invalid reasoning part) - * - Carries `reasoning_details` (OpenRouter) through to providerOptions - * - Strips all reasoning when the provider does not support it - */ - private buildCleanConversationHistory(messages: RooMessage[]): RooMessage[] { - const preserveReasoning = this.api.getModel().info.preserveReasoning === true || this.api.isAiSdkProvider() - - return messages - .filter((msg) => { - // Always remove standalone RooReasoningMessage items (no `role` field → invalid ModelMessage) - if (isRooReasoningMessage(msg)) { - return false - } - return true - }) - .map((msg) => { - if (!isRooAssistantMessage(msg) || !Array.isArray(msg.content)) { - return msg - } - - // Detect native AI SDK format: content parts already have providerOptions - // (stored directly from result.response.messages). These don't need legacy sanitization. - const isNativeFormat = (msg.content as Array<{ providerOptions?: unknown }>).some( - (p) => p.providerOptions, - ) - - if (isNativeFormat) { - // Native format: only strip reasoning if the provider doesn't support it - if (!preserveReasoning) { - const filtered = (msg.content as Array<{ type: string }>).filter((p) => p.type !== "reasoning") - return { - ...msg, - content: filtered.length > 0 ? filtered : [{ type: "text" as const, text: "" }], - } as unknown as RooMessage - } - // Pass through unchanged — already in valid AI SDK format - return msg - } - - // Legacy path: sanitize old-format messages with custom block types - // (thinking, redacted_thinking, thoughtSignature) - - // Extract thoughtSignature block (Gemini 3) before filtering - let thoughtSignature: string | undefined - for (const part of msg.content) { - const partAny = part as unknown as { type?: string; thoughtSignature?: string } - if (partAny.type === "thoughtSignature" && partAny.thoughtSignature) { - thoughtSignature = partAny.thoughtSignature - } - } - - const sanitized: Array<{ type: string; [key: string]: unknown }> = [] - let appliedThoughtSignature = false - - for (const part of msg.content) { - const partType = (part as { type: string }).type - - if (partType === "thinking") { - // Anthropic extended thinking → AI SDK reasoning part - if (!preserveReasoning) continue - const thinkingPart = part as unknown as { thinking?: string; signature?: string } - if (typeof thinkingPart.thinking === "string" && thinkingPart.thinking.length > 0) { - const reasoningPart: Record = { - type: "reasoning", - text: thinkingPart.thinking, - } - if (thinkingPart.signature) { - reasoningPart.providerOptions = { - anthropic: { signature: thinkingPart.signature }, - bedrock: { signature: thinkingPart.signature }, - } - } - sanitized.push(reasoningPart as (typeof sanitized)[number]) - } - continue - } - - if (partType === "redacted_thinking") { - // No AI SDK equivalent — strip - continue - } - - if (partType === "thoughtSignature") { - // Extracted above, will be attached to first tool-call — strip block - continue - } - - if (partType === "reasoning") { - if (!preserveReasoning) continue - const reasoningPart = part as unknown as { text?: string; encrypted_content?: string } - // Only valid if it has a `text` field (AI SDK schema requires it) - if (typeof reasoningPart.text === "string" && reasoningPart.text.length > 0) { - sanitized.push(part as (typeof sanitized)[number]) - } - // Blocks with encrypted_content but no text are invalid → skip - continue - } - - if (partType === "tool-call" && thoughtSignature && !appliedThoughtSignature) { - // Attach Gemini thoughtSignature to the first tool-call - const toolCall = { ...(part as object) } as Record - toolCall.providerOptions = { - ...((toolCall.providerOptions as Record) ?? {}), - google: { thoughtSignature }, - vertex: { thoughtSignature }, - } - sanitized.push(toolCall as (typeof sanitized)[number]) - appliedThoughtSignature = true - continue - } - - // text, tool-call, tool-result, file — pass through - sanitized.push(part as (typeof sanitized)[number]) - } - - const content = sanitized.length > 0 ? sanitized : [{ type: "text" as const, text: "" }] - - // Carry reasoning_details through to providerOptions for OpenRouter round-tripping - const rawReasoningDetails = (msg as unknown as { reasoning_details?: Record[] }) - .reasoning_details - const validReasoningDetails = rawReasoningDetails?.filter((detail) => { - switch (detail.type) { - case "reasoning.encrypted": - return typeof detail.data === "string" && detail.data.length > 0 - case "reasoning.text": - return typeof detail.text === "string" - case "reasoning.summary": - return typeof detail.summary === "string" - default: - return false - } - }) - - const result: Record = { - ...msg, - content, - } - - if (validReasoningDetails && validReasoningDetails.length > 0) { - result.providerOptions = { - ...((msg as unknown as { providerOptions?: Record }).providerOptions ?? {}), - openrouter: { reasoning_details: validReasoningDetails }, - } - } - - return result as unknown as RooMessage - }) - } public async checkpointRestore(options: CheckpointRestoreOptions) { return checkpointRestore(this, options) } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 135d691de93..8df49b4e28b 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -15,6 +15,7 @@ import { ApiStreamChunk } from "../../../api/transform/stream" import { ContextProxy } from "../../config/ContextProxy" import { processUserContentMentions } from "../../mentions/processUserContentMentions" import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" +import { NativeToolCallParser } from "../../assistant-message/NativeToolCallParser" // Mock delay before any imports that might use it vi.mock("delay", () => ({ @@ -2259,4 +2260,148 @@ describe("pushToolResultToUserContent", () => { expect(task.pendingToolResults).toHaveLength(1) expect(task.pendingToolResults[0]).toEqual(toolResult) }) + + describe("case tool_call handler - fallback when parseToolCall returns null", () => { + it("should push a fallback tool_use block when parseToolCall returns null", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Verify that parseToolCall returns null for an unrecognized tool name + const result = NativeToolCallParser.parseToolCall({ + id: "call_fallback_123", + name: "completely_nonexistent_tool" as any, + arguments: "{}", + }) + expect(result).toBeNull() + + // Simulate the fixed case "tool_call" handler: + // When parseToolCall returns null, the handler now creates a fallback ToolUse block + const chunk = { + type: "tool_call" as const, + id: "call_fallback_123", + name: "completely_nonexistent_tool", + arguments: "{}", + } + + let toolUse = NativeToolCallParser.parseToolCall({ + id: chunk.id, + name: chunk.name as any, + arguments: chunk.arguments, + }) + + if (!toolUse) { + toolUse = { + type: "tool_use" as const, + name: (chunk.name ?? "unknown_tool") as any, + params: {}, + partial: false, + } + } + + toolUse.id = chunk.id + task.assistantMessageContent.push(toolUse) + + // Verify the fallback block was pushed + expect(task.assistantMessageContent).toHaveLength(1) + const block = task.assistantMessageContent[0] as any + expect(block.type).toBe("tool_use") + expect(block.name).toBe("completely_nonexistent_tool") + expect(block.id).toBe("call_fallback_123") + expect(block.partial).toBe(false) + }) + + it("should ensure didToolUse check passes when fallback tool_use block is pushed", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Simulate the fallback block being pushed (as the fix does) + task.assistantMessageContent.push({ + type: "tool_use" as const, + name: "unknown_tool" as any, + params: {}, + partial: false, + }) + + // This is the exact check from Task.ts ~line 3751 + const didToolUse = task.assistantMessageContent.some( + (block) => block.type === "tool_use" || block.type === "mcp_tool_use", + ) + + expect(didToolUse).toBe(true) + }) + + it("should use chunk.name when available in fallback block", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const chunk = { + type: "tool_call" as const, + id: "call_with_name", + name: "some_bad_tool", + arguments: "{invalid json", + } + + // parseToolCall returns null for invalid JSON or unrecognized tool + const toolUse = NativeToolCallParser.parseToolCall({ + id: chunk.id, + name: chunk.name as any, + arguments: chunk.arguments, + }) + expect(toolUse).toBeNull() + + // The fallback should use chunk.name + const fallback: any = { + type: "tool_use" as const, + name: (chunk.name ?? "unknown_tool") as any, + params: {}, + partial: false, + } + fallback.id = chunk.id + task.assistantMessageContent.push(fallback) + + expect((task.assistantMessageContent[0] as any).name).toBe("some_bad_tool") + }) + + it("should not affect the streaming path (tool_call_start pushes block independently)", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // The streaming path pushes a block at tool_call_start time, + // so even before parsing completes, the block exists. + // Simulate the streaming path pushing a partial tool_use block: + const partialToolUse: any = { + type: "tool_use" as const, + name: "read_file" as any, + params: {}, + partial: true, + id: "call_streaming_123", + } + task.assistantMessageContent.push(partialToolUse) + + // The didToolUse check should find it + const didToolUse = task.assistantMessageContent.some( + (block) => block.type === "tool_use" || block.type === "mcp_tool_use", + ) + expect(didToolUse).toBe(true) + + // Verify the streaming path block is intact + expect(task.assistantMessageContent[0]).toEqual(partialToolUse) + }) + }) }) diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts deleted file mode 100644 index 4dce6f04fdb..00000000000 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import type { ClineProvider } from "../../webview/ClineProvider" -import type { ProviderSettings, ModelInfo } from "@roo-code/types" - -// All vi.mock() calls are hoisted to the top of the file by Vitest -// and are applied before any imports are resolved - -// Mock vscode module before importing Task -vi.mock("vscode", () => ({ - workspace: { - createFileSystemWatcher: vi.fn(() => ({ - onDidCreate: vi.fn(), - onDidChange: vi.fn(), - onDidDelete: vi.fn(), - dispose: vi.fn(), - })), - getConfiguration: vi.fn(() => ({ - get: vi.fn(() => true), - })), - openTextDocument: vi.fn(), - applyEdit: vi.fn(), - }, - RelativePattern: vi.fn((base, pattern) => ({ base, pattern })), - window: { - createOutputChannel: vi.fn(() => ({ - appendLine: vi.fn(), - dispose: vi.fn(), - })), - createTextEditorDecorationType: vi.fn(() => ({ - dispose: vi.fn(), - })), - showTextDocument: vi.fn(), - activeTextEditor: undefined, - }, - Uri: { - file: vi.fn((path) => ({ fsPath: path })), - parse: vi.fn((str) => ({ toString: () => str })), - }, - Range: vi.fn(), - Position: vi.fn(), - WorkspaceEdit: vi.fn(() => ({ - replace: vi.fn(), - insert: vi.fn(), - delete: vi.fn(), - })), - ViewColumn: { - One: 1, - Two: 2, - Three: 3, - }, -})) - -// Mock other dependencies -vi.mock("../../services/mcp/McpServerManager", () => ({ - McpServerManager: { - getInstance: vi.fn().mockResolvedValue(null), - }, -})) - -vi.mock("../../integrations/terminal/TerminalRegistry", () => ({ - TerminalRegistry: { - releaseTerminalsForTask: vi.fn(), - }, -})) - -vi.mock("@roo-code/telemetry", () => ({ - TelemetryService: { - instance: { - captureTaskCreated: vi.fn(), - captureTaskRestarted: vi.fn(), - captureConversationMessage: vi.fn(), - captureLlmCompletion: vi.fn(), - captureConsecutiveMistakeError: vi.fn(), - }, - }, -})) - -// Mock @roo-code/cloud to prevent socket.io-client initialization issues -vi.mock("@roo-code/cloud", () => ({ - CloudService: { - isEnabled: () => false, - }, - BridgeOrchestrator: { - subscribeToTask: vi.fn(), - }, -})) - -// Mock delay to prevent actual delays -vi.mock("delay", () => ({ - __esModule: true, - default: vi.fn().mockResolvedValue(undefined), -})) - -// Mock p-wait-for to prevent hanging on async conditions -vi.mock("p-wait-for", () => ({ - default: vi.fn().mockResolvedValue(undefined), -})) - -// Mock execa -vi.mock("execa", () => ({ - execa: vi.fn(), -})) - -// Mock fs/promises -vi.mock("fs/promises", () => ({ - mkdir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn().mockResolvedValue("[]"), - unlink: vi.fn().mockResolvedValue(undefined), - rmdir: vi.fn().mockResolvedValue(undefined), -})) - -// Mock mentions -vi.mock("../../mentions", () => ({ - parseMentions: vi.fn().mockImplementation((text) => Promise.resolve({ text, mode: undefined, contentBlocks: [] })), - openMention: vi.fn(), - getLatestTerminalOutput: vi.fn(), -})) - -// Mock extract-text -vi.mock("../../../integrations/misc/extract-text", () => ({ - extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), -})) - -// Mock getEnvironmentDetails -vi.mock("../../environment/getEnvironmentDetails", () => ({ - getEnvironmentDetails: vi.fn().mockResolvedValue(""), -})) - -// Mock RooIgnoreController -vi.mock("../../ignore/RooIgnoreController") - -// Mock condense -vi.mock("../../condense", () => ({ - summarizeConversation: vi.fn().mockResolvedValue({ - messages: [], - summary: "summary", - cost: 0, - newContextTokens: 1, - }), -})) - -// Mock storage utilities -vi.mock("../../../utils/storage", () => ({ - getTaskDirectoryPath: vi - .fn() - .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), - getSettingsDirectoryPath: vi - .fn() - .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), -})) - -// Mock fs utilities -vi.mock("../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockReturnValue(false), -})) - -// Import Task AFTER all vi.mock() calls - Vitest hoists mocks so this works -import { Task } from "../Task" - -describe("Task reasoning preservation", () => { - let mockProvider: Partial - let mockApiConfiguration: ProviderSettings - - beforeEach(() => { - // Mock provider with necessary methods - mockProvider = { - postStateToWebview: vi.fn().mockResolvedValue(undefined), - postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), - getState: vi.fn().mockResolvedValue({ - mode: "code", - experiments: {}, - }), - context: { - globalStorageUri: { fsPath: "/test/storage" }, - extensionPath: "/test/extension", - } as any, - log: vi.fn(), - updateTaskHistory: vi.fn().mockResolvedValue(undefined), - postMessageToWebview: vi.fn().mockResolvedValue(undefined), - } - - mockApiConfiguration = { - apiProvider: "anthropic", - apiKey: "test-key", - } as ProviderSettings - }) - - it("should store native AI SDK format messages directly when providerOptions present", async () => { - const task = new Task({ - provider: mockProvider as ClineProvider, - apiConfiguration: mockApiConfiguration, - task: "Test task", - startTask: false, - }) - - // Avoid disk writes in this test - ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) - - task.api = { - getResponseId: vi.fn().mockReturnValue("resp_123"), - } as any - - task.apiConversationHistory = [] - - // Simulate a native AI SDK response message (has providerOptions on reasoning part) - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [ - { - type: "reasoning", - text: "Let me think about this...", - providerOptions: { - anthropic: { signature: "sig_abc123" }, - }, - }, - { type: "text", text: "Here is my response." }, - ], - }) - - expect(task.apiConversationHistory).toHaveLength(1) - const stored = task.apiConversationHistory[0] as any - - expect(stored.role).toBe("assistant") - expect(stored.id).toBe("resp_123") - // Content preserved exactly as-is (no manual block injection) - expect(stored.content).toEqual([ - { - type: "reasoning", - text: "Let me think about this...", - providerOptions: { - anthropic: { signature: "sig_abc123" }, - }, - }, - { type: "text", text: "Here is my response." }, - ]) - }) - - it("should store messages without providerOptions via fallback path", async () => { - const task = new Task({ - provider: mockProvider as ClineProvider, - apiConfiguration: mockApiConfiguration, - task: "Test task", - startTask: false, - }) - - // Avoid disk writes in this test - ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) - - task.api = { - getResponseId: vi.fn().mockReturnValue(undefined), - getEncryptedContent: vi.fn().mockReturnValue(undefined), - } as any - - task.apiConversationHistory = [] - - // Non-AI-SDK message (no providerOptions on content parts) - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: "Here is my response." }], - }) - - expect(task.apiConversationHistory).toHaveLength(1) - const stored = task.apiConversationHistory[0] as any - - expect(stored.role).toBe("assistant") - expect(stored.content).toEqual([{ type: "text", text: "Here is my response." }]) - }) - - it("should handle empty reasoning message gracefully when preserveReasoning is true", async () => { - // Create a task instance - const task = new Task({ - provider: mockProvider as ClineProvider, - apiConfiguration: mockApiConfiguration, - task: "Test task", - startTask: false, - }) - - // Mock the API to return a model with preserveReasoning enabled - const mockModelInfo: ModelInfo = { - contextWindow: 16000, - supportsPromptCache: true, - preserveReasoning: true, - } - - task.api = { - getModel: vi.fn().mockReturnValue({ - id: "test-model", - info: mockModelInfo, - }), - } as any - - // Mock the API conversation history - task.apiConversationHistory = [] - - const assistantMessage = "Here is my response." - - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: assistantMessage }], - }) - - // Verify no reasoning blocks were added when no reasoning is present - expect((task.apiConversationHistory[0] as any).content).toEqual([ - { type: "text", text: "Here is my response." }, - ]) - }) - - it("should embed encrypted reasoning as first assistant content block", async () => { - const task = new Task({ - provider: mockProvider as ClineProvider, - apiConfiguration: mockApiConfiguration, - task: "Test task", - startTask: false, - }) - - // Avoid disk writes in this test - ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) - - // Mock API handler to provide encrypted reasoning data and response id - task.api = { - getEncryptedContent: vi.fn().mockReturnValue({ - encrypted_content: "encrypted_payload", - id: "rs_test", - }), - getResponseId: vi.fn().mockReturnValue("resp_test"), - } as any - - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [{ type: "text", text: "Here is my response." }], - }) - - expect(task.apiConversationHistory).toHaveLength(1) - const stored = task.apiConversationHistory[0] as any - - expect(stored.role).toBe("assistant") - expect(Array.isArray(stored.content)).toBe(true) - expect(stored.id).toBe("resp_test") - - const [reasoningBlock, textBlock] = stored.content - - expect(reasoningBlock).toMatchObject({ - type: "reasoning", - encrypted_content: "encrypted_payload", - id: "rs_test", - }) - - expect(textBlock).toMatchObject({ - type: "text", - text: "Here is my response.", - }) - }) - - it("should store native format with redacted thinking in providerOptions", async () => { - const task = new Task({ - provider: mockProvider as ClineProvider, - apiConfiguration: mockApiConfiguration, - task: "Test task", - startTask: false, - }) - - // Avoid disk writes in this test - ;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined) - - task.api = { - getResponseId: vi.fn().mockReturnValue("resp_456"), - } as any - - task.apiConversationHistory = [] - - // Simulate native format with redacted thinking (as AI SDK provides it) - await (task as any).addToApiConversationHistory({ - role: "assistant", - content: [ - { - type: "reasoning", - text: "Visible reasoning...", - providerOptions: { - anthropic: { signature: "sig_visible" }, - }, - }, - { - type: "reasoning", - text: "", - providerOptions: { - anthropic: { redactedData: "redacted_payload_abc" }, - }, - }, - { type: "text", text: "My answer." }, - ], - }) - - expect(task.apiConversationHistory).toHaveLength(1) - const stored = task.apiConversationHistory[0] as any - - // All content preserved as-is including redacted reasoning - expect(stored.content).toHaveLength(3) - expect(stored.content[0].providerOptions.anthropic.signature).toBe("sig_visible") - expect(stored.content[1].providerOptions.anthropic.redactedData).toBe("redacted_payload_abc") - }) -}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts index 2f11281d2be..f81c71d6e9e 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts @@ -40,7 +40,7 @@ vi.mock("../checkpointRestoreHandler", () => ({ import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" import type { ClineMessage } from "@roo-code/types" -import type { ApiMessage } from "../../task-persistence/apiMessages" +import type { LegacyApiMessage } from "../../task-persistence/apiMessages" import { MessageManager } from "../../message-manager" describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { @@ -54,7 +54,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { mockCurrentTask = { taskId: "test-task-id", clineMessages: [] as ClineMessage[], - apiConversationHistory: [] as ApiMessage[], + apiConversationHistory: [] as LegacyApiMessage[], overwriteClineMessages: vi.fn(), overwriteApiConversationHistory: vi.fn(), handleWebviewAskResponse: vi.fn(), @@ -126,7 +126,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { }, ], }, - ] as ApiMessage[] + ] as LegacyApiMessage[] // Trigger edit confirmation await webviewMessageHandler(mockClineProvider, { @@ -184,7 +184,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { role: "assistant", content: [{ type: "text", text: "Response" }], }, - ] as ApiMessage[] + ] as LegacyApiMessage[] await webviewMessageHandler(mockClineProvider, { type: "editMessageConfirm", @@ -244,7 +244,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { role: "assistant", content: [{ type: "text", text: "Response" }], }, - ] as ApiMessage[] + ] as LegacyApiMessage[] await webviewMessageHandler(mockClineProvider, { type: "editMessageConfirm", @@ -282,7 +282,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { role: "assistant", content: [{ type: "text", text: "Old message 2" }], }, - ] as ApiMessage[] + ] as LegacyApiMessage[] await webviewMessageHandler(mockClineProvider, { type: "editMessageConfirm", @@ -378,7 +378,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { }, ], }, - ] as ApiMessage[] + ] as LegacyApiMessage[] // Edit the first user message await webviewMessageHandler(mockClineProvider, { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 041cc729835..0d6288b08c6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -25,7 +25,7 @@ import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" import { TelemetryService } from "@roo-code/telemetry" -import { type ApiMessage } from "../task-persistence/apiMessages" +import { type LegacyApiMessage } from "../task-persistence/apiMessages" import { saveTaskMessages } from "../task-persistence" import { ClineProvider } from "./ClineProvider" @@ -131,11 +131,11 @@ export const webviewMessageHandler = async ( // Find all matching API messages by timestamp const allApiMatches = currentCline.apiConversationHistory - .map((msg: ApiMessage, idx: number) => ({ msg, idx })) - .filter(({ msg }: { msg: ApiMessage }) => msg.ts === messageTs) + .map((msg: LegacyApiMessage, idx: number) => ({ msg, idx })) + .filter(({ msg }: { msg: LegacyApiMessage }) => msg.ts === messageTs) // Prefer non-summary message if multiple matches exist (handles timestamp collision after condense) - const preferred = allApiMatches.find(({ msg }: { msg: ApiMessage }) => !msg.isSummary) || allApiMatches[0] + const preferred = allApiMatches.find(({ msg }: { msg: LegacyApiMessage }) => !msg.isSummary) || allApiMatches[0] const apiConversationHistoryIndex = preferred?.idx ?? -1 return { messageIndex, apiConversationHistoryIndex } @@ -148,7 +148,7 @@ export const webviewMessageHandler = async ( const findFirstApiIndexAtOrAfter = (ts: number, currentCline: any) => { if (typeof ts !== "number") return -1 return currentCline.apiConversationHistory.findIndex( - (msg: ApiMessage) => typeof msg?.ts === "number" && (msg.ts as number) >= ts, + (msg: LegacyApiMessage) => typeof msg?.ts === "number" && (msg.ts as number) >= ts, ) }