From 434a0c9fd09b680eef18e020bdd8d182d7e22ca1 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 9 Feb 2026 14:36:19 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20complete=20AI=20SDK=20migration=20?= =?UTF-8?q?=E2=80=94=20all=20providers=20+=20neutral=20message=20model=20+?= =?UTF-8?q?=20cache=20unification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all 26 providers to AI SDK, replace Anthropic-shaped message types with AI SDK types, eliminate legacy transforms, and unify cache control. Provider Migrations: - 8 providers → OpenAICompatibleHandler (Archetype 1) - 6 providers → dedicated @ai-sdk/* packages (Archetype 2/3) - 3 OpenAI providers → @ai-sdk/openai (Chat Completions + Responses API) - 3 providers → @openrouter/ai-sdk-provider, @ai-sdk/anthropic, @ai-sdk/google-vertex/anthropic - 1 provider → custom LanguageModelV3 adapter (VS Code LM) Type System: - Replace Anthropic.MessageParam with AI SDK types (ToolCallPart, ToolResultPart, ReasoningPart, etc.) - tool_use → tool-call, tool_result → tool-result, thinking → reasoning - id/name/input → toolCallId/toolName/input, tool_use_id → toolCallId - Add migrateApiMessages() for backward-compatible api_conversation_history.json reading - Define RooContentBlock, RooMessageParam, RooMessageMetadata, RedactedReasoningPart Dead Code Removed: - Legacy base classes: BaseOpenAiCompatibleProvider, RouterProvider - Format transforms: r1-format, mistral-format, openai-format, minimax-format, vscode-lm-format - Per-provider caching: caching/anthropic, caching/gemini, caching/vertex, caching/vercel-ai-gateway - anthropic-filter.ts New Dependencies: @ai-sdk/openai, @ai-sdk/anthropic, @ai-sdk/provider New Shared Utility: src/api/transform/caching.ts (unified cache control) --- pnpm-lock.yaml | 4 + .../history-resume-delegation.spec.ts | 17 +- src/api/index.ts | 6 +- .../__tests__/anthropic-vertex.spec.ts | 1012 ++++++----- src/api/providers/__tests__/anthropic.spec.ts | 1089 +++++++----- ...openai-compatible-provider-timeout.spec.ts | 119 -- .../base-openai-compatible-provider.spec.ts | 548 ------ .../providers/__tests__/base-provider.spec.ts | 5 +- src/api/providers/__tests__/baseten.spec.ts | 9 +- .../__tests__/bedrock-error-handling.spec.ts | 1 - src/api/providers/__tests__/bedrock.spec.ts | 58 +- src/api/providers/__tests__/deepseek.spec.ts | 9 +- src/api/providers/__tests__/fireworks.spec.ts | 7 +- src/api/providers/__tests__/gemini.spec.ts | 7 +- src/api/providers/__tests__/lite-llm.spec.ts | 1185 +++++++------ .../__tests__/lm-studio-timeout.spec.ts | 92 +- .../__tests__/lmstudio-native-tools.spec.ts | 414 ++--- src/api/providers/__tests__/lmstudio.spec.ts | 302 +++- src/api/providers/__tests__/minimax.spec.ts | 517 +++--- src/api/providers/__tests__/mistral.spec.ts | 7 +- src/api/providers/__tests__/moonshot.spec.ts | 7 +- .../providers/__tests__/native-ollama.spec.ts | 779 ++++----- .../openai-codex-native-tool-calls.spec.ts | 104 +- .../__tests__/openai-native-reasoning.spec.ts | 75 +- .../__tests__/openai-native-tools.spec.ts | 321 ++-- .../__tests__/openai-native-usage.spec.ts | 734 ++++++-- .../providers/__tests__/openai-native.spec.ts | 1530 ++++++++++------- .../__tests__/openai-timeout.spec.ts | 187 +- .../__tests__/openai-usage-tracking.spec.ts | 218 +-- src/api/providers/__tests__/openai.spec.ts | 1246 ++++++-------- .../providers/__tests__/openrouter.spec.ts | 999 +++++++---- .../__tests__/qwen-code-native-tools.spec.ts | 627 +++---- src/api/providers/__tests__/requesty.spec.ts | 579 +++---- src/api/providers/__tests__/roo.spec.ts | 970 +++++------ src/api/providers/__tests__/sambanova.spec.ts | 9 +- .../__tests__/vercel-ai-gateway.spec.ts | 794 ++++----- src/api/providers/__tests__/vertex.spec.ts | 5 +- src/api/providers/__tests__/vscode-lm.spec.ts | 188 +- src/api/providers/__tests__/xai.spec.ts | 7 +- src/api/providers/__tests__/zai.spec.ts | 5 +- src/api/providers/anthropic-vertex.ts | 463 ++--- src/api/providers/anthropic.ts | 463 ++--- .../base-openai-compatible-provider.ts | 260 --- src/api/providers/base-provider.ts | 7 +- src/api/providers/baseten.ts | 4 +- src/api/providers/bedrock.ts | 8 +- src/api/providers/deepseek.ts | 4 +- src/api/providers/fake-ai.ts | 11 +- src/api/providers/fireworks.ts | 4 +- src/api/providers/gemini.ts | 8 +- src/api/providers/lite-llm.ts | 490 +++--- src/api/providers/lm-studio.ts | 264 +-- src/api/providers/minimax.ts | 318 +--- src/api/providers/mistral.ts | 4 +- src/api/providers/native-ollama.ts | 432 +---- src/api/providers/openai-codex.ts | 1247 +++----------- src/api/providers/openai-compatible.ts | 4 +- src/api/providers/openai-native.ts | 364 ++-- src/api/providers/openai.ts | 599 ++----- src/api/providers/openrouter.ts | 724 +++----- src/api/providers/qwen-code.ts | 240 +-- src/api/providers/requesty.ts | 269 ++- src/api/providers/roo.ts | 509 +++--- src/api/providers/router-provider.ts | 87 - src/api/providers/sambanova.ts | 4 +- src/api/providers/vercel-ai-gateway.ts | 170 +- src/api/providers/vertex.ts | 8 +- src/api/providers/vscode-lm.ts | 801 +++++---- src/api/providers/xai.ts | 4 +- src/api/providers/zai.ts | 4 +- src/api/transform/__tests__/ai-sdk.spec.ts | 126 +- .../__tests__/anthropic-filter.spec.ts | 144 -- src/api/transform/__tests__/caching.spec.ts | 276 +++ .../__tests__/image-cleaning.spec.ts | 49 +- .../__tests__/minimax-format.spec.ts | 336 ---- .../__tests__/mistral-format.spec.ts | 341 ---- .../transform/__tests__/openai-format.spec.ts | 1305 -------------- src/api/transform/__tests__/r1-format.spec.ts | 619 ------- .../__tests__/vscode-lm-format.spec.ts | 348 ---- src/api/transform/ai-sdk.ts | 47 +- src/api/transform/anthropic-filter.ts | 52 - src/api/transform/caching.ts | 151 ++ .../caching/__tests__/anthropic.spec.ts | 181 -- .../caching/__tests__/gemini.spec.ts | 266 --- .../__tests__/vercel-ai-gateway.spec.ts | 233 --- .../caching/__tests__/vertex.spec.ts | 178 -- src/api/transform/caching/anthropic.ts | 41 - src/api/transform/caching/gemini.ts | 47 - .../transform/caching/vercel-ai-gateway.ts | 30 - src/api/transform/caching/vertex.ts | 49 - src/api/transform/minimax-format.ts | 118 -- src/api/transform/mistral-format.ts | 182 -- src/api/transform/openai-format.ts | 509 ------ src/api/transform/r1-format.ts | 244 --- src/api/transform/reasoning.ts | 3 +- src/api/transform/vscode-lm-format.ts | 196 --- .../assistant-message/NativeToolCallParser.ts | 78 +- .../__tests__/NativeToolCallParser.spec.ts | 44 +- ...resentAssistantMessage-custom-tool.spec.ts | 56 +- .../presentAssistantMessage-images.spec.ts | 122 +- ...esentAssistantMessage-unknown-tool.spec.ts | 75 +- .../presentAssistantMessage.ts | 262 ++- src/core/condense/__tests__/condense.spec.ts | 6 +- .../__tests__/foldedFileContext.spec.ts | 4 +- src/core/condense/__tests__/index.spec.ts | 375 ++-- src/core/condense/index.ts | 84 +- .../__tests__/context-management.spec.ts | 27 +- src/core/context-management/index.ts | 8 +- .../diff/strategies/multi-search-replace.ts | 2 +- .../processUserContentMentions.spec.ts | 200 ++- .../mentions/processUserContentMentions.ts | 32 +- src/core/prompts/responses.ts | 20 +- .../native-tools/__tests__/converters.spec.ts | 1 - .../prompts/tools/native-tools/converters.ts | 30 +- src/core/task-persistence/apiMessages.ts | 266 ++- src/core/task-persistence/index.ts | 20 +- src/core/task/Task.ts | 231 ++- src/core/task/__tests__/Task.spec.ts | 158 +- .../flushPendingToolResultsToHistory.spec.ts | 66 +- .../task/__tests__/new-task-isolation.spec.ts | 257 +-- .../task/__tests__/task-tool-history.spec.ts | 60 +- .../__tests__/validateToolResultIds.spec.ts | 597 ++++--- src/core/task/mergeConsecutiveApiMessages.ts | 8 +- src/core/task/validateToolResultIds.ts | 77 +- src/core/tools/ApplyDiffTool.ts | 28 +- src/core/tools/ApplyPatchTool.ts | 4 +- src/core/tools/AskFollowupQuestionTool.ts | 4 +- src/core/tools/AttemptCompletionTool.ts | 6 +- src/core/tools/BaseTool.ts | 6 +- src/core/tools/BrowserActionTool.ts | 16 +- src/core/tools/CodebaseSearchTool.ts | 6 +- src/core/tools/EditFileTool.ts | 6 +- src/core/tools/ExecuteCommandTool.ts | 4 +- src/core/tools/GenerateImageTool.ts | 2 +- src/core/tools/ListFilesTool.ts | 6 +- src/core/tools/NewTaskTool.ts | 8 +- src/core/tools/ReadFileTool.ts | 2 +- src/core/tools/RunSlashCommandTool.ts | 6 +- src/core/tools/SearchAndReplaceTool.ts | 6 +- src/core/tools/SearchFilesTool.ts | 8 +- src/core/tools/SearchReplaceTool.ts | 6 +- src/core/tools/SkillTool.ts | 6 +- src/core/tools/SwitchModeTool.ts | 6 +- src/core/tools/ToolRepetitionDetector.ts | 10 +- src/core/tools/UpdateTodoListTool.ts | 4 +- src/core/tools/UseMcpToolTool.ts | 4 +- src/core/tools/WriteToFileTool.ts | 6 +- .../__tests__/ToolRepetitionDetector.spec.ts | 109 +- .../__tests__/askFollowupQuestionTool.spec.ts | 55 +- .../__tests__/attemptCompletionTool.spec.ts | 77 +- src/core/tools/__tests__/editFileTool.spec.ts | 29 +- .../__tests__/executeCommandTool.spec.ts | 21 +- .../tools/__tests__/generateImageTool.test.ts | 81 +- src/core/tools/__tests__/newTaskTool.spec.ts | 152 +- .../__tests__/runSlashCommandTool.spec.ts | 117 +- .../__tests__/searchAndReplaceTool.spec.ts | 20 +- .../tools/__tests__/searchReplaceTool.spec.ts | 20 +- src/core/tools/__tests__/skillTool.spec.ts | 99 +- .../tools/__tests__/useMcpToolTool.spec.ts | 124 +- .../tools/__tests__/writeToFileTool.spec.ts | 11 +- src/core/tools/accessMcpResourceTool.ts | 6 +- src/core/webview/ClineProvider.ts | 28 +- .../webview/__tests__/ClineProvider.spec.ts | 6 +- .../misc/__tests__/export-markdown.spec.ts | 49 +- src/integrations/misc/export-markdown.ts | 47 +- src/integrations/misc/line-counter.ts | 4 +- src/package.json | 1 + src/shared/tools.ts | 65 +- src/utils/__tests__/tiktoken.spec.ts | 145 +- src/utils/countTokens.ts | 5 +- src/utils/tiktoken.ts | 47 +- src/workers/countTokens.ts | 4 +- 172 files changed, 13109 insertions(+), 20176 deletions(-) delete mode 100644 src/api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts delete mode 100644 src/api/providers/__tests__/base-openai-compatible-provider.spec.ts delete mode 100644 src/api/providers/base-openai-compatible-provider.ts delete mode 100644 src/api/providers/router-provider.ts delete mode 100644 src/api/transform/__tests__/anthropic-filter.spec.ts create mode 100644 src/api/transform/__tests__/caching.spec.ts delete mode 100644 src/api/transform/__tests__/minimax-format.spec.ts delete mode 100644 src/api/transform/__tests__/mistral-format.spec.ts delete mode 100644 src/api/transform/__tests__/openai-format.spec.ts delete mode 100644 src/api/transform/__tests__/r1-format.spec.ts delete mode 100644 src/api/transform/__tests__/vscode-lm-format.spec.ts delete mode 100644 src/api/transform/anthropic-filter.ts create mode 100644 src/api/transform/caching.ts delete mode 100644 src/api/transform/caching/__tests__/anthropic.spec.ts delete mode 100644 src/api/transform/caching/__tests__/gemini.spec.ts delete mode 100644 src/api/transform/caching/__tests__/vercel-ai-gateway.spec.ts delete mode 100644 src/api/transform/caching/__tests__/vertex.spec.ts delete mode 100644 src/api/transform/caching/anthropic.ts delete mode 100644 src/api/transform/caching/gemini.ts delete mode 100644 src/api/transform/caching/vercel-ai-gateway.ts delete mode 100644 src/api/transform/caching/vertex.ts delete mode 100644 src/api/transform/minimax-format.ts delete mode 100644 src/api/transform/mistral-format.ts delete mode 100644 src/api/transform/openai-format.ts delete mode 100644 src/api/transform/r1-format.ts delete mode 100644 src/api/transform/vscode-lm-format.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 304c654ef75..560dffc5eef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -776,6 +776,9 @@ importers: '@ai-sdk/openai': specifier: ^3.0.26 version: 3.0.26(zod@3.25.76) + '@ai-sdk/provider': + specifier: ^3.0.8 + version: 3.0.8 '@ai-sdk/xai': specifier: ^3.0.48 version: 3.0.48(zod@3.25.76) @@ -6892,6 +6895,7 @@ packages: glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true global-agent@3.0.0: diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index a78c41b7c06..a05ad44b95c 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -238,9 +238,9 @@ describe("History resume delegation - parent metadata transitions", () => { role: "assistant", content: [ { - type: "tool_use", - name: "new_task", - id: "toolu_abc123", + type: "tool-call", + toolName: "new_task", + toolCallId: "toolu_abc123", input: { mode: "code", message: "Do something" }, }, ], @@ -265,9 +265,8 @@ describe("History resume delegation - parent metadata transitions", () => { role: "user", content: expect.arrayContaining([ expect.objectContaining({ - type: "tool_result", - tool_use_id: "toolu_abc123", - content: expect.stringContaining("Subtask c-tool completed"), + type: "tool-result", + toolCallId: "toolu_abc123", }), ]), }), @@ -281,11 +280,11 @@ describe("History resume delegation - parent metadata transitions", () => { const apiCall = vi.mocked(saveApiMessages).mock.calls[0][0] expect(apiCall.messages).toHaveLength(3) - // Verify the injected message is a user message with tool_result type + // Verify the injected message is a user message with tool-result type const injectedMsg = apiCall.messages[2] expect(injectedMsg.role).toBe("user") - expect((injectedMsg.content[0] as any).type).toBe("tool_result") - expect((injectedMsg.content[0] as any).tool_use_id).toBe("toolu_abc123") + expect((injectedMsg.content[0] as any).type).toBe("tool-result") + expect((injectedMsg.content[0] as any).toolCallId).toBe("toolu_abc123") }) it("reopenParentFromDelegation injects plain text when no new_task tool_use exists in API history", async () => { diff --git a/src/api/index.ts b/src/api/index.ts index 53aff562cf1..b39975058fe 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,8 +1,8 @@ -import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { isRetiredProvider, type ProviderSettings, type ModelInfo } from "@roo-code/types" +import type { NeutralMessageParam, NeutralContentBlock } from "../core/task-persistence" import { ApiStream } from "./transform/stream" import { @@ -91,7 +91,7 @@ export interface ApiHandlerCreateMessageMetadata { export interface ApiHandler { createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream @@ -105,7 +105,7 @@ export interface ApiHandler { * @param content The content to count tokens for * @returns A promise resolving to the token count */ - countTokens(content: Array): Promise + countTokens(content: NeutralContentBlock[]): Promise /** * Indicates whether this provider uses the Vercel AI SDK for streaming. diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 3341a0f584b..ad2a13b4607 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -1,232 +1,125 @@ -// npx vitest run src/api/providers/__tests__/anthropic-vertex.spec.ts +// npx vitest run api/providers/__tests__/anthropic-vertex.spec.ts -import { AnthropicVertexHandler } from "../anthropic-vertex" -import { ApiHandlerOptions } from "../../../shared/api" +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText, mockCreateVertexAnthropic } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockCreateVertexAnthropic: vi.fn(), +})) -import { VERTEX_1M_CONTEXT_MODEL_IDS } from "@roo-code/types" +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) -import { ApiStreamChunk } from "../../transform/stream" +vi.mock("@ai-sdk/google-vertex/anthropic", () => ({ + createVertexAnthropic: mockCreateVertexAnthropic.mockImplementation(() => { + const modelFn = (id: string) => ({ modelId: id, provider: "vertex-anthropic" }) + modelFn.languageModel = (id: string) => ({ modelId: id, provider: "vertex-anthropic" }) + return modelFn + }), +})) + +const mockCaptureException = vi.fn() -// Mock TelemetryService -vitest.mock("@roo-code/telemetry", () => ({ +vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { instance: { - captureException: vitest.fn(), + captureException: (...args: unknown[]) => mockCaptureException(...args), }, }, })) -// Mock the AI SDK -const mockStreamText = vitest.fn() -const mockGenerateText = vitest.fn() - -vitest.mock("ai", () => ({ - streamText: (...args: any[]) => mockStreamText(...args), - generateText: (...args: any[]) => mockGenerateText(...args), - tool: vitest.fn(), - jsonSchema: vitest.fn(), - ToolSet: {}, -})) - -// Mock the @ai-sdk/google-vertex/anthropic provider -const mockCreateVertexAnthropic = vitest.fn() +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" +import { VERTEX_1M_CONTEXT_MODEL_IDS } from "@roo-code/types" -vitest.mock("@ai-sdk/google-vertex/anthropic", () => ({ - createVertexAnthropic: (...args: any[]) => mockCreateVertexAnthropic(...args), -})) +import type { ApiHandlerOptions } from "../../../shared/api" +import type { ApiStreamChunk } from "../../transform/stream" +import { AnthropicVertexHandler } from "../anthropic-vertex" -// Mock ai-sdk transform utilities -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") { - yield { type: "text", text: part.text } - } else if (part.type === "reasoning-delta") { - yield { type: "reasoning", text: part.text } - } else if (part.type === "tool-input-start") { - yield { type: "tool_call_start", id: part.id, name: part.toolName } - } else if (part.type === "tool-input-delta") { - yield { type: "tool_call_delta", id: part.id, delta: part.delta } - } else if (part.type === "tool-input-end") { - yield { type: "tool_call_end", id: part.id } +// Helper: create a standard mock fullStream async generator +function createMockFullStream(parts: Array>) { + return async function* () { + for (const part of parts) { + yield part } - }), - mapToolChoice: vitest.fn().mockReturnValue(undefined), - handleAiSdkError: vitest.fn().mockImplementation((error: any) => error), -})) - -// Import mocked modules -import { convertToAiSdkMessages, convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" -import { Anthropic } from "@anthropic-ai/sdk" - -// Helper: create a mock provider function -function createMockProviderFn() { - const providerFn = vitest.fn().mockReturnValue("mock-model") - return providerFn + } } -// Helper: create a mock streamText result -function createMockStreamResult( - parts: any[], - usage?: { inputTokens: number; outputTokens: number }, - providerMetadata?: Record, +// Helper: set up mock return value for streamText +function mockStreamTextReturn( + parts: Array>, + usage = { inputTokens: 10, outputTokens: 5 }, + providerMetadata: Record = {}, ) { - return { - fullStream: (async function* () { - for (const part of parts) { - yield part - } - })(), - usage: Promise.resolve(usage ?? { inputTokens: 0, outputTokens: 0 }), - providerMetadata: Promise.resolve(providerMetadata ?? {}), + mockStreamText.mockReturnValue({ + fullStream: createMockFullStream(parts)(), + usage: Promise.resolve(usage), + providerMetadata: Promise.resolve(providerMetadata), + }) +} + +// Test subclass to expose protected methods +class TestAnthropicVertexHandler extends AnthropicVertexHandler { + public testProcessUsageMetrics( + usage: { inputTokens?: number; outputTokens?: number }, + providerMetadata?: Record>, + modelInfo?: Record, + ) { + return this.processUsageMetrics(usage, providerMetadata, modelInfo as any) } } describe("AnthropicVertexHandler", () => { - let handler: AnthropicVertexHandler - let mockProviderFn: ReturnType + const mockOptions: ApiHandlerOptions = { + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + } - beforeEach(() => { - mockProviderFn = createMockProviderFn() - mockCreateVertexAnthropic.mockReturnValue(mockProviderFn) - vitest.clearAllMocks() - }) + beforeEach(() => vi.clearAllMocks()) describe("constructor", () => { - it("should initialize with provided config for Claude", () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( - expect.objectContaining({ - project: "test-project", - location: "us-central1", - }), - ) - }) - - it("should use JSON credentials when provided", () => { - const credentials = { client_email: "test@test.com", private_key: "test-key" } - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - vertexJsonCredentials: JSON.stringify(credentials), - }) - - expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( - expect.objectContaining({ - googleAuthOptions: { credentials }, - }), - ) + it("should initialize with provided config", () => { + const handler = new AnthropicVertexHandler(mockOptions) + expect(handler).toBeInstanceOf(AnthropicVertexHandler) + expect(handler.getModel().id).toBe("claude-3-5-sonnet-v2@20241022") }) + }) - it("should use key file when provided", () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - vertexKeyFile: "/path/to/key.json", - }) - - expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( - expect.objectContaining({ - googleAuthOptions: { keyFile: "/path/to/key.json" }, - }), - ) - }) - - it("should use default values when project/region not provided", () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - }) - - expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( - expect.objectContaining({ - project: "not-provided", - location: "us-east5", - }), - ) - }) - - it("should include anthropic-beta header when 1M context is enabled", () => { - handler = new AnthropicVertexHandler({ - apiModelId: VERTEX_1M_CONTEXT_MODEL_IDS[0], - vertexProjectId: "test-project", - vertexRegion: "us-central1", - vertex1MContext: true, - }) - - expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - "anthropic-beta": "context-1m-2025-08-07", - }), - }), - ) - }) - - it("should not include anthropic-beta header when 1M context is disabled", () => { - handler = new AnthropicVertexHandler({ - apiModelId: VERTEX_1M_CONTEXT_MODEL_IDS[0], - vertexProjectId: "test-project", - vertexRegion: "us-central1", - vertex1MContext: false, - }) - - const calledHeaders = mockCreateVertexAnthropic.mock.calls[0][0].headers - expect(calledHeaders["anthropic-beta"]).toBeUndefined() + describe("isAiSdkProvider", () => { + it("should return true", () => { + const handler = new AnthropicVertexHandler(mockOptions) + expect(handler.isAiSdkProvider()).toBe(true) }) }) describe("createMessage", () => { - const mockMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: "Hi there!", - }, - ] - const systemPrompt = "You are a helpful assistant" + const messages: NeutralMessageParam[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + ] - beforeEach(() => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - }) - - it("should handle streaming responses correctly for Claude", async () => { - const streamParts = [ - { type: "text-delta", text: "Hello" }, - { type: "text-delta", text: " world!" }, - ] - - mockStreamText.mockReturnValue(createMockStreamResult(streamParts, { inputTokens: 10, outputTokens: 5 })) + it("should handle streaming text responses", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "Hello world!" }]) - const stream = handler.createMessage(systemPrompt, mockMessages) + const stream = handler.createMessage(systemPrompt, messages) const chunks: ApiStreamChunk[] = [] - for await (const chunk of stream) { chunks.push(chunk) } - // Text chunks from processAiSdkStreamPart + final usage const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks).toHaveLength(2) - expect(textChunks[0]).toEqual({ type: "text", text: "Hello" }) - expect(textChunks[1]).toEqual({ type: "text", text: " world!" }) + expect(textChunks).toHaveLength(1) + expect(textChunks[0]).toEqual({ type: "text", text: "Hello world!" }) - // Usage chunk at the end + // Verify usage chunk const usageChunks = chunks.filter((c) => c.type === "usage") expect(usageChunks).toHaveLength(1) expect(usageChunks[0]).toMatchObject({ @@ -234,256 +127,294 @@ describe("AnthropicVertexHandler", () => { inputTokens: 10, outputTokens: 5, }) - - // Verify streamText was called with correct params - expect(mockStreamText).toHaveBeenCalledWith( - expect.objectContaining({ - model: "mock-model", - system: systemPrompt, - }), - ) - }) - - it("should call convertToAiSdkMessages with the messages", async () => { - mockStreamText.mockReturnValue(createMockStreamResult([])) - - const stream = handler.createMessage(systemPrompt, mockMessages) - for await (const _chunk of stream) { - // consume - } - - expect(convertToAiSdkMessages).toHaveBeenCalledWith(mockMessages) }) - it("should pass tools through AI SDK conversion pipeline", async () => { - mockStreamText.mockReturnValue(createMockStreamResult([])) - - const mockTools = [ - { - type: "function" as const, - function: { - name: "get_weather", - description: "Get the current weather", - parameters: { - type: "object", - properties: { location: { type: "string" } }, - required: ["location"], - }, - }, - }, - ] - - const stream = handler.createMessage(systemPrompt, mockMessages, { - taskId: "test-task", - tools: mockTools, - }) + it("should handle multiple text deltas", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn([ + { type: "text-delta", text: "First line" }, + { type: "text-delta", text: " Second line" }, + ]) - for await (const _chunk of stream) { - // consume + const stream = handler.createMessage(systemPrompt, messages) + const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - expect(convertToolsForAiSdk).toHaveBeenCalled() + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(2) + expect(textChunks[0]).toEqual({ type: "text", text: "First line" }) + expect(textChunks[1]).toEqual({ type: "text", text: " Second line" }) }) - it("should handle API errors for Claude", async () => { - const mockError = new Error("Vertex API error") + it("should handle API errors and capture telemetry", async () => { + const handler = new AnthropicVertexHandler(mockOptions) mockStreamText.mockReturnValue({ fullStream: (async function* () { - yield { type: "text-delta", text: "" } - throw mockError + yield { type: "text-delta" as const, text: "" } + throw new Error("Vertex API error") })(), usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), providerMetadata: Promise.resolve({}), }) - const stream = handler.createMessage(systemPrompt, mockMessages) + const stream = handler.createMessage(systemPrompt, messages) await expect(async () => { for await (const _chunk of stream) { - // Should throw before yielding meaningful chunks + // consume } - }).rejects.toThrow() - }) - - it("should handle cache-related usage metrics from providerMetadata", async () => { - mockStreamText.mockReturnValue( - createMockStreamResult( - [{ type: "text-delta", text: "Hello" }], - { inputTokens: 10, outputTokens: 5 }, - { - anthropic: { - cacheCreationInputTokens: 3, - cacheReadInputTokens: 2, - }, + }).rejects.toThrow("Vertex API error") + + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Vertex API error", + provider: "AnthropicVertex", + modelId: mockOptions.apiModelId, + operation: "createMessage", + }), + ) + }) + + it("should apply cache control to system prompt and user messages for prompt-caching models", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const multiMessages: NeutralMessageParam[] = [ + { role: "user", content: "First message" }, + { role: "assistant", content: "Response" }, + { role: "user", content: "Second message" }, + ] + + const stream = handler.createMessage(systemPrompt, multiMessages) + for await (const _chunk of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + + // Verify system prompt has cache control + expect(callArgs.system).toEqual( + expect.objectContaining({ + role: "system", + content: systemPrompt, + providerOptions: expect.objectContaining({ + anthropic: { cacheControl: { type: "ephemeral" } }, + }), + }), + ) + + // Verify user messages have cache breakpoints applied + const aiSdkMessages = callArgs.messages + const userMessages = aiSdkMessages.filter((m: any) => m.role === "user") + + for (const msg of userMessages) { + const content = Array.isArray(msg.content) ? msg.content : [msg.content] + const lastTextPart = [...content].reverse().find((p: any) => typeof p === "object" && p.type === "text") + if (lastTextPart) { + expect(lastTextPart.providerOptions).toEqual({ + anthropic: { cacheControl: { type: "ephemeral" } }, + }) + } + } + }) + + it("should include Anthropic cache metrics from providerMetadata", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn( + [{ type: "text-delta", text: "response" }], + { inputTokens: 100, outputTokens: 50 }, + { + anthropic: { + cacheCreationInputTokens: 20, + cacheReadInputTokens: 10, }, - ), + }, ) - const stream = handler.createMessage(systemPrompt, mockMessages) + const stream = handler.createMessage(systemPrompt, messages) const chunks: ApiStreamChunk[] = [] - for await (const chunk of stream) { chunks.push(chunk) } - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0]).toMatchObject({ + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk).toMatchObject({ type: "usage", - inputTokens: 10, - outputTokens: 5, - cacheWriteTokens: 3, - cacheReadTokens: 2, + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 20, + cacheReadTokens: 10, }) }) + }) - it("should handle reasoning/thinking stream events", async () => { - const streamParts = [ - { type: "reasoning-delta", text: "Let me think about this..." }, - { type: "reasoning-delta", text: " I need to consider all options." }, - { type: "text-delta", text: "Here's my answer:" }, - ] + describe("thinking functionality", () => { + const systemPrompt = "You are a helpful assistant" + const messages: NeutralMessageParam[] = [{ role: "user", content: "Hello" }] - mockStreamText.mockReturnValue(createMockStreamResult(streamParts)) + it("should handle reasoning stream parts", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn([ + { type: "reasoning", text: "Let me think about this..." }, + { type: "text-delta", text: "Here's my answer:" }, + ]) - const stream = handler.createMessage(systemPrompt, mockMessages) + const stream = handler.createMessage(systemPrompt, messages) const chunks: ApiStreamChunk[] = [] - for await (const chunk of stream) { chunks.push(chunk) } const reasoningChunks = chunks.filter((c) => c.type === "reasoning") - expect(reasoningChunks).toHaveLength(2) - expect(reasoningChunks[0].text).toBe("Let me think about this...") - expect(reasoningChunks[1].text).toBe(" I need to consider all options.") + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0]).toEqual({ type: "reasoning", text: "Let me think about this..." }) const textChunks = chunks.filter((c) => c.type === "text") expect(textChunks).toHaveLength(1) - expect(textChunks[0].text).toBe("Here's my answer:") + expect(textChunks[0]).toEqual({ type: "text", text: "Here's my answer:" }) }) - it("should capture thought signature from stream events", async () => { - const streamParts = [ - { - type: "reasoning-delta", - text: "thinking...", - providerMetadata: { - anthropic: { signature: "test-signature-abc123" }, - }, - }, - { type: "text-delta", text: "answer" }, - ] - - mockStreamText.mockReturnValue(createMockStreamResult(streamParts)) + it("should handle multiple reasoning parts", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn([ + { type: "reasoning", text: "First thinking block" }, + { type: "reasoning", text: "Second thinking block" }, + { type: "text-delta", text: "Answer" }, + ]) - const stream = handler.createMessage(systemPrompt, mockMessages) - for await (const _chunk of stream) { - // consume + const stream = handler.createMessage(systemPrompt, messages) + const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - expect(handler.getThoughtSignature()).toBe("test-signature-abc123") + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") + expect(reasoningChunks).toHaveLength(2) + expect(reasoningChunks[0]).toEqual({ type: "reasoning", text: "First thinking block" }) + expect(reasoningChunks[1]).toEqual({ type: "reasoning", text: "Second thinking block" }) }) + }) - it("should capture redacted thinking blocks from stream events", async () => { - const streamParts = [ + describe("reasoning block handling", () => { + const systemPrompt = "You are a helpful assistant" + + it("should pass reasoning blocks through convertToAiSdkMessages", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "Response" }]) + + const messagesWithReasoning: NeutralMessageParam[] = [ + { role: "user", content: "Hello" }, { - type: "reasoning-delta", - text: "", - providerMetadata: { - anthropic: { redactedData: "encrypted-redacted-data" }, - }, + role: "assistant", + content: [ + { type: "reasoning" as any, text: "This is internal reasoning" }, + { type: "text", text: "This is the response" }, + ], }, - { type: "text-delta", text: "answer" }, + { role: "user", content: "Continue" }, ] - mockStreamText.mockReturnValue(createMockStreamResult(streamParts)) - - const stream = handler.createMessage(systemPrompt, mockMessages) + const stream = handler.createMessage(systemPrompt, messagesWithReasoning) for await (const _chunk of stream) { // consume } - const redactedBlocks = handler.getRedactedThinkingBlocks() - expect(redactedBlocks).toHaveLength(1) - expect(redactedBlocks![0]).toEqual({ - type: "redacted_thinking", - data: "encrypted-redacted-data", - }) + const callArgs = mockStreamText.mock.calls[0][0] + const aiSdkMessages = callArgs.messages + + // Verify convertToAiSdkMessages processed the messages + expect(aiSdkMessages.length).toBeGreaterThan(0) + + // Check assistant message exists with content + const assistantMessage = aiSdkMessages.find((m: any) => m.role === "assistant") + expect(assistantMessage).toBeDefined() + expect(assistantMessage.content).toBeDefined() }) - it("should configure thinking providerOptions for thinking models", async () => { - const thinkingHandler = new AnthropicVertexHandler({ - apiModelId: "claude-3-7-sonnet@20250219:thinking", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - modelMaxTokens: 16384, - modelMaxThinkingTokens: 4096, - }) + it("should handle messages with only reasoning content", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "Response" }]) - mockStreamText.mockReturnValue(createMockStreamResult([])) + const messagesWithOnlyReasoning: NeutralMessageParam[] = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [{ type: "reasoning" as any, text: "Only reasoning, no actual text" }], + }, + { role: "user", content: "Continue" }, + ] - const stream = thinkingHandler.createMessage(systemPrompt, [{ role: "user", content: "Hello" }]) + const stream = handler.createMessage(systemPrompt, messagesWithOnlyReasoning) for await (const _chunk of stream) { // consume } - expect(mockStreamText).toHaveBeenCalledWith( - expect.objectContaining({ - providerOptions: expect.objectContaining({ - anthropic: expect.objectContaining({ - thinking: { - type: "enabled", - budgetTokens: 4096, - }, - }), - }), - }), - ) + const callArgs = mockStreamText.mock.calls[0][0] + const aiSdkMessages = callArgs.messages + + // The call should succeed and messages should be present + expect(aiSdkMessages.length).toBeGreaterThan(0) }) }) describe("completePrompt", () => { - beforeEach(() => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - }) - - it("should complete prompt successfully for Claude", async () => { - mockGenerateText.mockResolvedValue({ - text: "Test response", - }) + it("should complete prompt successfully", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockGenerateText.mockResolvedValue({ text: "Test response" }) const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") - expect(mockGenerateText).toHaveBeenCalledWith( expect.objectContaining({ - model: "mock-model", prompt: "Test prompt", + temperature: 0, + }), + ) + }) + + it("should handle API errors and capture telemetry", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockGenerateText.mockRejectedValue(new Error("Vertex API error")) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Vertex API error") + + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Vertex API error", + provider: "AnthropicVertex", + modelId: mockOptions.apiModelId, + operation: "completePrompt", }), ) }) - it("should handle API errors for Claude", async () => { - const mockError = new Error("Vertex API error") - mockGenerateText.mockRejectedValue(mockError) + it("should handle empty response", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockGenerateText.mockResolvedValue({ text: "" }) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow() + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("") + }) + + it("should pass model and temperature to generateText", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockGenerateText.mockResolvedValue({ text: "response" }) + + await handler.completePrompt("Test prompt") + + const callArgs = mockGenerateText.mock.calls[0][0] + expect(callArgs.prompt).toBe("Test prompt") + expect(callArgs.temperature).toBe(0) + expect(callArgs.model).toBeDefined() }) }) describe("getModel", () => { - it("should return correct model info for Claude", () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - + it("should return correct model info", () => { + const handler = new AnthropicVertexHandler(mockOptions) const modelInfo = handler.getModel() expect(modelInfo.id).toBe("claude-3-5-sonnet-v2@20241022") expect(modelInfo.info).toBeDefined() @@ -491,10 +422,11 @@ describe("AnthropicVertexHandler", () => { expect(modelInfo.info.contextWindow).toBe(200_000) }) - it("honors custom maxTokens for thinking models", () => { + it("should honor custom maxTokens for thinking models", () => { const handler = new AnthropicVertexHandler({ - apiKey: "test-api-key", apiModelId: "claude-3-7-sonnet@20250219:thinking", + vertexProjectId: "test-project", + vertexRegion: "us-central1", modelMaxTokens: 32_768, modelMaxThinkingTokens: 16_384, }) @@ -505,10 +437,11 @@ describe("AnthropicVertexHandler", () => { expect(result.temperature).toBe(1.0) }) - it("does not honor custom maxTokens for non-thinking models", () => { + it("should not honor custom maxTokens for non-thinking models", () => { const handler = new AnthropicVertexHandler({ - apiKey: "test-api-key", apiModelId: "claude-3-7-sonnet@20250219", + vertexProjectId: "test-project", + vertexRegion: "us-central1", modelMaxTokens: 32_768, modelMaxThinkingTokens: 16_384, }) @@ -519,7 +452,7 @@ describe("AnthropicVertexHandler", () => { expect(result.temperature).toBe(0) }) - it("should enable 1M context for Claude Sonnet 4 when beta flag is set", () => { + it("should enable 1M context for first supported model when beta flag is set", () => { const handler = new AnthropicVertexHandler({ apiModelId: VERTEX_1M_CONTEXT_MODEL_IDS[0], vertexProjectId: "test-project", @@ -534,7 +467,7 @@ describe("AnthropicVertexHandler", () => { expect(model.betas).toContain("context-1m-2025-08-07") }) - it("should enable 1M context for Claude Sonnet 4.5 when beta flag is set", () => { + it("should enable 1M context for second supported model when beta flag is set", () => { const handler = new AnthropicVertexHandler({ apiModelId: VERTEX_1M_CONTEXT_MODEL_IDS[1], vertexProjectId: "test-project", @@ -578,9 +511,52 @@ describe("AnthropicVertexHandler", () => { }) }) + describe("1M context beta header", () => { + const systemPrompt = "You are a helpful assistant" + const messages: NeutralMessageParam[] = [{ role: "user", content: "Hello" }] + + it("should include anthropic-beta header when 1M context is enabled", async () => { + const handler = new AnthropicVertexHandler({ + apiModelId: VERTEX_1M_CONTEXT_MODEL_IDS[0], + vertexProjectId: "test-project", + vertexRegion: "us-central1", + vertex1MContext: true, + }) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.headers).toEqual({ "anthropic-beta": "context-1m-2025-08-07" }) + }) + + it("should not include anthropic-beta header when 1M context is disabled", async () => { + const handler = new AnthropicVertexHandler({ + apiModelId: VERTEX_1M_CONTEXT_MODEL_IDS[0], + vertexProjectId: "test-project", + vertexRegion: "us-central1", + vertex1MContext: false, + }) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.headers).toBeUndefined() + }) + }) + describe("thinking model configuration", () => { it("should configure thinking for models with :thinking suffix", () => { - const thinkingHandler = new AnthropicVertexHandler({ + const handler = new AnthropicVertexHandler({ apiModelId: "claude-3-7-sonnet@20250219:thinking", vertexProjectId: "test-project", vertexRegion: "us-central1", @@ -588,11 +564,10 @@ describe("AnthropicVertexHandler", () => { modelMaxThinkingTokens: 4096, }) - const modelInfo = thinkingHandler.getModel() - + const modelInfo = handler.getModel() expect(modelInfo.id).toBe("claude-3-7-sonnet@20250219") expect(modelInfo.reasoningBudget).toBe(4096) - expect(modelInfo.temperature).toBe(1.0) + expect(modelInfo.temperature).toBe(1.0) // Thinking requires temperature 1.0 }) it("should calculate thinking budget correctly", () => { @@ -604,7 +579,6 @@ describe("AnthropicVertexHandler", () => { modelMaxTokens: 16384, modelMaxThinkingTokens: 5000, }) - expect(handlerWithBudget.getModel().reasoningBudget).toBe(5000) // Test with default thinking budget (80% of max tokens) @@ -614,7 +588,6 @@ describe("AnthropicVertexHandler", () => { vertexRegion: "us-central1", modelMaxTokens: 10000, }) - expect(handlerWithDefaultBudget.getModel().reasoningBudget).toBe(8000) // 80% of 10000 // Test with minimum thinking budget (should be at least 1024) @@ -622,14 +595,13 @@ describe("AnthropicVertexHandler", () => { apiModelId: "claude-3-7-sonnet@20250219:thinking", vertexProjectId: "test-project", vertexRegion: "us-central1", - modelMaxTokens: 1000, // This would result in 800 tokens for thinking, but minimum is 1024 + modelMaxTokens: 1000, }) - expect(handlerWithSmallMaxTokens.getModel().reasoningBudget).toBe(1024) }) - it("should pass thinking configuration to API via providerOptions", async () => { - const thinkingHandler = new AnthropicVertexHandler({ + it("should pass thinking configuration via providerOptions", async () => { + const handler = new AnthropicVertexHandler({ apiModelId: "claude-3-7-sonnet@20250219:thinking", vertexProjectId: "test-project", vertexRegion: "us-central1", @@ -637,87 +609,273 @@ describe("AnthropicVertexHandler", () => { modelMaxThinkingTokens: 4096, }) - mockStreamText.mockReturnValue(createMockStreamResult([])) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - const stream = thinkingHandler.createMessage("You are a helpful assistant", [ - { role: "user", content: "Hello" }, + const stream = handler.createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }]) + for await (const _chunk of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions).toBeDefined() + expect(callArgs.providerOptions.anthropic.thinking).toEqual({ + type: "enabled", + budgetTokens: 4096, + }) + expect(callArgs.temperature).toBe(1.0) + }) + + it("should not set providerOptions for non-thinking models", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }]) + for await (const _chunk of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions).toBeUndefined() + }) + }) + + describe("native tool calling", () => { + const systemPrompt = "You are a helpful assistant" + const messages: NeutralMessageParam[] = [ + { role: "user", content: [{ type: "text" as const, text: "What's the weather in London?" }] }, + ] + + const mockTools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { + type: "object", + properties: { location: { type: "string" } }, + required: ["location"], + }, + }, + }, + ] + + it("should include tools in streamText call when tools are provided", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: mockTools, + }) + for await (const _chunk of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.tools).toBeDefined() + expect(callArgs.tools.get_weather).toBeDefined() + }) + + it("should handle tool calls via AI SDK stream parts", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn([ + { type: "tool-input-start", id: "toolu_123", toolName: "get_weather" }, + { type: "tool-input-delta", id: "toolu_123", delta: '{"location":' }, + { type: "tool-input-delta", id: "toolu_123", delta: '"London"}' }, + { type: "tool-input-end", id: "toolu_123" }, ]) + const stream = handler.createMessage(systemPrompt, messages) + const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallStart = chunks.filter((c) => c.type === "tool_call_start") + expect(toolCallStart).toHaveLength(1) + expect(toolCallStart[0]).toMatchObject({ + type: "tool_call_start", + id: "toolu_123", + name: "get_weather", + }) + + const toolCallDeltas = chunks.filter((c) => c.type === "tool_call_delta") + expect(toolCallDeltas).toHaveLength(2) + + const toolCallEnd = chunks.filter((c) => c.type === "tool_call_end") + expect(toolCallEnd).toHaveLength(1) + }) + + it("should pass tool_choice via mapToolChoice", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tool_choice: "auto", + }) for await (const _chunk of stream) { // consume } - expect(mockStreamText).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 1.0, - providerOptions: expect.objectContaining({ - anthropic: expect.objectContaining({ - thinking: { - type: "enabled", - budgetTokens: 4096, - }, - }), - }), - }), - ) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.toolChoice).toBe("auto") + }) + + it("should include maxOutputTokens from model info", async () => { + const handler = new AnthropicVertexHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.maxOutputTokens).toBe(8192) }) }) - describe("isAiSdkProvider", () => { - it("should return true", () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", + describe("processUsageMetrics", () => { + it("should correctly process basic usage metrics", () => { + const handler = new TestAnthropicVertexHandler(mockOptions) + const result = handler.testProcessUsageMetrics({ inputTokens: 100, outputTokens: 50 }, undefined, { + inputPrice: 3.0, + outputPrice: 15.0, }) - expect(handler.isAiSdkProvider()).toBe(true) + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() + }) + + it("should extract Anthropic cache metrics from provider metadata", () => { + const handler = new TestAnthropicVertexHandler(mockOptions) + const result = handler.testProcessUsageMetrics( + { inputTokens: 100, outputTokens: 50 }, + { + anthropic: { + cacheCreationInputTokens: 20, + cacheReadInputTokens: 10, + }, + }, + { + inputPrice: 3.0, + outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + }, + ) + + expect(result.cacheWriteTokens).toBe(20) + expect(result.cacheReadTokens).toBe(10) + expect(result.totalCost).toBeDefined() + expect(result.totalCost).toBeGreaterThan(0) + }) + + it("should handle missing provider metadata gracefully", () => { + const handler = new TestAnthropicVertexHandler(mockOptions) + const result = handler.testProcessUsageMetrics({ inputTokens: 100, outputTokens: 50 }, undefined, { + inputPrice: 3.0, + outputPrice: 15.0, + }) + + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() + }) + + it("should calculate cost using Anthropic-specific pricing", () => { + const handler = new TestAnthropicVertexHandler(mockOptions) + const result = handler.testProcessUsageMetrics( + { inputTokens: 1000, outputTokens: 500 }, + { + anthropic: { + cacheCreationInputTokens: 200, + cacheReadInputTokens: 100, + }, + }, + { + inputPrice: 3.0, + outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + }, + ) + + // Cost = (3.0/1M * 1000) + (15.0/1M * 500) + (3.75/1M * 200) + (0.3/1M * 100) + const expectedCost = (3.0 * 1000 + 15.0 * 500 + 3.75 * 200 + 0.3 * 100) / 1_000_000 + expect(result.totalCost).toBeCloseTo(expectedCost, 10) }) }) - describe("thought signature and redacted thinking", () => { - beforeEach(() => { - handler = new AnthropicVertexHandler({ + describe("auth paths", () => { + it("should pass JSON credentials via googleAuthOptions", async () => { + const jsonCreds = JSON.stringify({ type: "service_account", project_id: "test" }) + const handler = new AnthropicVertexHandler({ apiModelId: "claude-3-5-sonnet-v2@20241022", vertexProjectId: "test-project", - vertexRegion: "us-central1", + vertexRegion: "us-east5", + vertexJsonCredentials: jsonCreds, }) - }) - it("should return undefined for thought signature before any request", () => { - expect(handler.getThoughtSignature()).toBeUndefined() - }) + mockGenerateText.mockResolvedValue({ text: "response" }) + await handler.completePrompt("test") - it("should return undefined for redacted thinking blocks before any request", () => { - expect(handler.getRedactedThinkingBlocks()).toBeUndefined() + expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + project: "test-project", + location: "us-east5", + googleAuthOptions: { + credentials: JSON.parse(jsonCreds), + }, + }), + ) }) - it("should reset thought signature on each createMessage call", async () => { - // First call with signature - mockStreamText.mockReturnValue( - createMockStreamResult([ - { - type: "reasoning-delta", - text: "thinking", - providerMetadata: { anthropic: { signature: "sig-1" } }, + it("should pass key file via googleAuthOptions", async () => { + const handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-east5", + vertexKeyFile: "/path/to/key.json", + }) + + mockGenerateText.mockResolvedValue({ text: "response" }) + await handler.completePrompt("test") + + expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + project: "test-project", + location: "us-east5", + googleAuthOptions: { + keyFile: "/path/to/key.json", }, - ]), + }), ) + }) - const stream1 = handler.createMessage("test", [{ role: "user", content: "Hello" }]) - for await (const _chunk of stream1) { - // consume - } - expect(handler.getThoughtSignature()).toBe("sig-1") + it("should not pass googleAuthOptions for default ADC", async () => { + const handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-east5", + }) - // Second call without signature - mockStreamText.mockReturnValue(createMockStreamResult([{ type: "text-delta", text: "just text" }])) + mockGenerateText.mockResolvedValue({ text: "response" }) + await handler.completePrompt("test") - const stream2 = handler.createMessage("test", [{ role: "user", content: "Hello again" }]) - for await (const _chunk of stream2) { - // consume - } - expect(handler.getThoughtSignature()).toBeUndefined() + expect(mockCreateVertexAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + project: "test-project", + location: "us-east5", + }), + ) + // googleAuthOptions should be undefined (not passed) + const callArg = mockCreateVertexAnthropic.mock.calls[0][0] + expect(callArg.googleAuthOptions).toBeUndefined() }) }) }) diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index b80dc205eb5..c1323b58cd5 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -1,237 +1,313 @@ -// npx vitest run src/api/providers/__tests__/anthropic.spec.ts +// npx vitest run api/providers/__tests__/anthropic.spec.ts -import { AnthropicHandler } from "../anthropic" -import { ApiHandlerOptions } from "../../../shared/api" - -// Mock TelemetryService -vitest.mock("@roo-code/telemetry", () => ({ - TelemetryService: { - instance: { - captureException: vitest.fn(), - }, - }, +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText, mockCreateAnthropic } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockCreateAnthropic: vi.fn(), })) -// Mock the AI SDK -const mockStreamText = vitest.fn() -const mockGenerateText = vitest.fn() +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) -vitest.mock("ai", () => ({ - streamText: (...args: any[]) => mockStreamText(...args), - generateText: (...args: any[]) => mockGenerateText(...args), - tool: vitest.fn(), - jsonSchema: vitest.fn(), - ToolSet: {}, +vi.mock("@ai-sdk/anthropic", () => ({ + createAnthropic: mockCreateAnthropic.mockImplementation(() => ({ + chat: vi.fn((id: string) => ({ modelId: id, provider: "anthropic" })), + })), })) -// Mock the @ai-sdk/anthropic provider -const mockCreateAnthropic = vitest.fn() +const mockCaptureException = vi.fn() -vitest.mock("@ai-sdk/anthropic", () => ({ - createAnthropic: (...args: any[]) => mockCreateAnthropic(...args), +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: (...args: unknown[]) => mockCaptureException(...args), + }, + }, })) -// Mock ai-sdk transform utilities -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") { - yield { type: "text", text: part.text } - } else if (part.type === "reasoning-delta") { - yield { type: "reasoning", text: part.text } - } else if (part.type === "tool-input-start") { - yield { type: "tool_call_start", id: part.id, name: part.toolName } - } else if (part.type === "tool-input-delta") { - yield { type: "tool_call_delta", id: part.id, delta: part.delta } - } else if (part.type === "tool-input-end") { - yield { type: "tool_call_end", id: part.id } +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" +import type { ApiHandlerOptions } from "../../../shared/api" +import { AnthropicHandler } from "../anthropic" + +// Helper: create a standard mock fullStream async generator +function createMockFullStream(parts: Array>) { + return async function* () { + for (const part of parts) { + yield part } - }), - mapToolChoice: vitest.fn().mockReturnValue(undefined), - handleAiSdkError: vitest.fn().mockImplementation((error: any) => error), -})) + } +} -// Import mocked modules -import { convertToAiSdkMessages, convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" -import { Anthropic } from "@anthropic-ai/sdk" +// Helper: set up mock return value for streamText +function mockStreamTextReturn( + parts: Array>, + usage = { inputTokens: 10, outputTokens: 5 }, + providerMetadata: Record = {}, +) { + mockStreamText.mockReturnValue({ + fullStream: createMockFullStream(parts)(), + usage: Promise.resolve(usage), + providerMetadata: Promise.resolve(providerMetadata), + }) +} -// Helper: create a mock provider function -function createMockProviderFn() { - const providerFn = vitest.fn().mockReturnValue("mock-model") - return providerFn +// Test subclass to expose protected methods +class TestAnthropicHandler extends AnthropicHandler { + public testProcessUsageMetrics( + usage: { inputTokens?: number; outputTokens?: number }, + providerMetadata?: Record>, + modelInfo?: Record, + ) { + return this.processUsageMetrics(usage, providerMetadata, modelInfo as any) + } } describe("AnthropicHandler", () => { - let handler: AnthropicHandler - let mockOptions: ApiHandlerOptions - let mockProviderFn: ReturnType - - beforeEach(() => { - mockOptions = { - apiKey: "test-api-key", - apiModelId: "claude-3-5-sonnet-20241022", - } - - mockProviderFn = createMockProviderFn() - mockCreateAnthropic.mockReturnValue(mockProviderFn) + const mockOptions: ApiHandlerOptions = { + apiKey: "test-api-key", + apiModelId: "claude-3-5-sonnet-20241022", + } - handler = new AnthropicHandler(mockOptions) - vitest.clearAllMocks() - - // 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) - }) + beforeEach(() => vi.clearAllMocks()) describe("constructor", () => { it("should initialize with provided options", () => { + const handler = new AnthropicHandler(mockOptions) expect(handler).toBeInstanceOf(AnthropicHandler) expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) - it("should initialize with undefined API key and pass it through for env-var fallback", () => { - mockCreateAnthropic.mockClear() - const handlerWithoutKey = new AnthropicHandler({ - ...mockOptions, - apiKey: undefined, - }) - expect(handlerWithoutKey).toBeInstanceOf(AnthropicHandler) - const callArgs = mockCreateAnthropic.mock.calls[0]![0]! - expect(callArgs.apiKey).toBeUndefined() + it("should initialize with undefined API key", () => { + const handler = new AnthropicHandler({ ...mockOptions, apiKey: undefined }) + expect(handler).toBeInstanceOf(AnthropicHandler) }) - it("should use custom base URL if provided", () => { - const customBaseUrl = "https://custom.anthropic.com" - const handlerWithCustomUrl = new AnthropicHandler({ + it("should use custom base URL if provided", async () => { + const handler = new AnthropicHandler({ ...mockOptions, - anthropicBaseUrl: customBaseUrl, + anthropicBaseUrl: "https://custom.anthropic.com", }) - expect(handlerWithCustomUrl).toBeInstanceOf(AnthropicHandler) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage("test", [{ role: "user", content: "hello" }]) + for await (const _chunk of stream) { + // consume + } + + expect(mockCreateAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://custom.anthropic.com", + }), + ) }) - it("use apiKey for passing token if anthropicUseAuthToken is not set", () => { - mockCreateAnthropic.mockClear() - const _ = new AnthropicHandler({ + it("should pass undefined baseURL when empty string provided", async () => { + const handler = new AnthropicHandler({ ...mockOptions, + anthropicBaseUrl: "", }) - expect(mockCreateAnthropic).toHaveBeenCalledTimes(1) - const callArgs = mockCreateAnthropic.mock.calls[0]![0]! - expect(callArgs.apiKey).toEqual("test-api-key") - expect(callArgs.authToken).toBeUndefined() + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage("test", [{ role: "user", content: "hello" }]) + for await (const _chunk of stream) { + // consume + } + + expect(mockCreateAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: undefined, + }), + ) + }) + + it("should use apiKey when anthropicUseAuthToken is not set", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage("test", [{ role: "user", content: "hello" }]) + for await (const _chunk of stream) { + // consume + } + + expect(mockCreateAnthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "test-api-key" })) + expect(mockCreateAnthropic.mock.calls[0][0]).not.toHaveProperty("authToken") }) - it("use apiKey for passing token if anthropicUseAuthToken is set but custom base URL is not given", () => { - mockCreateAnthropic.mockClear() - const _ = new AnthropicHandler({ + it("should use apiKey when anthropicUseAuthToken is set but no base URL", async () => { + const handler = new AnthropicHandler({ ...mockOptions, anthropicUseAuthToken: true, }) - expect(mockCreateAnthropic).toHaveBeenCalledTimes(1) - const callArgs = mockCreateAnthropic.mock.calls[0]![0]! - expect(callArgs.apiKey).toEqual("test-api-key") - expect(callArgs.authToken).toBeUndefined() + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage("test", [{ role: "user", content: "hello" }]) + for await (const _chunk of stream) { + // consume + } + + expect(mockCreateAnthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "test-api-key" })) + expect(mockCreateAnthropic.mock.calls[0][0]).not.toHaveProperty("authToken") }) - it("use authToken for passing token if both of anthropicBaseUrl and anthropicUseAuthToken are set", () => { - mockCreateAnthropic.mockClear() - const customBaseUrl = "https://custom.anthropic.com" - const _ = new AnthropicHandler({ + it("should use authToken when both anthropicBaseUrl and anthropicUseAuthToken are set", async () => { + const handler = new AnthropicHandler({ ...mockOptions, - anthropicBaseUrl: customBaseUrl, + anthropicBaseUrl: "https://custom.anthropic.com", anthropicUseAuthToken: true, }) - expect(mockCreateAnthropic).toHaveBeenCalledTimes(1) - const callArgs = mockCreateAnthropic.mock.calls[0]![0]! - expect(callArgs.authToken).toEqual("test-api-key") - expect(callArgs.apiKey).toBeUndefined() + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage("test", [{ role: "user", content: "hello" }]) + for await (const _chunk of stream) { + // consume + } + + expect(mockCreateAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ + authToken: "test-api-key", + baseURL: "https://custom.anthropic.com", + }), + ) + expect(mockCreateAnthropic.mock.calls[0][0]).not.toHaveProperty("apiKey") }) + }) - it("should include 1M context beta header when enabled", () => { - mockCreateAnthropic.mockClear() - const _ = new AnthropicHandler({ - ...mockOptions, - apiModelId: "claude-sonnet-4-5", - anthropicBeta1MContext: true, + describe("isAiSdkProvider", () => { + it("should return true", () => { + const handler = new AnthropicHandler(mockOptions) + expect(handler.isAiSdkProvider()).toBe(true) + }) + }) + + describe("getModel", () => { + it("should return default model if no model ID is provided", () => { + const handler = new AnthropicHandler({ ...mockOptions, apiModelId: undefined }) + const model = handler.getModel() + expect(model.id).toBeDefined() + expect(model.info).toBeDefined() + }) + + it("should return specified model if valid model ID is provided", () => { + const handler = new AnthropicHandler(mockOptions) + const model = handler.getModel() + expect(model.id).toBe(mockOptions.apiModelId) + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(200_000) + expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsPromptCache).toBe(true) + }) + + it("should honor custom maxTokens for thinking models", () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-3-7-sonnet-20250219:thinking", + modelMaxTokens: 32_768, + modelMaxThinkingTokens: 16_384, }) - expect(mockCreateAnthropic).toHaveBeenCalledTimes(1) - const callArgs = mockCreateAnthropic.mock.calls[0]![0]! - expect(callArgs.headers["anthropic-beta"]).toContain("context-1m-2025-08-07") + + const result = handler.getModel() + expect(result.maxTokens).toBe(32_768) + expect(result.reasoningBudget).toEqual(16_384) + expect(result.temperature).toBe(1.0) }) - it("should include output-128k beta for thinking model", () => { - mockCreateAnthropic.mockClear() - const _ = new AnthropicHandler({ - ...mockOptions, + it("should not honor custom maxTokens for non-thinking models", () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-3-7-sonnet-20250219", + modelMaxTokens: 32_768, + modelMaxThinkingTokens: 16_384, + }) + + const result = handler.getModel() + expect(result.maxTokens).toBe(8192) + expect(result.reasoningBudget).toBeUndefined() + expect(result.temperature).toBe(0) + }) + + it("should strip :thinking suffix from model ID and include betas", () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", apiModelId: "claude-3-7-sonnet-20250219:thinking", }) - expect(mockCreateAnthropic).toHaveBeenCalledTimes(1) - const callArgs = mockCreateAnthropic.mock.calls[0]![0]! - expect(callArgs.headers["anthropic-beta"]).toContain("output-128k-2025-02-19") + + const model = handler.getModel() + expect(model.id).toBe("claude-3-7-sonnet-20250219") + expect(model.betas).toContain("output-128k-2025-02-19") + }) + + it("should handle Claude 4.5 Sonnet model correctly", () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-sonnet-4-5", + }) + const model = handler.getModel() + expect(model.id).toBe("claude-sonnet-4-5") + expect(model.info.maxTokens).toBe(64000) + expect(model.info.contextWindow).toBe(200000) + expect(model.info.supportsReasoningBudget).toBe(true) + }) + + it("should enable 1M context for Claude 4.5 Sonnet when beta flag is set", () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-sonnet-4-5", + anthropicBeta1MContext: true, + }) + const model = handler.getModel() + expect(model.info.contextWindow).toBe(1000000) + expect(model.info.inputPrice).toBe(6.0) + expect(model.info.outputPrice).toBe(22.5) }) }) describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." + const messages: NeutralMessageParam[] = [{ role: "user", content: [{ type: "text" as const, text: "Hello!" }] }] - function setupStreamTextMock(parts: any[], usage?: any, providerMetadata?: any) { - const asyncIterable = { - async *[Symbol.asyncIterator]() { - for (const part of parts) { - yield part - } - }, + it("should handle streaming text responses", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "Test response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - mockStreamText.mockReturnValue({ - fullStream: asyncIterable, - usage: Promise.resolve(usage || { inputTokens: 100, outputTokens: 50 }), - providerMetadata: Promise.resolve( - providerMetadata || { - anthropic: { - cacheCreationInputTokens: 20, - cacheReadInputTokens: 10, - }, - }, - ), - }) - } - it("should stream text content using AI SDK", async () => { - setupStreamTextMock([ - { type: "text-delta", text: "Hello" }, - { type: "text-delta", text: " world" }, - ]) + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response") + }) - const stream = handler.createMessage(systemPrompt, [ - { - role: "user", - content: [{ type: "text" as const, text: "First message" }], - }, - ]) + it("should include usage information", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }], { inputTokens: 100, outputTokens: 50 }) + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } - // Verify text content - const textChunks = chunks.filter((chunk) => chunk.type === "text") - expect(textChunks).toHaveLength(2) - expect(textChunks[0].text).toBe("Hello") - expect(textChunks[1].text).toBe(" world") - - // Verify usage information - const usageChunks = chunks.filter((chunk) => chunk.type === "usage") - expect(usageChunks.length).toBeGreaterThan(0) + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.inputTokens).toBe(100) + expect(usageChunk.outputTokens).toBe(50) }) - it("should handle prompt caching for supported models", async () => { - setupStreamTextMock( - [{ type: "text-delta", text: "Hello" }], + it("should include Anthropic cache metrics from providerMetadata", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn( + [{ type: "text-delta", text: "response" }], { inputTokens: 100, outputTokens: 50 }, { anthropic: { @@ -241,270 +317,376 @@ describe("AnthropicHandler", () => { }, ) - const stream = handler.createMessage(systemPrompt, [ - { - role: "user", - content: [{ type: "text" as const, text: "First message" }], - }, - { - role: "assistant", - content: [{ type: "text" as const, text: "Response" }], - }, - { - role: "user", - content: [{ type: "text" as const, text: "Second message" }], - }, - ]) - + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } - // Verify usage information includes cache metrics - const usageChunk = chunks.find( - (chunk) => chunk.type === "usage" && (chunk.cacheWriteTokens || chunk.cacheReadTokens), - ) + const usageChunk = chunks.find((c) => c.type === "usage") expect(usageChunk).toBeDefined() - expect(usageChunk?.cacheWriteTokens).toBe(20) - expect(usageChunk?.cacheReadTokens).toBe(10) - - // Verify streamText was called - expect(mockStreamText).toHaveBeenCalled() + expect(usageChunk.cacheWriteTokens).toBe(20) + expect(usageChunk.cacheReadTokens).toBe(10) }) - it("should pass tools via AI SDK when tools are provided", async () => { - const mockTools = [ - { - type: "function" as const, - function: { - name: "get_weather", - description: "Get the current weather", - parameters: { - type: "object", - properties: { - location: { type: "string" }, - }, - required: ["location"], - }, - }, - }, - ] + it("should apply cache control to system prompt for prompt-caching models", async () => { + const handler = new AnthropicHandler(mockOptions) // claude-3-5-sonnet supports prompt cache + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - setupStreamTextMock([{ type: "text-delta", text: "Weather check" }]) + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume + } - const stream = handler.createMessage( - systemPrompt, - [{ role: "user", content: [{ type: "text" as const, text: "What's the weather?" }] }], - { taskId: "test-task", tools: mockTools }, + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.system).toEqual( + expect.objectContaining({ + role: "system", + content: systemPrompt, + providerOptions: expect.objectContaining({ + anthropic: { cacheControl: { type: "ephemeral" } }, + }), + }), ) + }) + + it("should apply cache breakpoints to last 2 user messages", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const multiMessages: NeutralMessageParam[] = [ + { role: "user", content: [{ type: "text" as const, text: "First message" }] }, + { role: "assistant", content: [{ type: "text" as const, text: "Response" }] }, + { role: "user", content: [{ type: "text" as const, text: "Second message" }] }, + ] + const stream = handler.createMessage(systemPrompt, multiMessages) for await (const _chunk of stream) { - // Consume stream + // consume } - // Verify tools were converted - expect(convertToolsForAiSdk).toHaveBeenCalled() - expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + const aiSdkMessages = callArgs.messages + const userMessages = aiSdkMessages.filter((m: any) => m.role === "user") + + // Both user messages should have cache control applied + for (const msg of userMessages) { + const content = Array.isArray(msg.content) ? msg.content : [msg.content] + const lastTextPart = [...content].reverse().find((p: any) => typeof p === "object" && p.type === "text") + if (lastTextPart) { + expect(lastTextPart.providerOptions).toEqual({ + anthropic: { cacheControl: { type: "ephemeral" } }, + }) + } + } }) - it("should handle tool_choice mapping", async () => { - setupStreamTextMock([{ type: "text-delta", text: "test" }]) - - const stream = handler.createMessage( - systemPrompt, - [{ role: "user", content: [{ type: "text" as const, text: "test" }] }], - { taskId: "test-task", tool_choice: "auto" }, - ) + it("should pass temperature 0 as default", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { - // Consume stream + // consume } - expect(mapToolChoice).toHaveBeenCalledWith("auto") + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.temperature).toBe(0) }) - it("should disable parallel tool use when parallelToolCalls is false", async () => { - setupStreamTextMock([{ type: "text-delta", text: "test" }]) - - const stream = handler.createMessage( - systemPrompt, - [{ role: "user", content: [{ type: "text" as const, text: "test" }] }], - { taskId: "test-task", parallelToolCalls: false }, - ) + it("should include maxOutputTokens from model info", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { - // Consume stream + // consume } - expect(mockStreamText).toHaveBeenCalledWith( - expect.objectContaining({ - providerOptions: expect.objectContaining({ - anthropic: expect.objectContaining({ - disableParallelToolUse: true, - }), - }), - }), - ) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.maxOutputTokens).toBe(8192) }) - it("should not set disableParallelToolUse when parallelToolCalls is true or undefined", async () => { - setupStreamTextMock([{ type: "text-delta", text: "test" }]) - - const stream = handler.createMessage( - systemPrompt, - [{ role: "user", content: [{ type: "text" as const, text: "test" }] }], - { taskId: "test-task", parallelToolCalls: true }, - ) + it("should handle reasoning stream parts", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([ + { type: "reasoning", text: "Let me think..." }, + { type: "text-delta", text: "The answer is 42" }, + ]) - for await (const _chunk of stream) { - // Consume stream + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - // providerOptions should not include disableParallelToolUse - const callArgs = mockStreamText.mock.calls[0]![0] - const anthropicOptions = callArgs?.providerOptions?.anthropic - expect(anthropicOptions?.disableParallelToolUse).toBeUndefined() + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("Let me think...") + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("The answer is 42") }) - it("should handle tool call streaming via AI SDK", async () => { - setupStreamTextMock([ + it("should handle tool calls via AI SDK stream parts", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([ { type: "tool-input-start", id: "toolu_123", toolName: "get_weather" }, { type: "tool-input-delta", id: "toolu_123", delta: '{"location":' }, { type: "tool-input-delta", id: "toolu_123", delta: '"London"}' }, { type: "tool-input-end", id: "toolu_123" }, ]) - const stream = handler.createMessage( - systemPrompt, - [{ role: "user", content: [{ type: "text" as const, text: "What's the weather?" }] }], - { taskId: "test-task" }, - ) - + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } - const startChunk = chunks.find((c) => c.type === "tool_call_start") - expect(startChunk).toBeDefined() - expect(startChunk?.id).toBe("toolu_123") - expect(startChunk?.name).toBe("get_weather") + const toolCallStart = chunks.filter((c) => c.type === "tool_call_start") + expect(toolCallStart).toHaveLength(1) + expect(toolCallStart[0].id).toBe("toolu_123") + expect(toolCallStart[0].name).toBe("get_weather") - const deltaChunks = chunks.filter((c) => c.type === "tool_call_delta") - expect(deltaChunks).toHaveLength(2) + const toolCallDeltas = chunks.filter((c) => c.type === "tool_call_delta") + expect(toolCallDeltas).toHaveLength(2) - const endChunk = chunks.find((c) => c.type === "tool_call_end") - expect(endChunk).toBeDefined() + const toolCallEnd = chunks.filter((c) => c.type === "tool_call_end") + expect(toolCallEnd).toHaveLength(1) }) - it("should capture thinking signature from stream events", async () => { - const testSignature = "test-thinking-signature" - setupStreamTextMock([ + it("should include tools in streamText call when tools are provided", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const mockTools = [ { - type: "reasoning-delta", - text: "thinking...", - providerMetadata: { anthropic: { signature: testSignature } }, + type: "function" as const, + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { + type: "object", + properties: { location: { type: "string" } }, + required: ["location"], + }, + }, }, - { type: "text-delta", text: "Answer" }, - ]) + ] - const stream = handler.createMessage(systemPrompt, [ - { role: "user", content: [{ type: "text" as const, text: "test" }] }, - ]) + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: mockTools, + }) + for await (const _chunk of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.tools).toBeDefined() + expect(callArgs.tools.get_weather).toBeDefined() + }) + + it("should pass tool_choice 'auto' via mapToolChoice", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tool_choice: "auto", + }) for await (const _chunk of stream) { - // Consume stream + // consume } - expect(handler.getThoughtSignature()).toBe(testSignature) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.toolChoice).toBe("auto") }) - it("should capture redacted thinking blocks from stream events", async () => { - setupStreamTextMock([ - { - type: "reasoning-delta", - text: "", - providerMetadata: { anthropic: { redactedData: "redacted-data-base64" } }, - }, - { type: "text-delta", text: "Answer" }, - ]) + it("should pass tool_choice 'required' via mapToolChoice", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - const stream = handler.createMessage(systemPrompt, [ - { role: "user", content: [{ type: "text" as const, text: "test" }] }, - ]) + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tool_choice: "required", + }) + for await (const _chunk of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.toolChoice).toBe("required") + }) + it("should pass tool_choice 'none' via mapToolChoice", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tool_choice: "none", + }) for await (const _chunk of stream) { - // Consume stream + // consume } - const redactedBlocks = handler.getRedactedThinkingBlocks() - expect(redactedBlocks).toBeDefined() - expect(redactedBlocks).toHaveLength(1) - expect(redactedBlocks![0]).toEqual({ - type: "redacted_thinking", - data: "redacted-data-base64", + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.toolChoice).toBe("none") + }) + + it("should convert specific tool_choice to AI SDK format", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tool_choice: { type: "function" as const, function: { name: "get_weather" } }, }) + for await (const _chunk of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.toolChoice).toEqual({ type: "tool", toolName: "get_weather" }) }) - it("should reset thinking state between requests", async () => { - // First request with signature - setupStreamTextMock([ - { - type: "reasoning-delta", - text: "thinking...", - providerMetadata: { anthropic: { signature: "sig-1" } }, - }, - ]) + it("should include anthropic-beta header with fine-grained-tool-streaming and prompt-caching", async () => { + const handler = new AnthropicHandler(mockOptions) // claude-3-5-sonnet supports prompt cache + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - const stream1 = handler.createMessage(systemPrompt, [ - { role: "user", content: [{ type: "text" as const, text: "test 1" }] }, - ]) - for await (const _chunk of stream1) { - // Consume + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume } - expect(handler.getThoughtSignature()).toBe("sig-1") - // Second request without signature - setupStreamTextMock([{ type: "text-delta", text: "plain answer" }]) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.headers).toBeDefined() + const betas: string = callArgs.headers["anthropic-beta"] + expect(betas).toContain("fine-grained-tool-streaming-2025-05-14") + expect(betas).toContain("prompt-caching-2024-07-31") + }) - const stream2 = handler.createMessage(systemPrompt, [ - { role: "user", content: [{ type: "text" as const, text: "test 2" }] }, - ]) - for await (const _chunk of stream2) { - // Consume + it("should include context-1m beta for supported models when enabled", async () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-sonnet-4-5", + anthropicBeta1MContext: true, + }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume } - expect(handler.getThoughtSignature()).toBeUndefined() + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.headers["anthropic-beta"]).toContain("context-1m-2025-08-07") }) - it("should pass system prompt via system param with systemProviderOptions for cache control", async () => { - setupStreamTextMock([{ type: "text-delta", text: "test" }]) + it("should not include context-1m beta for unsupported models", async () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-3-5-sonnet-20241022", + anthropicBeta1MContext: true, + }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume + } - const stream = handler.createMessage(systemPrompt, [ - { role: "user", content: [{ type: "text" as const, text: "test" }] }, - ]) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.headers["anthropic-beta"]).not.toContain("context-1m-2025-08-07") + }) + it("should include output-128k beta for :thinking models", async () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-3-7-sonnet-20250219:thinking", + }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { - // Consume + // consume } - // Verify streamText was called with system + systemProviderOptions (not as a message) - const callArgs = mockStreamText.mock.calls[0]![0] - expect(callArgs.system).toBe(systemPrompt) - expect(callArgs.systemProviderOptions).toEqual({ - anthropic: { cacheControl: { type: "ephemeral" } }, + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.headers["anthropic-beta"]).toContain("output-128k-2025-02-19") + }) + + it("should set providerOptions with thinking for thinking models", async () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-3-7-sonnet-20250219:thinking", + modelMaxTokens: 32_768, + modelMaxThinkingTokens: 16_384, }) - // System prompt should NOT be in the messages array - const systemMessages = callArgs.messages.filter((m: any) => m.role === "system") - expect(systemMessages).toHaveLength(0) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions).toBeDefined() + expect(callArgs.providerOptions.anthropic.thinking).toEqual({ + type: "enabled", + budgetTokens: 16_384, + }) + }) + + it("should not set providerOptions for non-thinking models", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions).toBeUndefined() + }) + + it("should handle API errors and capture telemetry", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta" as const, text: "" } + throw new Error("API Error") + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, messages) + + await expect(async () => { + for await (const _chunk of stream) { + // consume + } + }).rejects.toThrow("API Error") + + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "API Error", + provider: "Anthropic", + modelId: mockOptions.apiModelId, + operation: "createMessage", + }), + ) }) }) describe("completePrompt", () => { it("should complete prompt successfully", async () => { - mockGenerateText.mockResolvedValueOnce({ - text: "Test response", - }) + const handler = new AnthropicHandler(mockOptions) + mockGenerateText.mockResolvedValue({ text: "Test response" }) const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") @@ -516,108 +698,165 @@ describe("AnthropicHandler", () => { ) }) - it("should handle API errors", async () => { - const error = new Error("Anthropic completion error: API Error") - mockGenerateText.mockRejectedValueOnce(error) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow() + it("should handle API errors and capture telemetry", async () => { + const handler = new AnthropicHandler(mockOptions) + mockGenerateText.mockRejectedValue(new Error("API Error")) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API Error") + + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "API Error", + provider: "Anthropic", + modelId: mockOptions.apiModelId, + operation: "completePrompt", + }), + ) }) it("should handle empty response", async () => { - mockGenerateText.mockResolvedValueOnce({ - text: "", - }) + const handler = new AnthropicHandler(mockOptions) + mockGenerateText.mockResolvedValue({ text: "" }) + const result = await handler.completePrompt("Test prompt") expect(result).toBe("") }) - }) - describe("getModel", () => { - it("should return default model if no model ID is provided", () => { - const handlerWithoutModel = new AnthropicHandler({ - ...mockOptions, - apiModelId: undefined, - }) - const model = handlerWithoutModel.getModel() - expect(model.id).toBeDefined() - expect(model.info).toBeDefined() - }) + it("should pass model and temperature to generateText", async () => { + const handler = new AnthropicHandler(mockOptions) + mockGenerateText.mockResolvedValue({ text: "response" }) - it("should return specified model if valid model ID is provided", () => { - const model = handler.getModel() - expect(model.id).toBe(mockOptions.apiModelId) - expect(model.info).toBeDefined() - expect(model.info.maxTokens).toBe(8192) - expect(model.info.contextWindow).toBe(200_000) - expect(model.info.supportsImages).toBe(true) - expect(model.info.supportsPromptCache).toBe(true) + await handler.completePrompt("Test prompt") + + const callArgs = mockGenerateText.mock.calls[0][0] + expect(callArgs.prompt).toBe("Test prompt") + expect(callArgs.temperature).toBe(0) + expect(callArgs.model).toBeDefined() }) + }) - it("honors custom maxTokens for thinking models", () => { - const handler = new AnthropicHandler({ - apiKey: "test-api-key", - apiModelId: "claude-3-7-sonnet-20250219:thinking", - modelMaxTokens: 32_768, - modelMaxThinkingTokens: 16_384, + describe("processUsageMetrics", () => { + it("should correctly process basic usage metrics", () => { + const handler = new TestAnthropicHandler(mockOptions) + const result = handler.testProcessUsageMetrics({ inputTokens: 100, outputTokens: 50 }, undefined, { + inputPrice: 3.0, + outputPrice: 15.0, }) - const result = handler.getModel() - expect(result.maxTokens).toBe(32_768) - expect(result.reasoningBudget).toEqual(16_384) - expect(result.temperature).toBe(1.0) + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() }) - it("does not honor custom maxTokens for non-thinking models", () => { - const handler = new AnthropicHandler({ - apiKey: "test-api-key", - apiModelId: "claude-3-7-sonnet-20250219", - modelMaxTokens: 32_768, - modelMaxThinkingTokens: 16_384, - }) + it("should extract Anthropic cache metrics from provider metadata", () => { + const handler = new TestAnthropicHandler(mockOptions) + const result = handler.testProcessUsageMetrics( + { inputTokens: 100, outputTokens: 50 }, + { + anthropic: { + cacheCreationInputTokens: 20, + cacheReadInputTokens: 10, + }, + }, + { + inputPrice: 3.0, + outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + }, + ) - const result = handler.getModel() - expect(result.maxTokens).toBe(8192) - expect(result.reasoningBudget).toBeUndefined() - expect(result.temperature).toBe(0) + expect(result.cacheWriteTokens).toBe(20) + expect(result.cacheReadTokens).toBe(10) + expect(result.totalCost).toBeDefined() + expect(result.totalCost).toBeGreaterThan(0) }) - it("should handle Claude 4.5 Sonnet model correctly", () => { - const handler = new AnthropicHandler({ - apiKey: "test-api-key", - apiModelId: "claude-sonnet-4-5", + it("should handle missing provider metadata gracefully", () => { + const handler = new TestAnthropicHandler(mockOptions) + const result = handler.testProcessUsageMetrics({ inputTokens: 100, outputTokens: 50 }, undefined, { + inputPrice: 3.0, + outputPrice: 15.0, }) - const model = handler.getModel() - expect(model.id).toBe("claude-sonnet-4-5") - expect(model.info.maxTokens).toBe(64000) - expect(model.info.contextWindow).toBe(200000) - expect(model.info.supportsReasoningBudget).toBe(true) - }) - it("should enable 1M context for Claude 4.5 Sonnet when beta flag is set", () => { - const handler = new AnthropicHandler({ - apiKey: "test-api-key", - apiModelId: "claude-sonnet-4-5", - anthropicBeta1MContext: true, - }) - const model = handler.getModel() - expect(model.info.contextWindow).toBe(1000000) - expect(model.info.inputPrice).toBe(6.0) - expect(model.info.outputPrice).toBe(22.5) + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() }) - }) - describe("isAiSdkProvider", () => { - it("should return true", () => { - expect(handler.isAiSdkProvider()).toBe(true) + it("should calculate cost using Anthropic-specific pricing", () => { + const handler = new TestAnthropicHandler(mockOptions) + const result = handler.testProcessUsageMetrics( + { inputTokens: 1000, outputTokens: 500 }, + { + anthropic: { + cacheCreationInputTokens: 200, + cacheReadInputTokens: 100, + }, + }, + { + inputPrice: 3.0, + outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + }, + ) + + // Cost = (3.0/1M * 1000) + (15.0/1M * 500) + (3.75/1M * 200) + (0.3/1M * 100) + const expectedCost = (3.0 * 1000 + 15.0 * 500 + 3.75 * 200 + 0.3 * 100) / 1_000_000 + expect(result.totalCost).toBeCloseTo(expectedCost, 10) }) }) - describe("thinking signature", () => { - it("should return undefined when no signature captured", () => { - expect(handler.getThoughtSignature()).toBeUndefined() + describe("error handling", () => { + const testMessages: NeutralMessageParam[] = [ + { role: "user", content: [{ type: "text" as const, text: "Hello" }] }, + ] + + it("should capture telemetry when createMessage stream throws", async () => { + const handler = new AnthropicHandler(mockOptions) + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta" as const, text: "" } + throw new Error("Connection failed") + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage("test", testMessages) + + await expect(async () => { + for await (const _chunk of stream) { + // consume + } + }).rejects.toThrow() + + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Connection failed", + provider: "Anthropic", + modelId: mockOptions.apiModelId, + operation: "createMessage", + }), + ) }) - it("should return undefined for redacted blocks when none captured", () => { - expect(handler.getRedactedThinkingBlocks()).toBeUndefined() + it("should capture telemetry when completePrompt throws", async () => { + const handler = new AnthropicHandler(mockOptions) + mockGenerateText.mockRejectedValue(new Error("Unexpected error")) + + await expect(handler.completePrompt("test")).rejects.toThrow("Unexpected error") + + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unexpected error", + provider: "Anthropic", + modelId: mockOptions.apiModelId, + operation: "completePrompt", + }), + ) }) }) }) diff --git a/src/api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts deleted file mode 100644 index baa7ae953bc..00000000000 --- a/src/api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -// npx vitest run api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts - -import type { ModelInfo } from "@roo-code/types" - -import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider" - -// Mock the timeout config utility -vitest.mock("../utils/timeout-config", () => ({ - getApiRequestTimeout: vitest.fn(), -})) - -import { getApiRequestTimeout } from "../utils/timeout-config" - -// Mock OpenAI and capture constructor calls -const mockOpenAIConstructor = vitest.fn() - -vitest.mock("openai", () => { - return { - __esModule: true, - default: vitest.fn().mockImplementation((config) => { - mockOpenAIConstructor(config) - return { - chat: { - completions: { - create: vitest.fn(), - }, - }, - } - }), - } -}) - -// Create a concrete test implementation of the abstract base class -class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-model"> { - constructor(apiKey: string) { - const testModels: Record<"test-model", ModelInfo> = { - "test-model": { - maxTokens: 4096, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.5, - outputPrice: 1.5, - }, - } - - super({ - providerName: "TestProvider", - baseURL: "https://test.example.com/v1", - defaultProviderModelId: "test-model", - providerModels: testModels, - apiKey, - }) - } -} - -describe("BaseOpenAiCompatibleProvider Timeout Configuration", () => { - beforeEach(() => { - vitest.clearAllMocks() - }) - - it("should call getApiRequestTimeout when creating the provider", () => { - ;(getApiRequestTimeout as any).mockReturnValue(600000) - - new TestOpenAiCompatibleProvider("test-api-key") - - expect(getApiRequestTimeout).toHaveBeenCalled() - }) - - it("should pass the default timeout to the OpenAI client constructor", () => { - ;(getApiRequestTimeout as any).mockReturnValue(600000) // 600 seconds in ms - - new TestOpenAiCompatibleProvider("test-api-key") - - expect(mockOpenAIConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://test.example.com/v1", - apiKey: "test-api-key", - timeout: 600000, - }), - ) - }) - - it("should use custom timeout value from getApiRequestTimeout", () => { - ;(getApiRequestTimeout as any).mockReturnValue(1800000) // 30 minutes in ms - - new TestOpenAiCompatibleProvider("test-api-key") - - expect(mockOpenAIConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 1800000, - }), - ) - }) - - it("should handle zero timeout (no timeout)", () => { - ;(getApiRequestTimeout as any).mockReturnValue(0) - - new TestOpenAiCompatibleProvider("test-api-key") - - expect(mockOpenAIConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: 0, - }), - ) - }) - - it("should pass DEFAULT_HEADERS to the OpenAI client constructor", () => { - ;(getApiRequestTimeout as any).mockReturnValue(600000) - - new TestOpenAiCompatibleProvider("test-api-key") - - expect(mockOpenAIConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - defaultHeaders: expect.any(Object), - }), - ) - }) -}) diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts deleted file mode 100644 index 6f8d121e69e..00000000000 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ /dev/null @@ -1,548 +0,0 @@ -// npx vitest run api/providers/__tests__/base-openai-compatible-provider.spec.ts - -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -import type { ModelInfo } from "@roo-code/types" - -import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider" - -// Create mock functions -const mockCreate = vi.fn() - -// Mock OpenAI module -vi.mock("openai", () => ({ - default: vi.fn(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, - })), -})) - -// Create a concrete test implementation of the abstract base class -class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-model"> { - constructor(apiKey: string) { - const testModels: Record<"test-model", ModelInfo> = { - "test-model": { - maxTokens: 4096, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.5, - outputPrice: 1.5, - }, - } - - super({ - providerName: "TestProvider", - baseURL: "https://test.example.com/v1", - defaultProviderModelId: "test-model", - providerModels: testModels, - apiKey, - }) - } -} - -describe("BaseOpenAiCompatibleProvider", () => { - let handler: TestOpenAiCompatibleProvider - - beforeEach(() => { - vi.clearAllMocks() - handler = new TestOpenAiCompatibleProvider("test-api-key") - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - describe("TagMatcher reasoning tags", () => { - it("should handle reasoning tags () from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Let me think" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " about this" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "The answer is 42" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // TagMatcher yields chunks as they're processed - expect(chunks).toEqual([ - { type: "reasoning", text: "Let me think" }, - { type: "reasoning", text: " about this" }, - { type: "text", text: "The answer is 42" }, - ]) - }) - - it("should handle complete tag in a single chunk", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Regular text before " } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Complete thought" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " regular text after" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // When a complete tag arrives in one chunk, TagMatcher may not parse it - // This test documents the actual behavior - expect(chunks.length).toBeGreaterThan(0) - expect(chunks[0]).toEqual({ type: "text", text: "Regular text before " }) - }) - - it("should handle incomplete tag at end of stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Incomplete thought" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // TagMatcher should handle incomplete tags and flush remaining content - expect(chunks.length).toBeGreaterThan(0) - expect( - chunks.some( - (c) => (c.type === "text" || c.type === "reasoning") && c.text.includes("Incomplete thought"), - ), - ).toBe(true) - }) - - it("should handle text without any tags", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Just regular text" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " without reasoning" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks).toEqual([ - { type: "text", text: "Just regular text" }, - { type: "text", text: " without reasoning" }, - ]) - }) - - it("should handle tags that start at beginning of stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "reasoning" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " content" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " normal text" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks).toEqual([ - { type: "reasoning", text: "reasoning" }, - { type: "reasoning", text: " content" }, - { type: "text", text: " normal text" }, - ]) - }) - }) - - describe("reasoning_content field", () => { - it("should filter out whitespace-only reasoning_content", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: "\n" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: " " } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: "\t\n " } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Regular content" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Should only have the regular content, not the whitespace-only reasoning - expect(chunks).toEqual([{ type: "text", text: "Regular content" }]) - }) - - it("should yield non-empty reasoning_content", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: "Thinking step 1" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: "\n" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: "Thinking step 2" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Should only yield the non-empty reasoning content - expect(chunks).toEqual([ - { type: "reasoning", text: "Thinking step 1" }, - { type: "reasoning", text: "Thinking step 2" }, - ]) - }) - - it("should handle reasoning_content with leading/trailing whitespace", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: " content with spaces " } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Should yield reasoning with spaces (only pure whitespace is filtered) - expect(chunks).toEqual([{ type: "reasoning", text: " content with spaces " }]) - }) - }) - - describe("Basic functionality", () => { - it("should create stream with correct parameters", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } - }) - - const systemPrompt = "Test system prompt" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message" }] - - const messageGenerator = handler.createMessage(systemPrompt, messages) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "test-model", - temperature: 0, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - stream_options: { include_usage: true }, - }), - undefined, - ) - }) - - it("should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: {} }], - usage: { prompt_tokens: 100, completion_tokens: 50 }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 100, outputTokens: 50 }) - }) - }) - - describe("Tool call handling", () => { - it("should yield tool_call_end events when finish_reason is tool_calls", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_123", - function: { name: "test_tool", arguments: '{"arg":' }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: '"value"}' }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - }, - ], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Should have tool_call_partial and tool_call_end - const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - - expect(partialChunks).toHaveLength(2) - expect(endChunks).toHaveLength(1) - expect(endChunks[0]).toEqual({ type: "tool_call_end", id: "call_123" }) - }) - - it("should yield multiple tool_call_end events for parallel tool calls", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_001", - function: { name: "tool_a", arguments: "{}" }, - }, - { - index: 1, - id: "call_002", - function: { name: "tool_b", arguments: "{}" }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - }, - ], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - expect(endChunks).toHaveLength(2) - expect(endChunks.map((c: any) => c.id).sort()).toEqual(["call_001", "call_002"]) - }) - - it("should not yield tool_call_end when finish_reason is not tool_calls", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { content: "Some text response" }, - finish_reason: "stop", - }, - ], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) - - const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - expect(endChunks).toHaveLength(0) - }) - }) -}) diff --git a/src/api/providers/__tests__/base-provider.spec.ts b/src/api/providers/__tests__/base-provider.spec.ts index ced452f5a55..ffb21128814 100644 --- a/src/api/providers/__tests__/base-provider.spec.ts +++ b/src/api/providers/__tests__/base-provider.spec.ts @@ -1,5 +1,4 @@ -import { Anthropic } from "@anthropic-ai/sdk" - +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import type { ModelInfo } from "@roo-code/types" import { BaseProvider } from "../base-provider" @@ -7,7 +6,7 @@ import type { ApiStream } from "../../transform/stream" // Create a concrete implementation for testing class TestProvider extends BaseProvider { - createMessage(_systemPrompt: string, _messages: Anthropic.Messages.MessageParam[]): ApiStream { + createMessage(_systemPrompt: string, _messages: NeutralMessageParam[]): ApiStream { throw new Error("Not implemented") } diff --git a/src/api/providers/__tests__/baseten.spec.ts b/src/api/providers/__tests__/baseten.spec.ts index e44b201f291..3706101b02e 100644 --- a/src/api/providers/__tests__/baseten.spec.ts +++ b/src/api/providers/__tests__/baseten.spec.ts @@ -24,8 +24,7 @@ vi.mock("@ai-sdk/baseten", () => ({ }), })) -import type { Anthropic } from "@anthropic-ai/sdk" - +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import { basetenDefaultModelId, basetenModels, type BasetenModelId } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" @@ -101,7 +100,7 @@ describe("BasetenHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [ @@ -281,7 +280,7 @@ describe("BasetenHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], @@ -389,7 +388,7 @@ describe("BasetenHandler", () => { describe("error handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/bedrock-error-handling.spec.ts b/src/api/providers/__tests__/bedrock-error-handling.spec.ts index d217984c8da..a40c72cfbe7 100644 --- a/src/api/providers/__tests__/bedrock-error-handling.spec.ts +++ b/src/api/providers/__tests__/bedrock-error-handling.spec.ts @@ -38,7 +38,6 @@ vi.mock("@ai-sdk/amazon-bedrock", () => ({ })) import { AwsBedrockHandler } from "../bedrock" -import type { Anthropic } from "@anthropic-ai/sdk" describe("AwsBedrockHandler Error Handling", () => { let handler: AwsBedrockHandler diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index 2cb09fc56db..5a29c84a9a7 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -37,6 +37,7 @@ vi.mock("@ai-sdk/amazon-bedrock", () => ({ createAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), })) +import type { RooMessageParam } from "../../../core/task-persistence/apiMessages" import { AwsBedrockHandler } from "../bedrock" import { BEDROCK_1M_CONTEXT_MODEL_IDS, @@ -45,8 +46,6 @@ import { ApiProviderError, } from "@roo-code/types" -import type { Anthropic } from "@anthropic-ai/sdk" - describe("AwsBedrockHandler", () => { let handler: AwsBedrockHandler @@ -490,17 +489,14 @@ describe("AwsBedrockHandler", () => { it("should properly pass image content through to streamText via AI SDK messages", async () => { setupMockStreamText() - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: [ { type: "image", - source: { - type: "base64", - data: mockImageData, - media_type: "image/jpeg", - }, + image: mockImageData, + mediaType: "image/jpeg", }, { type: "text", @@ -533,8 +529,8 @@ describe("AwsBedrockHandler", () => { // The AI SDK convertToAiSdkMessages converts images to { type: "image", image: "data:...", mimeType: "..." } const imagePart = userMsg.content.find((p: { type: string }) => p.type === "image") expect(imagePart).toBeDefined() - expect(imagePart.image).toContain("data:image/jpeg;base64,") - expect(imagePart.mimeType).toBe("image/jpeg") + expect(imagePart.image).toBe(mockImageData) + // mediaType is part of the internal format, not on the AI SDK message const textPart = userMsg.content.find((p: { type: string }) => p.type === "text") expect(textPart).toBeDefined() @@ -544,17 +540,14 @@ describe("AwsBedrockHandler", () => { it("should handle multiple images in a single message", async () => { setupMockStreamText() - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: [ { type: "image", - source: { - type: "base64", - data: mockImageData, - media_type: "image/jpeg", - }, + image: mockImageData, + mediaType: "image/jpeg", }, { type: "text", @@ -562,11 +555,8 @@ describe("AwsBedrockHandler", () => { }, { type: "image", - source: { - type: "base64", - data: mockImageData, - media_type: "image/png", - }, + image: mockImageData, + mediaType: "image/png", }, { type: "text", @@ -592,9 +582,9 @@ describe("AwsBedrockHandler", () => { const imageParts = userMsg.content.filter((p: { type: string }) => p.type === "image") expect(imageParts).toHaveLength(2) - expect(imageParts[0].image).toContain("data:image/jpeg;base64,") - expect(imageParts[0].mimeType).toBe("image/jpeg") - expect(imageParts[1].image).toContain("data:image/png;base64,") + expect(imageParts[0].image).toBe(mockImageData) + // mediaType is part of the internal format, not on the AI SDK message + expect(imageParts[1].image).toBe(mockImageData) expect(imageParts[1].mimeType).toBe("image/png") }) }) @@ -761,7 +751,7 @@ describe("AwsBedrockHandler", () => { awsBedrock1MContext: true, }) - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: "Test message", @@ -794,7 +784,7 @@ describe("AwsBedrockHandler", () => { awsBedrock1MContext: false, }) - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: "Test message", @@ -828,7 +818,7 @@ describe("AwsBedrockHandler", () => { awsBedrock1MContext: true, }) - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: "Test message", @@ -881,7 +871,7 @@ describe("AwsBedrockHandler", () => { awsBedrock1MContext: true, }) - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: "Test message", @@ -1013,7 +1003,7 @@ describe("AwsBedrockHandler", () => { awsBedrockServiceTier: "PRIORITY", }) - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: "Test message", @@ -1050,7 +1040,7 @@ describe("AwsBedrockHandler", () => { awsBedrockServiceTier: "FLEX", }) - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: "Test message", @@ -1087,7 +1077,7 @@ describe("AwsBedrockHandler", () => { awsBedrockServiceTier: "PRIORITY", // Try to apply PRIORITY tier }) - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: "Test message", @@ -1122,7 +1112,7 @@ describe("AwsBedrockHandler", () => { // No awsBedrockServiceTier specified }) - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: "Test message", @@ -1192,7 +1182,7 @@ describe("AwsBedrockHandler", () => { awsRegion: "us-east-1", }) - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: "Hello", @@ -1267,7 +1257,7 @@ describe("AwsBedrockHandler", () => { awsRegion: "us-east-1", }) - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: "Hello", diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 32bd3a029a1..112e3d16615 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -23,8 +23,7 @@ vi.mock("@ai-sdk/deepseek", () => ({ }), })) -import type { Anthropic } from "@anthropic-ai/sdk" - +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import { deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" @@ -173,7 +172,7 @@ describe("DeepSeekHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [ @@ -400,7 +399,7 @@ describe("DeepSeekHandler", () => { describe("reasoning content with deepseek-reasoner", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [ @@ -570,7 +569,7 @@ describe("DeepSeekHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index 77c4b10f45d..eb2162dd73d 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -25,8 +25,7 @@ vi.mock("@ai-sdk/fireworks", () => ({ }), })) -import type { Anthropic } from "@anthropic-ai/sdk" - +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import { fireworksDefaultModelId, fireworksModels, type FireworksModelId } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" @@ -363,7 +362,7 @@ describe("FireworksHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [ @@ -730,7 +729,7 @@ describe("FireworksHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 13875499ee6..716be694de8 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -36,8 +36,7 @@ vitest.mock("@ai-sdk/google", async (importOriginal) => { } }) -import { Anthropic } from "@anthropic-ai/sdk" - +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import { type ModelInfo, geminiDefaultModelId, ApiProviderError } from "@roo-code/types" import { t } from "i18next" @@ -102,7 +101,7 @@ describe("GeminiHandler", () => { }) describe("createMessage", () => { - const mockMessages: Anthropic.Messages.MessageParam[] = [ + const mockMessages: NeutralMessageParam[] = [ { role: "user", content: "Hello", @@ -377,7 +376,7 @@ describe("GeminiHandler", () => { }) describe("error telemetry", () => { - const mockMessages: Anthropic.Messages.MessageParam[] = [ + const mockMessages: NeutralMessageParam[] = [ { role: "user", content: "Hello", diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index 9f3a641cb3b..0c45156698a 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -1,56 +1,113 @@ -import OpenAI from "openai" -import { Anthropic } from "@anthropic-ai/sdk" +// npx vitest run api/providers/__tests__/lite-llm.spec.ts -import { LiteLLMHandler } from "../lite-llm" -import { ApiHandlerOptions } from "../../../shared/api" -import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types" - -// Mock vscode first to avoid import errors -vi.mock("vscode", () => ({})) - -// Mock OpenAI -const mockCreate = vi.fn() +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -vi.mock("openai", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - default: vi.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -// Mock model fetching -vi.mock("../fetchers/modelCache", () => ({ - getModels: vi.fn().mockImplementation(() => { - return Promise.resolve({ - [litellmDefaultModelId]: litellmDefaultModelInfo, - "gpt-5": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - gpt5: { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "GPT-5": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt-5-turbo": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt5-preview": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt-5o": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt-5.1": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt-5-mini": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt-4": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "claude-3-opus": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "llama-3": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gpt-4-turbo": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - // Gemini models for thought signature injection tests - "gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gemini-3-flash": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "gemini-2.5-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "google/gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - "vertex_ai/gemini-3-pro": { ...litellmDefaultModelInfo, maxTokens: 8192 }, - }) +const mockLanguageModel = vi.fn() + +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + const provider = Object.assign( + vi.fn(() => ({ modelId: "test-model", provider: "litellm" })), + { + languageModel: mockLanguageModel.mockImplementation((id: string, _config?: unknown) => ({ + modelId: id, + provider: "litellm", + })), + }, + ) + return provider }), - getModelsFromCache: vi.fn().mockReturnValue(undefined), })) +// Mock vscode to avoid import errors +vi.mock("vscode", () => ({})) + +const mockGetModels = vi.fn() +const mockGetModelsFromCache = vi.fn() + +vi.mock("../fetchers/modelCache", () => ({ + getModels: (...args: unknown[]) => mockGetModels(...args), + getModelsFromCache: (...args: unknown[]) => mockGetModelsFromCache(...args), +})) + +import type { RooMessageParam } from "../../../core/task-persistence/apiMessages" +import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types" + +import { ApiHandlerOptions } from "../../../shared/api" + +import { LiteLLMHandler } from "../lite-llm" + +const testModelInfo = { ...litellmDefaultModelInfo, maxTokens: 8192 } + +const allModels: Record = { + [litellmDefaultModelId]: litellmDefaultModelInfo, + "gpt-5": testModelInfo, + gpt5: testModelInfo, + "GPT-5": testModelInfo, + "gpt-5-turbo": testModelInfo, + "gpt5-preview": testModelInfo, + "gpt-5o": testModelInfo, + "gpt-5.1": testModelInfo, + "gpt-5-mini": testModelInfo, + "gpt-4": testModelInfo, + "claude-3-opus": testModelInfo, + "llama-3": testModelInfo, + "gpt-4-turbo": testModelInfo, + "gemini-3-pro": testModelInfo, + "gemini-3-flash": testModelInfo, + "gemini-2.5-pro": testModelInfo, + "google/gemini-3-pro": testModelInfo, + "vertex_ai/gemini-3-pro": testModelInfo, + "bedrock/anthropic.claude-3-sonnet": testModelInfo, +} + +/** Helper to get the transformRequestBody from the last mockLanguageModel call. */ +function getTransformRequestBody(): (body: Record) => Record { + const lastCall = mockLanguageModel.mock.calls[mockLanguageModel.mock.calls.length - 1] + return lastCall[1]?.transformRequestBody +} + +/** Helper to create a minimal async fullStream mock. */ +function mockFullStreamWith(text = "Response") { + async function* mockFullStream() { + yield { type: "text-delta" as const, text } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + raw: {}, + }), + }) +} + +/** Helper to drain a createMessage generator and return chunks. */ +async function drainStream( + handler: LiteLLMHandler, + systemPrompt: string, + messages: RooMessageParam[], + metadata?: Record, +) { + const chunks: unknown[] = [] + for await (const chunk of handler.createMessage(systemPrompt, messages, metadata as any)) { + chunks.push(chunk) + } + return chunks +} + describe("LiteLLMHandler", () => { let handler: LiteLLMHandler let mockOptions: ApiHandlerOptions @@ -62,74 +119,206 @@ describe("LiteLLMHandler", () => { litellmBaseUrl: "http://localhost:4000", litellmModelId: litellmDefaultModelId, } + mockGetModelsFromCache.mockReturnValue(undefined) + mockGetModels.mockResolvedValue(allModels) handler = new LiteLLMHandler(mockOptions) }) + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(LiteLLMHandler) + }) + + it("should use default model ID if not provided", () => { + const h = new LiteLLMHandler({ litellmApiKey: "key" }) + const model = h.getModel() + expect(model.id).toBe(litellmDefaultModelId) + }) + + it("should use cache if available at construction time", () => { + mockGetModelsFromCache.mockReturnValue({ "gpt-4": testModelInfo }) + const h = new LiteLLMHandler({ ...mockOptions, litellmModelId: "gpt-4" }) + const model = h.getModel() + expect(model.id).toBe("gpt-4") + expect(model.info).toMatchObject(testModelInfo) + }) + }) + + describe("fetchModel", () => { + it("returns correct model info after fetching", async () => { + const h = new LiteLLMHandler(mockOptions) + const result = await h.fetchModel() + + expect(mockGetModels).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "litellm", + apiKey: "test-key", + baseUrl: "http://localhost:4000", + }), + ) + expect(result.id).toBe(litellmDefaultModelId) + expect(result.info).toBeDefined() + }) + }) + + describe("getModel", () => { + it("should return model with params", () => { + mockGetModelsFromCache.mockReturnValue({ [litellmDefaultModelId]: testModelInfo }) + const h = new LiteLLMHandler(mockOptions) + const model = h.getModel() + + expect(model.id).toBe(litellmDefaultModelId) + expect(model.info).toBeDefined() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) + }) + + describe("createMessage", () => { + it("should handle streaming responses", async () => { + mockFullStreamWith("Test response") + + const chunks = await drainStream(handler, "You are a helpful assistant", [ + { role: "user", content: "Hello" }, + ]) + + const textChunks = chunks.filter((c: any) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect((textChunks[0] as any).text).toBe("Test response") + }) + + it("should call fetchModel before streaming", async () => { + mockFullStreamWith() + + await drainStream(handler, "test", [{ role: "user", content: "hi" }]) + + expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "litellm" })) + }) + + it("should include tools and toolChoice when provided", async () => { + mockFullStreamWith() + + const mockTools = [ + { + type: "function" as const, + function: { + name: "get_weather", + description: "Get weather", + parameters: { + type: "object", + properties: { location: { type: "string" } }, + required: ["location"], + }, + }, + }, + ] + + await drainStream(handler, "test", [{ role: "user", content: "Hello" }], { + tools: mockTools, + tool_choice: "auto", + }) + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.any(Object), + }), + ) + }) + + it("should handle API errors", async () => { + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield* [] // satisfy require-yield + throw new Error("API Error") + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + await expect(async () => { + await drainStream(handler, "test", [{ role: "user", content: "hi" }]) + }).rejects.toThrow() + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ text: "Test completion" }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + + it("should call fetchModel before completing", async () => { + mockGenerateText.mockResolvedValue({ text: "done" }) + + await handler.completePrompt("test") + + expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "litellm" })) + }) + }) + describe("prompt caching", () => { - it("should add cache control headers when litellmUsePromptCache is enabled", async () => { + it("should apply cache_control via transformRequestBody when litellmUsePromptCache is enabled", async () => { const optionsWithCache: ApiHandlerOptions = { ...mockOptions, litellmUsePromptCache: true, } + // Return model info with supportsPromptCache + mockGetModels.mockResolvedValue({ + [litellmDefaultModelId]: { ...litellmDefaultModelInfo, supportsPromptCache: true }, + }) handler = new LiteLLMHandler(optionsWithCache) - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ + mockFullStreamWith() + + await drainStream(handler, "You are a helpful assistant", [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there!" }, { role: "user", content: "How are you?" }, - ] - - // Mock the stream response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "I'm doing well!" } }], - usage: { - prompt_tokens: 100, - completion_tokens: 50, - cache_creation_input_tokens: 20, - cache_read_input_tokens: 30, - }, - } - }, - } + ]) - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) + const transformRequestBody = getTransformRequestBody() + expect(transformRequestBody).toBeDefined() - const generator = handler.createMessage(systemPrompt, messages) - const results = [] - for await (const chunk of generator) { - results.push(chunk) + // Simulate the wire-format body that @ai-sdk/openai-compatible would produce + const mockBody = { + messages: [ + { role: "system", content: "You are a helpful assistant" }, + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + { role: "user", content: "How are you?" }, + ], + max_tokens: 8192, } - // Verify that create was called with cache control headers - const createCall = mockCreate.mock.calls[0][0] + const transformed = transformRequestBody(mockBody) as Record - // Check system message has cache control in the proper format - expect(createCall.messages[0]).toMatchObject({ + // System message should have cache_control + const msgs = transformed.messages as Record[] + expect(msgs[0]).toMatchObject({ role: "system", content: [ { type: "text", - text: systemPrompt, + text: "You are a helpful assistant", cache_control: { type: "ephemeral" }, }, ], }) - // Check that the last two user messages have cache control - const userMessageIndices = createCall.messages - .map((msg: any, idx: number) => (msg.role === "user" ? idx : -1)) - .filter((idx: number) => idx !== -1) + // Last two user messages should have cache_control + const userMsgIndices = msgs.map((msg, idx) => (msg.role === "user" ? idx : -1)).filter((idx) => idx !== -1) - const lastUserIdx = userMessageIndices[userMessageIndices.length - 1] - const secondLastUserIdx = userMessageIndices[userMessageIndices.length - 2] + const lastUserIdx = userMsgIndices[userMsgIndices.length - 1] + const secondLastUserIdx = userMsgIndices[userMsgIndices.length - 2] - // Check last user message has proper structure with cache control - expect(createCall.messages[lastUserIdx]).toMatchObject({ + expect(msgs[lastUserIdx]).toMatchObject({ role: "user", content: [ { @@ -140,9 +329,8 @@ describe("LiteLLMHandler", () => { ], }) - // Check second last user message (first user message in this case) if (secondLastUserIdx !== -1) { - expect(createCall.messages[secondLastUserIdx]).toMatchObject({ + expect(msgs[secondLastUserIdx]).toMatchObject({ role: "user", content: [ { @@ -153,9 +341,36 @@ describe("LiteLLMHandler", () => { ], }) } + }) - // Verify usage includes cache tokens - const usageChunk = results.find((chunk) => chunk.type === "usage") + it("should yield usage with cache tokens from raw response", async () => { + const optionsWithCache: ApiHandlerOptions = { + ...mockOptions, + litellmUsePromptCache: true, + } + mockGetModels.mockResolvedValue({ + [litellmDefaultModelId]: { ...litellmDefaultModelInfo, supportsPromptCache: true }, + }) + handler = new LiteLLMHandler(optionsWithCache) + + async function* mockFullStream() { + yield { type: "text-delta" as const, text: "Response" } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + raw: { + cache_creation_input_tokens: 20, + prompt_tokens_details: { cached_tokens: 30 }, + }, + }), + }) + + const chunks = await drainStream(handler, "test", [{ role: "user", content: "Hello" }]) + + const usageChunk = chunks.find((c: any) => c.type === "usage") expect(usageChunk).toMatchObject({ type: "usage", inputTokens: 100, @@ -168,44 +383,23 @@ describe("LiteLLMHandler", () => { describe("GPT-5 model handling", () => { it("should use max_completion_tokens instead of max_tokens for GPT-5 models", async () => { - const optionsWithGPT5: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "gpt-5", - } - handler = new LiteLLMHandler(optionsWithGPT5) - - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] - - // Mock the stream response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Hello!" } }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - }, - } - }, - } + handler = new LiteLLMHandler({ ...mockOptions, litellmModelId: "gpt-5" }) - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) + mockFullStreamWith() - const generator = handler.createMessage(systemPrompt, messages) - const results = [] - for await (const chunk of generator) { - results.push(chunk) - } + await drainStream(handler, "You are a helpful assistant", [{ role: "user", content: "Hello" }]) - // Verify that create was called with max_completion_tokens instead of max_tokens - const createCall = mockCreate.mock.calls[0][0] + const transformRequestBody = getTransformRequestBody() + const transformed = transformRequestBody({ + messages: [ + { role: "system", content: "Test" }, + { role: "user", content: "Hello" }, + ], + max_tokens: 8192, + }) as Record - // Should have max_completion_tokens, not max_tokens - expect(createCall.max_completion_tokens).toBeDefined() - expect(createCall.max_tokens).toBeUndefined() + expect(transformed.max_completion_tokens).toBe(8192) + expect(transformed.max_tokens).toBeUndefined() }) it("should use max_completion_tokens for various GPT-5 model variations", async () => { @@ -222,43 +416,23 @@ describe("LiteLLMHandler", () => { for (const modelId of gpt5Variations) { vi.clearAllMocks() + mockGetModelsFromCache.mockReturnValue(undefined) + mockGetModels.mockResolvedValue(allModels) - const optionsWithGPT5: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: modelId, - } - handler = new LiteLLMHandler(optionsWithGPT5) - - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test" }] - - // Mock the stream response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Response" } }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - }, - } - }, - } + handler = new LiteLLMHandler({ ...mockOptions, litellmModelId: modelId }) - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) + mockFullStreamWith() - const generator = handler.createMessage(systemPrompt, messages) - for await (const chunk of generator) { - // Consume the generator - } + await drainStream(handler, "test", [{ role: "user", content: "Test" }]) - // Verify that create was called with max_completion_tokens for this model variation - const createCall = mockCreate.mock.calls[0][0] + const transformRequestBody = getTransformRequestBody() + const transformed = transformRequestBody({ + messages: [{ role: "user", content: "Test" }], + max_tokens: 8192, + }) as Record - expect(createCall.max_completion_tokens).toBeDefined() - expect(createCall.max_tokens).toBeUndefined() + expect(transformed.max_completion_tokens).toBe(8192) + expect(transformed.max_tokens).toBeUndefined() } }) @@ -267,139 +441,66 @@ describe("LiteLLMHandler", () => { for (const modelId of nonGPT5Models) { vi.clearAllMocks() + mockGetModelsFromCache.mockReturnValue(undefined) + mockGetModels.mockResolvedValue(allModels) - const options: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: modelId, - } - handler = new LiteLLMHandler(options) - - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test" }] - - // Mock the stream response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Response" } }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - }, - } - }, - } + handler = new LiteLLMHandler({ ...mockOptions, litellmModelId: modelId }) - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) + mockFullStreamWith() - const generator = handler.createMessage(systemPrompt, messages) - for await (const chunk of generator) { - // Consume the generator - } + await drainStream(handler, "test", [{ role: "user", content: "Test" }]) - // Verify that create was called with max_tokens for non-GPT-5 models - const createCall = mockCreate.mock.calls[0][0] + const transformRequestBody = getTransformRequestBody() + const transformed = transformRequestBody({ + messages: [{ role: "user", content: "Test" }], + max_tokens: 8192, + }) as Record - expect(createCall.max_tokens).toBeDefined() - expect(createCall.max_completion_tokens).toBeUndefined() + expect(transformed.max_tokens).toBe(8192) + expect(transformed.max_completion_tokens).toBeUndefined() } }) - it("should use max_completion_tokens in completePrompt for GPT-5 models", async () => { - const optionsWithGPT5: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "gpt-5", - } - handler = new LiteLLMHandler(optionsWithGPT5) + it("should not set max_completion_tokens when max_tokens is undefined (GPT-5)", async () => { + handler = new LiteLLMHandler({ ...mockOptions, litellmModelId: "gpt-5" }) - mockCreate.mockResolvedValue({ - choices: [{ message: { content: "Test response" } }], - }) + mockFullStreamWith() - await handler.completePrompt("Test prompt") + await drainStream(handler, "test", [{ role: "user", content: "Hello" }]) - // Verify that create was called with max_completion_tokens - const createCall = mockCreate.mock.calls[0][0] + const transformRequestBody = getTransformRequestBody() + const transformed = transformRequestBody({ + messages: [{ role: "user", content: "Test" }], + // No max_tokens + }) as Record - expect(createCall.max_completion_tokens).toBeDefined() - expect(createCall.max_tokens).toBeUndefined() + expect(transformed.max_tokens).toBeUndefined() + expect(transformed.max_completion_tokens).toBeUndefined() }) - it("should not set any max token fields when maxTokens is undefined (GPT-5 streaming)", async () => { - const optionsWithGPT5: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "gpt-5", - } - handler = new LiteLLMHandler(optionsWithGPT5) - - // Force fetchModel to return undefined maxTokens - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "gpt-5", - info: { ...litellmDefaultModelInfo, maxTokens: undefined }, - }) - - // Mock the stream response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Hello!" } }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - }, - } - }, - } - - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) - - const generator = handler.createMessage("You are a helpful assistant", [ - { role: "user", content: "Hello" } as unknown as Anthropic.Messages.MessageParam, - ]) - for await (const _chunk of generator) { - // consume - } - - // Should not include either token field - const createCall = mockCreate.mock.calls[0][0] - expect(createCall.max_tokens).toBeUndefined() - expect(createCall.max_completion_tokens).toBeUndefined() - }) - - it("should not set any max token fields when maxTokens is undefined (GPT-5 completePrompt)", async () => { - const optionsWithGPT5: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "gpt-5", - } - handler = new LiteLLMHandler(optionsWithGPT5) + it("should use max_completion_tokens in completePrompt for GPT-5 models", async () => { + handler = new LiteLLMHandler({ ...mockOptions, litellmModelId: "gpt-5" }) - // Force fetchModel to return undefined maxTokens - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "gpt-5", - info: { ...litellmDefaultModelInfo, maxTokens: undefined }, - }) - - mockCreate.mockResolvedValue({ - choices: [{ message: { content: "Ok" } }], - }) + mockGenerateText.mockResolvedValue({ text: "Test response" }) await handler.completePrompt("Test prompt") - const createCall = mockCreate.mock.calls[0][0] - expect(createCall.max_tokens).toBeUndefined() - expect(createCall.max_completion_tokens).toBeUndefined() + const transformRequestBody = getTransformRequestBody() + const transformed = transformRequestBody({ + messages: [{ role: "user", content: "Test prompt" }], + max_tokens: 8192, + }) as Record + + expect(transformed.max_completion_tokens).toBe(8192) + expect(transformed.max_tokens).toBeUndefined() }) }) describe("Gemini thought signature injection", () => { describe("isGeminiModel detection", () => { it("should detect Gemini 3 models", () => { - const handler = new LiteLLMHandler(mockOptions) - const isGeminiModel = (handler as any).isGeminiModel.bind(handler) + const h = new LiteLLMHandler(mockOptions) + const isGeminiModel = (h as any).isGeminiModel.bind(h) expect(isGeminiModel("gemini-3-pro")).toBe(true) expect(isGeminiModel("gemini-3-flash")).toBe(true) @@ -407,18 +508,17 @@ describe("LiteLLMHandler", () => { }) it("should detect Gemini 2.5 models", () => { - const handler = new LiteLLMHandler(mockOptions) - const isGeminiModel = (handler as any).isGeminiModel.bind(handler) + const h = new LiteLLMHandler(mockOptions) + const isGeminiModel = (h as any).isGeminiModel.bind(h) expect(isGeminiModel("gemini-2.5-pro")).toBe(true) expect(isGeminiModel("gemini-2.5-flash")).toBe(true) }) it("should detect Gemini models with spaces (LiteLLM model groups)", () => { - const handler = new LiteLLMHandler(mockOptions) - const isGeminiModel = (handler as any).isGeminiModel.bind(handler) + const h = new LiteLLMHandler(mockOptions) + const isGeminiModel = (h as any).isGeminiModel.bind(h) - // LiteLLM model groups often use space-separated names with title case expect(isGeminiModel("Gemini 3 Pro")).toBe(true) expect(isGeminiModel("Gemini 3 Flash")).toBe(true) expect(isGeminiModel("gemini 3 pro")).toBe(true) @@ -427,20 +527,19 @@ describe("LiteLLMHandler", () => { }) it("should detect provider-prefixed Gemini models", () => { - const handler = new LiteLLMHandler(mockOptions) - const isGeminiModel = (handler as any).isGeminiModel.bind(handler) + const h = new LiteLLMHandler(mockOptions) + const isGeminiModel = (h as any).isGeminiModel.bind(h) expect(isGeminiModel("google/gemini-3-pro")).toBe(true) expect(isGeminiModel("vertex_ai/gemini-3-pro")).toBe(true) expect(isGeminiModel("vertex/gemini-2.5-pro")).toBe(true) - // Space-separated variants with provider prefix expect(isGeminiModel("google/gemini 3 pro")).toBe(true) expect(isGeminiModel("vertex_ai/gemini 2.5 pro")).toBe(true) }) it("should not detect non-Gemini models", () => { - const handler = new LiteLLMHandler(mockOptions) - const isGeminiModel = (handler as any).isGeminiModel.bind(handler) + const h = new LiteLLMHandler(mockOptions) + const isGeminiModel = (h as any).isGeminiModel.bind(h) expect(isGeminiModel("gpt-4")).toBe(false) expect(isGeminiModel("claude-3-opus")).toBe(false) @@ -450,12 +549,11 @@ describe("LiteLLMHandler", () => { }) describe("injectThoughtSignatureForGemini", () => { - // Base64 encoded "skip_thought_signature_validator" const dummySignature = Buffer.from("skip_thought_signature_validator").toString("base64") it("should inject provider_specific_fields.thought_signature for assistant messages with tool_calls", () => { - const handler = new LiteLLMHandler(mockOptions) - const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler) + const h = new LiteLLMHandler(mockOptions) + const injectThoughtSignature = (h as any).injectThoughtSignatureForGemini.bind(h) const messages = [ { role: "user", content: "Hello" }, @@ -471,14 +569,13 @@ describe("LiteLLMHandler", () => { const result = injectThoughtSignature(messages) - // The first tool call should have provider_specific_fields.thought_signature injected expect(result[1].tool_calls[0].provider_specific_fields).toBeDefined() expect(result[1].tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature) }) it("should not inject if assistant message has no tool_calls", () => { - const handler = new LiteLLMHandler(mockOptions) - const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler) + const h = new LiteLLMHandler(mockOptions) + const injectThoughtSignature = (h as any).injectThoughtSignatureForGemini.bind(h) const messages = [ { role: "user", content: "Hello" }, @@ -487,13 +584,12 @@ describe("LiteLLMHandler", () => { const result = injectThoughtSignature(messages) - // No changes should be made expect(result[1].tool_calls).toBeUndefined() }) it("should always overwrite existing thought_signature", () => { - const handler = new LiteLLMHandler(mockOptions) - const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler) + const h = new LiteLLMHandler(mockOptions) + const injectThoughtSignature = (h as any).injectThoughtSignatureForGemini.bind(h) const existingSignature = "existing_signature_base64" @@ -515,13 +611,12 @@ describe("LiteLLMHandler", () => { const result = injectThoughtSignature(messages) - // Should overwrite with dummy signature (always inject to ensure compatibility) expect(result[1].tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature) }) it("should inject signature into ALL tool calls for parallel calls", () => { - const handler = new LiteLLMHandler(mockOptions) - const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler) + const h = new LiteLLMHandler(mockOptions) + const injectThoughtSignature = (h as any).injectThoughtSignatureForGemini.bind(h) const messages = [ { role: "user", content: "Hello" }, @@ -538,15 +633,14 @@ describe("LiteLLMHandler", () => { const result = injectThoughtSignature(messages) - // ALL tool calls should have the signature expect(result[1].tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature) expect(result[1].tool_calls[1].provider_specific_fields.thought_signature).toBe(dummySignature) expect(result[1].tool_calls[2].provider_specific_fields.thought_signature).toBe(dummySignature) }) it("should preserve existing provider_specific_fields when adding thought_signature", () => { - const handler = new LiteLLMHandler(mockOptions) - const injectThoughtSignature = (handler as any).injectThoughtSignatureForGemini.bind(handler) + const h = new LiteLLMHandler(mockOptions) + const injectThoughtSignature = (h as any).injectThoughtSignatureForGemini.bind(h) const messages = [ { role: "user", content: "Hello" }, @@ -566,358 +660,359 @@ describe("LiteLLMHandler", () => { const result = injectThoughtSignature(messages) - // Should have both existing field and new thought_signature expect(result[1].tool_calls[0].provider_specific_fields.other_field).toBe("value") expect(result[1].tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature) }) }) describe("createMessage integration with Gemini models", () => { - // Base64 encoded "skip_thought_signature_validator" const dummySignature = Buffer.from("skip_thought_signature_validator").toString("base64") - it("should inject thought signatures for Gemini 3 models with native tools", async () => { - const optionsWithGemini: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "gemini-3-pro", - } - handler = new LiteLLMHandler(optionsWithGemini) + it("should inject thought signatures for Gemini models via transformRequestBody", async () => { + handler = new LiteLLMHandler({ ...mockOptions, litellmModelId: "gemini-3-pro" }) - // Mock fetchModel to return a Gemini model - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "gemini-3-pro", - info: { ...litellmDefaultModelInfo, maxTokens: 8192 }, - }) + mockFullStreamWith() - const systemPrompt = "You are a helpful assistant" - // Simulate conversation history with a tool call from a previous model (Claude) - const messages: Anthropic.Messages.MessageParam[] = [ + await drainStream(handler, "You are a helpful assistant", [ { role: "user", content: "Hello" }, { role: "assistant", content: [ { type: "text", text: "I'll help you with that." }, - { type: "tool_use", id: "toolu_123", name: "read_file", input: { path: "test.txt" } }, + { + type: "tool-call", + toolCallId: "toolu_123", + toolName: "read_file", + input: { path: "test.txt" }, + }, ], }, { role: "user", - content: [{ type: "tool_result", tool_use_id: "toolu_123", content: "file contents" }], - }, - { role: "user", content: "Thanks!" }, - ] - - // Mock the stream response - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "You're welcome!" } }], - usage: { - prompt_tokens: 100, - completion_tokens: 20, + content: [ + { + type: "tool-result", + toolCallId: "toolu_123", + toolName: "", + output: { type: "text" as const, value: "file contents" }, }, - } + ], }, - } + { role: "user", content: "Thanks!" }, + ]) - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) + const transformRequestBody = getTransformRequestBody() - // Provide tools and native protocol to trigger the injection - const metadata = { - tools: [ + // Simulate the wire-format body with tool calls + const transformed = transformRequestBody({ + messages: [ + { role: "system", content: "You are a helpful assistant" }, + { role: "user", content: "Hello" }, { - type: "function", - function: { name: "read_file", description: "Read a file", parameters: {} }, + role: "assistant", + content: "I'll help you with that.", + tool_calls: [ + { + id: "toolu_123", + type: "function", + function: { name: "read_file", arguments: '{"path":"test.txt"}' }, + }, + ], }, + { role: "tool", tool_call_id: "toolu_123", content: "file contents" }, + { role: "user", content: "Thanks!" }, ], - } - - const generator = handler.createMessage(systemPrompt, messages, metadata as any) - for await (const _chunk of generator) { - // Consume the generator - } + }) as Record - // Verify that the assistant message with tool_calls has thought_signature injected - const createCall = mockCreate.mock.calls[0][0] - const assistantMessage = createCall.messages.find( - (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0, + const msgs = transformed.messages as Record[] + const assistantMsg = msgs.find( + (msg) => msg.role === "assistant" && (msg.tool_calls as unknown[])?.length > 0, ) - expect(assistantMessage).toBeDefined() - // First tool call should have the thought signature - expect(assistantMessage.tool_calls[0].provider_specific_fields).toBeDefined() - expect(assistantMessage.tool_calls[0].provider_specific_fields.thought_signature).toBe(dummySignature) + expect(assistantMsg).toBeDefined() + const toolCalls = assistantMsg!.tool_calls as Record[] + expect(toolCalls[0].provider_specific_fields).toBeDefined() + expect((toolCalls[0].provider_specific_fields as Record).thought_signature).toBe( + dummySignature, + ) }) it("should not inject thought signatures for non-Gemini models", async () => { - const optionsWithGPT4: ApiHandlerOptions = { - ...mockOptions, - litellmModelId: "gpt-4", - } - handler = new LiteLLMHandler(optionsWithGPT4) + handler = new LiteLLMHandler({ ...mockOptions, litellmModelId: "gpt-4" }) - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "gpt-4", - info: { ...litellmDefaultModelInfo, maxTokens: 8192 }, - }) + mockFullStreamWith() - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "text", text: "I'll help you with that." }, - { type: "tool_use", id: "toolu_123", name: "read_file", input: { path: "test.txt" } }, - ], - }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: "toolu_123", content: "file contents" }], - }, - ] + await drainStream(handler, "test", [{ role: "user", content: "Hello" }]) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Response" } }], - usage: { prompt_tokens: 100, completion_tokens: 20 }, - } - }, - } - - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) + const transformRequestBody = getTransformRequestBody() - const metadata = { - tools: [ + const transformed = transformRequestBody({ + messages: [ + { role: "user", content: "Hello" }, { - type: "function", - function: { name: "read_file", description: "Read a file", parameters: {} }, + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_123", + type: "function", + function: { name: "test", arguments: "{}" }, + }, + ], }, ], - } + }) as Record - const generator = handler.createMessage(systemPrompt, messages, metadata as any) - for await (const _chunk of generator) { - // Consume - } - - // Verify that thought_signature was NOT injected for non-Gemini model - const createCall = mockCreate.mock.calls[0][0] - const assistantMessage = createCall.messages.find( - (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0, + const msgs = transformed.messages as Record[] + const assistantMsg = msgs.find( + (msg) => msg.role === "assistant" && (msg.tool_calls as unknown[])?.length > 0, ) - expect(assistantMessage).toBeDefined() - // Tool calls should not have provider_specific_fields added - expect(assistantMessage.tool_calls[0].provider_specific_fields).toBeUndefined() + expect(assistantMsg).toBeDefined() + const toolCalls = assistantMsg!.tool_calls as Record[] + expect(toolCalls[0].provider_specific_fields).toBeUndefined() }) }) }) describe("tool ID normalization", () => { - it("should truncate tool IDs longer than 64 characters", async () => { - const optionsWithBedrock: ApiHandlerOptions = { + it("should truncate tool IDs longer than 64 characters via transformRequestBody", async () => { + handler = new LiteLLMHandler({ ...mockOptions, litellmModelId: "bedrock/anthropic.claude-3-sonnet", - } - handler = new LiteLLMHandler(optionsWithBedrock) - - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "bedrock/anthropic.claude-3-sonnet", - info: { ...litellmDefaultModelInfo, maxTokens: 8192 }, }) - // Create a tool ID longer than 64 characters - const longToolId = "toolu_" + "a".repeat(70) // 76 characters total + mockFullStreamWith() - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "text", text: "I'll help you with that." }, - { type: "tool_use", id: longToolId, name: "read_file", input: { path: "test.txt" } }, - ], - }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: longToolId, content: "file contents" }], - }, - ] + await drainStream(handler, "test", [{ role: "user", content: "Hello" }]) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Response" } }], - usage: { prompt_tokens: 100, completion_tokens: 20 }, - } - }, - } + const transformRequestBody = getTransformRequestBody() - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) + const longToolId = "toolu_" + "a".repeat(70) - const generator = handler.createMessage(systemPrompt, messages) - for await (const _chunk of generator) { - // Consume - } + const transformed = transformRequestBody({ + messages: [ + { + role: "assistant", + content: "I'll help.", + tool_calls: [ + { id: longToolId, type: "function", function: { name: "read_file", arguments: "{}" } }, + ], + }, + { + role: "tool", + tool_call_id: longToolId, + content: "file contents", + }, + ], + }) as Record - // Verify that tool IDs are truncated to 64 characters or less - const createCall = mockCreate.mock.calls[0][0] - const assistantMessage = createCall.messages.find( - (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0, - ) - const toolMessage = createCall.messages.find((msg: any) => msg.role === "tool") + const msgs = transformed.messages as Record[] + const assistantMsg = msgs.find((msg) => msg.role === "assistant") + const toolMsg = msgs.find((msg) => msg.role === "tool") - expect(assistantMessage).toBeDefined() - expect(assistantMessage.tool_calls[0].id.length).toBeLessThanOrEqual(64) + expect(assistantMsg).toBeDefined() + const toolCalls = assistantMsg!.tool_calls as Record[] + expect((toolCalls[0].id as string).length).toBeLessThanOrEqual(64) - expect(toolMessage).toBeDefined() - expect(toolMessage.tool_call_id.length).toBeLessThanOrEqual(64) + expect(toolMsg).toBeDefined() + expect((toolMsg!.tool_call_id as string).length).toBeLessThanOrEqual(64) }) it("should not modify tool IDs that are already within 64 characters", async () => { - const optionsWithBedrock: ApiHandlerOptions = { + handler = new LiteLLMHandler({ ...mockOptions, litellmModelId: "bedrock/anthropic.claude-3-sonnet", - } - handler = new LiteLLMHandler(optionsWithBedrock) - - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "bedrock/anthropic.claude-3-sonnet", - info: { ...litellmDefaultModelInfo, maxTokens: 8192 }, }) - // Create a tool ID within 64 characters - const shortToolId = "toolu_01ABC123" // Well under 64 characters + mockFullStreamWith() - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "text", text: "I'll help you with that." }, - { type: "tool_use", id: shortToolId, name: "read_file", input: { path: "test.txt" } }, - ], - }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: shortToolId, content: "file contents" }], - }, - ] + await drainStream(handler, "test", [{ role: "user", content: "Hello" }]) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Response" } }], - usage: { prompt_tokens: 100, completion_tokens: 20 }, - } - }, - } + const transformRequestBody = getTransformRequestBody() - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), - }) + const shortToolId = "toolu_01ABC123" - const generator = handler.createMessage(systemPrompt, messages) - for await (const _chunk of generator) { - // Consume - } - - // Verify that tool IDs are unchanged - const createCall = mockCreate.mock.calls[0][0] - const assistantMessage = createCall.messages.find( - (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0, - ) - const toolMessage = createCall.messages.find((msg: any) => msg.role === "tool") + const transformed = transformRequestBody({ + messages: [ + { + role: "assistant", + content: "I'll help.", + tool_calls: [ + { id: shortToolId, type: "function", function: { name: "read_file", arguments: "{}" } }, + ], + }, + { + role: "tool", + tool_call_id: shortToolId, + content: "file contents", + }, + ], + }) as Record - expect(assistantMessage).toBeDefined() - expect(assistantMessage.tool_calls[0].id).toBe(shortToolId) + const msgs = transformed.messages as Record[] + const assistantMsg = msgs.find((msg) => msg.role === "assistant") + const toolMsg = msgs.find((msg) => msg.role === "tool") - expect(toolMessage).toBeDefined() - expect(toolMessage.tool_call_id).toBe(shortToolId) + const toolCalls = assistantMsg!.tool_calls as Record[] + expect(toolCalls[0].id).toBe(shortToolId) + expect(toolMsg!.tool_call_id).toBe(shortToolId) }) it("should maintain uniqueness with hash suffix when truncating", async () => { - const optionsWithBedrock: ApiHandlerOptions = { + handler = new LiteLLMHandler({ ...mockOptions, litellmModelId: "bedrock/anthropic.claude-3-sonnet", - } - handler = new LiteLLMHandler(optionsWithBedrock) - - vi.spyOn(handler as any, "fetchModel").mockResolvedValue({ - id: "bedrock/anthropic.claude-3-sonnet", - info: { ...litellmDefaultModelInfo, maxTokens: 8192 }, }) - // Create two tool IDs that differ only near the end + mockFullStreamWith() + + await drainStream(handler, "test", [{ role: "user", content: "Hello" }]) + + const transformRequestBody = getTransformRequestBody() + const longToolId1 = "toolu_" + "a".repeat(60) + "_suffix1" const longToolId2 = "toolu_" + "a".repeat(60) + "_suffix2" - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [ - { type: "text", text: "I'll help." }, - { type: "tool_use", id: longToolId1, name: "read_file", input: { path: "test1.txt" } }, - { type: "tool_use", id: longToolId2, name: "read_file", input: { path: "test2.txt" } }, - ], - }, - { - role: "user", - content: [ - { type: "tool_result", tool_use_id: longToolId1, content: "file1 contents" }, - { type: "tool_result", tool_use_id: longToolId2, content: "file2 contents" }, - ], + const transformed = transformRequestBody({ + messages: [ + { + role: "assistant", + content: "I'll help.", + tool_calls: [ + { id: longToolId1, type: "function", function: { name: "read_file", arguments: "{}" } }, + { id: longToolId2, type: "function", function: { name: "read_file", arguments: "{}" } }, + ], + }, + ], + }) as Record + + const msgs = transformed.messages as Record[] + const assistantMsg = msgs.find((msg) => msg.role === "assistant") + const toolCalls = assistantMsg!.tool_calls as Record[] + + expect(toolCalls).toHaveLength(2) + + const id1 = toolCalls[0].id as string + const id2 = toolCalls[1].id as string + + expect(id1.length).toBeLessThanOrEqual(64) + expect(id2.length).toBeLessThanOrEqual(64) + expect(id1).not.toBe(id2) + }) + }) + + describe("processUsageMetrics", () => { + it("should correctly process usage metrics including cache and cost", async () => { + class TestLiteLLMHandler extends LiteLLMHandler { + public testProcessUsageMetrics(usage: Record) { + return this.processUsageMetrics(usage as any) + } + } + + mockGetModelsFromCache.mockReturnValue({ + [litellmDefaultModelId]: litellmDefaultModelInfo, + }) + const h = new TestLiteLLMHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + details: {}, + raw: { + cache_creation_input_tokens: 20, + prompt_tokens_details: { cached_tokens: 30 }, }, - ] + } + + const result = h.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBe(20) + expect(result.cacheReadTokens).toBe(30) + expect(result.totalCost).toEqual(expect.any(Number)) + }) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - choices: [{ delta: { content: "Response" } }], - usage: { prompt_tokens: 100, completion_tokens: 20 }, - } + it("should handle prompt_cache_miss_tokens as cache write", async () => { + class TestLiteLLMHandler extends LiteLLMHandler { + public testProcessUsageMetrics(usage: Record) { + return this.processUsageMetrics(usage as any) + } + } + + mockGetModelsFromCache.mockReturnValue({ + [litellmDefaultModelId]: litellmDefaultModelInfo, + }) + const h = new TestLiteLLMHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + details: {}, + raw: { + prompt_cache_miss_tokens: 15, + prompt_cache_hit_tokens: 25, }, } - mockCreate.mockReturnValue({ - withResponse: vi.fn().mockResolvedValue({ data: mockStream }), + const result = h.testProcessUsageMetrics(usage) + + expect(result.cacheWriteTokens).toBe(15) + expect(result.cacheReadTokens).toBe(25) + }) + + it("should handle missing cache metrics gracefully", async () => { + class TestLiteLLMHandler extends LiteLLMHandler { + public testProcessUsageMetrics(usage: Record) { + return this.processUsageMetrics(usage as any) + } + } + + mockGetModelsFromCache.mockReturnValue({ + [litellmDefaultModelId]: litellmDefaultModelInfo, }) + const h = new TestLiteLLMHandler(mockOptions) - const generator = handler.createMessage(systemPrompt, messages) - for await (const _chunk of generator) { - // Consume + const usage = { + inputTokens: 100, + outputTokens: 50, + details: {}, + raw: {}, } - // Verify that truncated tool IDs are unique (hash suffix ensures this) - const createCall = mockCreate.mock.calls[0][0] - const assistantMessage = createCall.messages.find( - (msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0, - ) + const result = h.testProcessUsageMetrics(usage) - expect(assistantMessage).toBeDefined() - expect(assistantMessage.tool_calls).toHaveLength(2) + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() + }) - const id1 = assistantMessage.tool_calls[0].id - const id2 = assistantMessage.tool_calls[1].id + it("should fall back to details.cachedInputTokens when raw is missing", async () => { + class TestLiteLLMHandler extends LiteLLMHandler { + public testProcessUsageMetrics(usage: Record) { + return this.processUsageMetrics(usage as any) + } + } - // Both should be truncated to 64 characters - expect(id1.length).toBeLessThanOrEqual(64) - expect(id2.length).toBeLessThanOrEqual(64) + mockGetModelsFromCache.mockReturnValue({ + [litellmDefaultModelId]: litellmDefaultModelInfo, + }) + const h = new TestLiteLLMHandler(mockOptions) - // They should be different (hash suffix ensures uniqueness) - expect(id1).not.toBe(id2) + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { cachedInputTokens: 15 }, + raw: undefined, + } + + const result = h.testProcessUsageMetrics(usage) + + expect(result.cacheReadTokens).toBe(15) }) }) }) diff --git a/src/api/providers/__tests__/lm-studio-timeout.spec.ts b/src/api/providers/__tests__/lm-studio-timeout.spec.ts index 659fcaaf670..d6d59f5e2fd 100644 --- a/src/api/providers/__tests__/lm-studio-timeout.spec.ts +++ b/src/api/providers/__tests__/lm-studio-timeout.spec.ts @@ -1,41 +1,31 @@ // npx vitest run api/providers/__tests__/lm-studio-timeout.spec.ts -import { LmStudioHandler } from "../lm-studio" -import { ApiHandlerOptions } from "../../../shared/api" - -// Mock the timeout config utility -vitest.mock("../utils/timeout-config", () => ({ - getApiRequestTimeout: vitest.fn(), +const { mockCreateOpenAICompatible } = vi.hoisted(() => ({ + mockCreateOpenAICompatible: vi.fn(() => vi.fn(() => ({ modelId: "llama2", provider: "lmstudio" }))), })) -import { getApiRequestTimeout } from "../utils/timeout-config" +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: mockCreateOpenAICompatible, +})) -// Mock OpenAI -const mockOpenAIConstructor = vitest.fn() -vitest.mock("openai", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: vitest.fn().mockImplementation((config) => { - mockOpenAIConstructor(config) - return { - chat: { - completions: { - create: vitest.fn(), - }, - }, - } - }), + ...actual, + streamText: vi.fn(), + generateText: vi.fn(), } }) -describe("LmStudioHandler timeout configuration", () => { +import { LmStudioHandler } from "../lm-studio" +import { ApiHandlerOptions } from "../../../shared/api" + +describe("LmStudioHandler provider configuration", () => { beforeEach(() => { - vitest.clearAllMocks() + vi.clearAllMocks() }) - it("should use default timeout of 600 seconds when no configuration is set", () => { - ;(getApiRequestTimeout as any).mockReturnValue(600000) - + it("should create provider with correct baseURL from options", () => { const options: ApiHandlerOptions = { apiModelId: "llama2", lmStudioModelId: "llama2", @@ -44,37 +34,63 @@ describe("LmStudioHandler timeout configuration", () => { new LmStudioHandler(options) - expect(getApiRequestTimeout).toHaveBeenCalled() - expect(mockOpenAIConstructor).toHaveBeenCalledWith( + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( expect.objectContaining({ + name: "lmstudio", baseURL: "http://localhost:1234/v1", apiKey: "noop", - timeout: 600000, // 600 seconds in milliseconds }), ) }) - it("should use custom timeout when configuration is set", () => { - ;(getApiRequestTimeout as any).mockReturnValue(1200000) // 20 minutes + it("should use default baseURL when none is provided", () => { + const options: ApiHandlerOptions = { + apiModelId: "llama2", + lmStudioModelId: "llama2", + } + + new LmStudioHandler(options) + + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "http://localhost:1234/v1", + }), + ) + }) + it("should use default baseURL when empty string is provided", () => { const options: ApiHandlerOptions = { apiModelId: "llama2", lmStudioModelId: "llama2", - lmStudioBaseUrl: "http://localhost:1234", + lmStudioBaseUrl: "", } new LmStudioHandler(options) - expect(mockOpenAIConstructor).toHaveBeenCalledWith( + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( expect.objectContaining({ - timeout: 1200000, // 1200 seconds in milliseconds + baseURL: "http://localhost:1234/v1", }), ) }) - it("should handle zero timeout (no timeout)", () => { - ;(getApiRequestTimeout as any).mockReturnValue(0) + it("should use custom baseURL when provided", () => { + const options: ApiHandlerOptions = { + apiModelId: "llama2", + lmStudioModelId: "llama2", + lmStudioBaseUrl: "http://custom-host:5678", + } + new LmStudioHandler(options) + + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "http://custom-host:5678/v1", + }), + ) + }) + + it("should always use 'noop' as the API key", () => { const options: ApiHandlerOptions = { apiModelId: "llama2", lmStudioModelId: "llama2", @@ -82,9 +98,9 @@ describe("LmStudioHandler timeout configuration", () => { new LmStudioHandler(options) - expect(mockOpenAIConstructor).toHaveBeenCalledWith( + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( expect.objectContaining({ - timeout: 0, // No timeout + apiKey: "noop", }), ) }) diff --git a/src/api/providers/__tests__/lmstudio-native-tools.spec.ts b/src/api/providers/__tests__/lmstudio-native-tools.spec.ts index cca543a269b..3a1d5ae33aa 100644 --- a/src/api/providers/__tests__/lmstudio-native-tools.spec.ts +++ b/src/api/providers/__tests__/lmstudio-native-tools.spec.ts @@ -1,45 +1,35 @@ // npx vitest run api/providers/__tests__/lmstudio-native-tools.spec.ts -// Mock OpenAI client - must come before other imports -const mockCreate = vi.fn() -vi.mock("openai", () => { +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: vi.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: vi.fn(), } }) +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + return vi.fn(() => ({ + modelId: "local-model", + provider: "lmstudio", + })) + }), +})) + import { LmStudioHandler } from "../lm-studio" -import { NativeToolCallParser } from "../../../core/assistant-message/NativeToolCallParser" import type { ApiHandlerOptions } from "../../../shared/api" -describe("LmStudioHandler Native Tools", () => { +describe("LmStudioHandler Native Tools (AI SDK)", () => { let handler: LmStudioHandler let mockOptions: ApiHandlerOptions - const testTools = [ - { - type: "function" as const, - function: { - name: "test_tool", - description: "A test tool", - parameters: { - type: "object", - properties: { - arg1: { type: "string", description: "First argument" }, - }, - required: ["arg1"], - }, - }, - }, - ] - beforeEach(() => { vi.clearAllMocks() @@ -49,132 +39,65 @@ describe("LmStudioHandler Native Tools", () => { lmStudioBaseUrl: "http://localhost:1234", } handler = new LmStudioHandler(mockOptions) - - // Clear NativeToolCallParser state before each test - NativeToolCallParser.clearRawChunkState() }) - describe("Native Tool Calling Support", () => { - it("should include tools in request when model supports native tools and tools are provided", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) + describe("Native Tool Calling Support via AI SDK", () => { + it("should pass tools to streamText when tools are provided in metadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - await stream.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - type: "function", - function: expect.objectContaining({ - name: "test_tool", - }), - }), - ]), - }), - ) - // parallel_tool_calls should be true by default when not explicitly set - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).toHaveProperty("parallel_tool_calls", true) - }) - - it("should include tool_choice when provided", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", - tools: testTools, - tool_choice: "auto", + tools: [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { + type: "object", + properties: { + arg1: { type: "string", description: "First argument" }, + }, + required: ["arg1"], + }, + }, + }, + ], }) - await stream.next() - expect(mockCreate).toHaveBeenCalledWith( + // Drain the stream + for await (const _chunk of stream) { + // consume + } + + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - tool_choice: "auto", + tools: expect.any(Object), }), ) }) - it("should always include tools and tool_choice in request (tools are always present after PR #10841)", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) + it("should yield tool_call_start, tool_call_delta, and tool_call_end chunks", async () => { + async function* mockFullStream() { + yield { type: "tool-input-start", id: "call_lmstudio_123", toolName: "test_tool" } + yield { type: "tool-input-delta", id: "call_lmstudio_123", delta: '{"arg1":"value"}' } + yield { type: "tool-input-end", id: "call_lmstudio_123" } + } - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - await stream.next() - - const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - expect(callArgs).toHaveProperty("tools") - expect(callArgs).toHaveProperty("tool_choice") - // parallel_tool_calls should be true by default when not explicitly set - expect(callArgs).toHaveProperty("parallel_tool_calls", true) - }) - - it("should yield tool_call_partial chunks during streaming", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_lmstudio_123", - function: { - name: "test_tool", - arguments: '{"arg1":', - }, - }, - ], - }, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '"value"}', - }, - }, - ], - }, - }, - ], - } - }, - })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", - tools: testTools, }) const chunks = [] @@ -183,195 +106,112 @@ describe("LmStudioHandler Native Tools", () => { } expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, + type: "tool_call_start", id: "call_lmstudio_123", name: "test_tool", - arguments: '{"arg1":', }) expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"value"}', + type: "tool_call_delta", + id: "call_lmstudio_123", + delta: '{"arg1":"value"}', }) - }) - - it("should set parallel_tool_calls based on metadata", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - parallelToolCalls: true, + expect(chunks).toContainEqual({ + type: "tool_call_end", + id: "call_lmstudio_123", }) - await stream.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - parallel_tool_calls: true, - }), - ) }) - it("should yield tool_call_end events when finish_reason is tool_calls", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_lmstudio_test", - function: { - name: "test_tool", - arguments: '{"arg1":"value"}', - }, - }, - ], - }, - }, - ], - } - yield { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - }, - ], - } - }, - })) + it("should handle reasoning content alongside tool calls", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "Thinking about this..." } + yield { type: "tool-input-start", id: "call_after_think", toolName: "test_tool" } + yield { type: "tool-input-delta", id: "call_after_think", delta: '{"arg1":"result"}' } + yield { type: "tool-input-end", id: "call_after_think" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", - tools: testTools, }) const chunks = [] for await (const chunk of stream) { - // Simulate what Task.ts does: when we receive tool_call_partial, - // process it through NativeToolCallParser to populate rawChunkTracker - if (chunk.type === "tool_call_partial") { - NativeToolCallParser.processRawChunk({ - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - }) - } chunks.push(chunk) } - // Should have tool_call_partial and tool_call_end - const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + // Should have reasoning, tool_call_start, tool_call_delta, and tool_call_end + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - expect(partialChunks).toHaveLength(1) + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("Thinking about this...") + expect(startChunks).toHaveLength(1) expect(endChunks).toHaveLength(1) - expect(endChunks[0].id).toBe("call_lmstudio_test") }) - it("should work with parallel tool calls disabled (sends false)", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Response" } }], - } - }, - })) + it("should handle multiple sequential tool calls", async () => { + async function* mockFullStream() { + yield { type: "tool-input-start", id: "call_1", toolName: "tool_a" } + yield { type: "tool-input-delta", id: "call_1", delta: '{"x":"1"}' } + yield { type: "tool-input-end", id: "call_1" } + yield { type: "tool-input-start", id: "call_2", toolName: "tool_b" } + yield { type: "tool-input-delta", id: "call_2", delta: '{"y":"2"}' } + yield { type: "tool-input-end", id: "call_2" } + } - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - parallelToolCalls: false, + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - await stream.next() - - // When parallelToolCalls is false, the parameter should be sent as false - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).toHaveProperty("parallel_tool_calls", false) - }) - - it("should handle reasoning content alongside tool calls", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - content: "Thinking about this...", - }, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_after_think", - function: { - name: "test_tool", - arguments: '{"arg1":"result"}', - }, - }, - ], - }, - }, - ], - } - yield { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - }, - ], - } - }, - })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", - tools: testTools, }) const chunks = [] for await (const chunk of stream) { - if (chunk.type === "tool_call_partial") { - NativeToolCallParser.processRawChunk({ - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - }) - } chunks.push(chunk) } - // Should have reasoning, tool_call_partial, and tool_call_end - const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") - const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + const startChunks = chunks.filter((chunk) => chunk.type === "tool_call_start") const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - expect(reasoningChunks).toHaveLength(1) - expect(reasoningChunks[0].text).toBe("Thinking about this...") - expect(partialChunks).toHaveLength(1) - expect(endChunks).toHaveLength(1) + expect(startChunks).toHaveLength(2) + expect(endChunks).toHaveLength(2) + expect(startChunks[0].name).toBe("tool_a") + expect(startChunks[1].name).toBe("tool_b") + }) + + it("should pass tool_choice to streamText when provided", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tool_choice: "auto", + }) + for await (const _chunk of stream) { + // drain + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + toolChoice: "auto", + }), + ) }) }) }) diff --git a/src/api/providers/__tests__/lmstudio.spec.ts b/src/api/providers/__tests__/lmstudio.spec.ts index 0adebdeea7a..9cbe6d24b91 100644 --- a/src/api/providers/__tests__/lmstudio.spec.ts +++ b/src/api/providers/__tests__/lmstudio.spec.ts @@ -1,68 +1,35 @@ -// Mock OpenAI client - must come before other imports -const mockCreate = vi.fn() -vi.mock("openai", () => { +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: vi.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate.mockImplementation(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - choices: [ - { - message: { role: "assistant", content: "Test response" }, - finish_reason: "stop", - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - } - - return { - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - }, - } - }), - }, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -import type { Anthropic } from "@anthropic-ai/sdk" +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "local-model", + provider: "lmstudio", + })) + }), +})) + +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" +import { openAiModelInfoSaneDefaults } from "@roo-code/types" -import { LmStudioHandler } from "../lm-studio" import type { ApiHandlerOptions } from "../../../shared/api" +import { LmStudioHandler, getLmStudioModels } from "../lm-studio" + describe("LmStudioHandler", () => { let handler: LmStudioHandler let mockOptions: ApiHandlerOptions @@ -74,7 +41,7 @@ describe("LmStudioHandler", () => { lmStudioBaseUrl: "http://localhost:1234", } handler = new LmStudioHandler(mockOptions) - mockCreate.mockClear() + vi.clearAllMocks() }) describe("constructor", () => { @@ -90,18 +57,69 @@ describe("LmStudioHandler", () => { }) expect(handlerWithoutUrl).toBeInstanceOf(LmStudioHandler) }) + + it("should handle empty string base URL", () => { + const handlerWithEmptyUrl = new LmStudioHandler({ + apiModelId: "local-model", + lmStudioModelId: "local-model", + lmStudioBaseUrl: "", + }) + expect(handlerWithEmptyUrl).toBeInstanceOf(LmStudioHandler) + }) + }) + + describe("getModel", () => { + it("should return model info with sane defaults", () => { + const modelInfo = handler.getModel() + expect(modelInfo.id).toBe(mockOptions.lmStudioModelId) + expect(modelInfo.info).toBeDefined() + expect(modelInfo.info.maxTokens).toBe(openAiModelInfoSaneDefaults.maxTokens) + expect(modelInfo.info.contextWindow).toBe(openAiModelInfoSaneDefaults.contextWindow) + }) + + it("should return empty string id when no model ID provided", () => { + const handlerWithoutModel = new LmStudioHandler({ + lmStudioBaseUrl: "http://localhost:1234", + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe("") + expect(model.info).toBeDefined() + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) }) describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", - content: "Hello!", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], }, ] it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }), + }) + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { @@ -114,8 +132,35 @@ describe("LmStudioHandler", () => { expect(textChunks[0].text).toBe("Test response") }) + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(5) + }) + it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) + mockStreamText.mockImplementation(() => { + throw new Error("API Error") + }) const stream = handler.createMessage(systemPrompt, messages) @@ -123,45 +168,136 @@ describe("LmStudioHandler", () => { for await (const _chunk of stream) { // Should not reach here } - }).rejects.toThrow("Please check the LM Studio developer logs to debug what went wrong") + }).rejects.toThrow() + }) + + it("should pass speculative decoding providerOptions when enabled", async () => { + const speculativeHandler = new LmStudioHandler({ + ...mockOptions, + lmStudioSpeculativeDecodingEnabled: true, + lmStudioDraftModelId: "draft-model", + }) + + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const stream = speculativeHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // drain + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: { + lmstudio: { draft_model: "draft-model" }, + }, + }), + ) + }) + + it("should NOT pass providerOptions when speculative decoding is disabled", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // drain + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions).toBeUndefined() + }) + + it("should handle tool call streaming", async () => { + async function* mockFullStream() { + yield { type: "tool-input-start", id: "call_123", toolName: "test_tool" } + yield { type: "tool-input-delta", id: "call_123", delta: '{"arg1":"value"}' } + yield { type: "tool-input-end", id: "call_123" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual({ + type: "tool_call_start", + id: "call_123", + name: "test_tool", + }) + expect(chunks).toContainEqual({ + type: "tool_call_delta", + id: "call_123", + delta: '{"arg1":"value"}', + }) + expect(chunks).toContainEqual({ + type: "tool_call_end", + id: "call_123", + }) }) }) describe("completePrompt", () => { it("should complete prompt successfully", async () => { + mockGenerateText.mockResolvedValue({ text: "Test response" }) + const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") - expect(mockCreate).toHaveBeenCalledWith({ - model: mockOptions.lmStudioModelId, - messages: [{ role: "user", content: "Test prompt" }], - temperature: 0, - stream: false, + }) + + it("should pass speculative decoding providerOptions when enabled", async () => { + const speculativeHandler = new LmStudioHandler({ + ...mockOptions, + lmStudioSpeculativeDecodingEnabled: true, + lmStudioDraftModelId: "draft-model", }) + + mockGenerateText.mockResolvedValue({ text: "response" }) + + await speculativeHandler.completePrompt("Test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: { + lmstudio: { draft_model: "draft-model" }, + }, + }), + ) }) it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow( - "Please check the LM Studio developer logs to debug what went wrong", - ) + mockGenerateText.mockRejectedValue(new Error("API Error")) + await expect(handler.completePrompt("Test prompt")).rejects.toThrow() }) it("should handle empty response", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [{ message: { content: "" } }], - }) + mockGenerateText.mockResolvedValue({ text: "" }) const result = await handler.completePrompt("Test prompt") expect(result).toBe("") }) }) +}) - describe("getModel", () => { - it("should return model info", () => { - const modelInfo = handler.getModel() - expect(modelInfo.id).toBe(mockOptions.lmStudioModelId) - expect(modelInfo.info).toBeDefined() - expect(modelInfo.info.maxTokens).toBe(-1) - expect(modelInfo.info.contextWindow).toBe(128_000) - }) +describe("getLmStudioModels", () => { + it("should be exported as a function", () => { + expect(typeof getLmStudioModels).toBe("function") }) }) diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 86cb5e01947..863729db658 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -1,381 +1,284 @@ -// npx vitest run src/api/providers/__tests__/minimax.spec.ts - -vitest.mock("vscode", () => ({ - workspace: { - getConfiguration: vitest.fn().mockReturnValue({ - get: vitest.fn().mockReturnValue(600), // Default timeout in seconds - }), - }, +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), })) -import { Anthropic } from "@anthropic-ai/sdk" - -import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types" - -import { MiniMaxHandler } from "../minimax" - -vitest.mock("@anthropic-ai/sdk", () => { - const mockCreate = vitest.fn() +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - Anthropic: vitest.fn(() => ({ - messages: { - create: mockCreate, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "MiniMax-M2", + provider: "minimax", + })) + }), +})) + +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" +import { minimaxDefaultModelId, minimaxModels } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { MiniMaxHandler } from "../minimax" + describe("MiniMaxHandler", () => { let handler: MiniMaxHandler - let mockCreate: any + let mockOptions: ApiHandlerOptions beforeEach(() => { - vitest.clearAllMocks() - const anthropicInstance = (Anthropic as unknown as any)() - mockCreate = anthropicInstance.messages.create + vi.clearAllMocks() + mockOptions = { + minimaxApiKey: "test-api-key", + apiModelId: "MiniMax-M2", + minimaxBaseUrl: "https://api.minimax.io/v1", + } + handler = new MiniMaxHandler(mockOptions) }) - describe("International MiniMax (default)", () => { - beforeEach(() => { - handler = new MiniMaxHandler({ - minimaxApiKey: "test-minimax-api-key", - minimaxBaseUrl: "https://api.minimax.io/v1", + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(MiniMaxHandler) + expect(handler.getModel().id).toBe("MiniMax-M2") + }) + + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new MiniMaxHandler({ + ...mockOptions, + apiModelId: undefined, }) + expect(handlerWithoutModel.getModel().id).toBe(minimaxDefaultModelId) }) - it("should use the correct international MiniMax base URL by default", () => { - new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" }) - expect(Anthropic).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.minimax.io/anthropic", - }), - ) + it("should use default base URL if not provided", () => { + const handlerWithoutBaseUrl = new MiniMaxHandler({ + ...mockOptions, + minimaxBaseUrl: undefined, + }) + expect(handlerWithoutBaseUrl).toBeInstanceOf(MiniMaxHandler) }) - it("should convert /v1 endpoint to /anthropic endpoint", () => { - new MiniMaxHandler({ - minimaxApiKey: "test-minimax-api-key", - minimaxBaseUrl: "https://api.minimax.io/v1", + it("should handle China base URL", () => { + const handlerChina = new MiniMaxHandler({ + ...mockOptions, + minimaxBaseUrl: "https://api.minimaxi.com/v1", }) - expect(Anthropic).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.minimax.io/anthropic", - }), - ) + expect(handlerChina).toBeInstanceOf(MiniMaxHandler) }) - it("should use the provided API key", () => { - const minimaxApiKey = "test-minimax-api-key" - new MiniMaxHandler({ minimaxApiKey }) - expect(Anthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey })) + it("should strip /anthropic suffix and use /v1 endpoint", () => { + const handlerAnthropicUrl = new MiniMaxHandler({ + ...mockOptions, + minimaxBaseUrl: "https://api.minimax.io/anthropic" as any, + }) + expect(handlerAnthropicUrl).toBeInstanceOf(MiniMaxHandler) }) + }) - it("should return default model when no model is specified", () => { + describe("getModel", () => { + it("should return model info for valid model ID", () => { const model = handler.getModel() - expect(model.id).toBe(minimaxDefaultModelId) - expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId]) + expect(model.id).toBe("MiniMax-M2") + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(16_384) + expect(model.info.contextWindow).toBe(192_000) + expect(model.info.supportsPromptCache).toBe(true) }) - it("should return specified model when valid model is provided", () => { - const testModelId: MinimaxModelId = "MiniMax-M2" - const handlerWithModel = new MiniMaxHandler({ - apiModelId: testModelId, - minimaxApiKey: "test-minimax-api-key", + it("should return default model info for unknown model ID", () => { + const handlerUnknown = new MiniMaxHandler({ + ...mockOptions, + apiModelId: "unknown-model", }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(minimaxModels[testModelId]) + const model = handlerUnknown.getModel() + expect(model.id).toBe("unknown-model") + // Falls back to default model info + expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId]) }) - it("should return MiniMax-M2 model with correct configuration", () => { - const testModelId: MinimaxModelId = "MiniMax-M2" - const handlerWithModel = new MiniMaxHandler({ - apiModelId: testModelId, - minimaxApiKey: "test-minimax-api-key", + it("should return default model if no model ID is provided", () => { + const handlerNoModel = new MiniMaxHandler({ + ...mockOptions, + apiModelId: undefined, }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(minimaxModels[testModelId]) - expect(model.info.contextWindow).toBe(192_000) - expect(model.info.maxTokens).toBe(16_384) - expect(model.info.supportsPromptCache).toBe(true) - expect(model.info.cacheWritesPrice).toBe(0.375) - expect(model.info.cacheReadsPrice).toBe(0.03) + const model = handlerNoModel.getModel() + expect(model.id).toBe(minimaxDefaultModelId) + expect(model.info).toBeDefined() }) - it("should return MiniMax-M2-Stable model with correct configuration", () => { - const testModelId: MinimaxModelId = "MiniMax-M2-Stable" - const handlerWithModel = new MiniMaxHandler({ - apiModelId: testModelId, - minimaxApiKey: "test-minimax-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(minimaxModels[testModelId]) - expect(model.info.contextWindow).toBe(192_000) - expect(model.info.maxTokens).toBe(16_384) - expect(model.info.supportsPromptCache).toBe(true) - expect(model.info.cacheWritesPrice).toBe(0.375) - expect(model.info.cacheReadsPrice).toBe(0.03) + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") }) }) - describe("China MiniMax", () => { - beforeEach(() => { - handler = new MiniMaxHandler({ - minimaxApiKey: "test-minimax-api-key", - minimaxBaseUrl: "https://api.minimaxi.com/v1", - }) - }) + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: NeutralMessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] - it("should use the correct China MiniMax base URL", () => { - new MiniMaxHandler({ - minimaxApiKey: "test-minimax-api-key", - minimaxBaseUrl: "https://api.minimaxi.com/v1", + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, }) - expect(Anthropic).toHaveBeenCalledWith( - expect.objectContaining({ baseURL: "https://api.minimaxi.com/anthropic" }), - ) - }) - it("should convert China /v1 endpoint to /anthropic endpoint", () => { - new MiniMaxHandler({ - minimaxApiKey: "test-minimax-api-key", - minimaxBaseUrl: "https://api.minimaxi.com/v1", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, }) - expect(Anthropic).toHaveBeenCalledWith( - expect.objectContaining({ baseURL: "https://api.minimaxi.com/anthropic" }), - ) - }) - it("should use the provided API key for China", () => { - const minimaxApiKey = "test-minimax-api-key" - new MiniMaxHandler({ minimaxApiKey, minimaxBaseUrl: "https://api.minimaxi.com/v1" }) - expect(Anthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey })) - }) + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(minimaxDefaultModelId) - expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId]) + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response") }) - }) - describe("Default behavior", () => { - it("should default to international base URL when none is specified", () => { - const handlerDefault = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" }) - expect(Anthropic).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.minimax.io/anthropic", - }), - ) + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - const model = handlerDefault.getModel() - expect(model.id).toBe(minimaxDefaultModelId) - expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId]) - }) + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) - it("should default to MiniMax-M2 model", () => { - const handlerDefault = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" }) - const model = handlerDefault.getModel() - expect(model.id).toBe("MiniMax-M2") - }) - }) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) - describe("API Methods", () => { - beforeEach(() => { - handler = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" }) - }) + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - it("completePrompt method should return text from MiniMax API", async () => { - const expectedResponse = "This is a test response from MiniMax" - mockCreate.mockResolvedValueOnce({ - content: [{ type: "text", text: expectedResponse }], - }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(5) }) - it("should handle errors in completePrompt", async () => { - const errorMessage = "MiniMax API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow() - }) + it("should handle reasoning content in stream", async () => { + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think..." } + yield { type: "text-delta", text: "Answer" } + } - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from MiniMax stream" - - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - type: "content_block_start", - index: 0, - content_block: { type: "text", text: testContent }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }), }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("Let me think...") }) - it("createMessage should yield usage data from stream", async () => { - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 20, - }, - }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), + it("should handle tool calls in stream", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-123", + toolName: "get_weather", + } + yield { + type: "tool-input-delta", + id: "tool-123", + delta: '{"city":"London"}', + } + yield { + type: "tool-input-end", + id: "tool-123", + } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }), }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20 }) + const toolStartChunks = chunks.filter((c) => c.type === "tool_call_start") + expect(toolStartChunks).toHaveLength(1) + expect(toolStartChunks[0].name).toBe("get_weather") + expect(toolStartChunks[0].id).toBe("tool-123") }) - it("createMessage should pass correct parameters to MiniMax client", async () => { - const modelId: MinimaxModelId = "MiniMax-M2" - const modelInfo = minimaxModels[modelId] - const handlerWithModel = new MiniMaxHandler({ - apiModelId: modelId, - minimaxApiKey: "test-minimax-api-key", - }) + it("should handle errors in stream", async () => { + async function* mockFullStream() { + yield + throw new Error("Stream error") + } - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - const systemPrompt = "Test system prompt for MiniMax" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for MiniMax" }] - - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: modelId, - max_tokens: Math.min(modelInfo.maxTokens, Math.ceil(modelInfo.contextWindow * 0.2)), - temperature: 1, - system: expect.any(Array), - messages: expect.any(Array), - stream: true, - }), - ) + const stream = handler.createMessage(systemPrompt, messages) + await expect(async () => { + for await (const _chunk of stream) { + // consume + } + }).rejects.toThrow() }) + }) - it("should use temperature 1 by default", async () => { - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", }) - const messageGenerator = handler.createMessage("test", []) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("Test completion") + expect(mockGenerateText).toHaveBeenCalledWith( expect.objectContaining({ - temperature: 1, + prompt: "Test prompt", }), ) }) - it("should handle thinking blocks in stream", async () => { - const thinkingContent = "Let me think about this..." - - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - type: "content_block_start", - index: 0, - content_block: { type: "thinking", thinking: thinkingContent }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "reasoning", text: thinkingContent }) - }) - - it("should handle tool calls in stream", async () => { - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - type: "content_block_start", - index: 0, - content_block: { - type: "tool_use", - id: "tool-123", - name: "get_weather", - input: { city: "London" }, - }, - }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - type: "content_block_stop", - index: 0, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly - expect(firstChunk.value).toEqual({ - type: "tool_call_partial", - index: 0, - id: "tool-123", - name: "get_weather", - arguments: undefined, - }) + it("should handle errors in completePrompt", async () => { + mockGenerateText.mockRejectedValue(new Error("API error")) + await expect(handler.completePrompt("test")).rejects.toThrow("API error") }) }) diff --git a/src/api/providers/__tests__/mistral.spec.ts b/src/api/providers/__tests__/mistral.spec.ts index 0cac881dffe..37dc3a3c7c2 100644 --- a/src/api/providers/__tests__/mistral.spec.ts +++ b/src/api/providers/__tests__/mistral.spec.ts @@ -24,8 +24,7 @@ vi.mock("@ai-sdk/mistral", () => ({ createMistral: mockCreateMistral, })) -import type { Anthropic } from "@anthropic-ai/sdk" - +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import { mistralDefaultModelId, mistralModels, type MistralModelId } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" @@ -102,7 +101,7 @@ describe("MistralHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [ @@ -329,7 +328,7 @@ describe("MistralHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/moonshot.spec.ts b/src/api/providers/__tests__/moonshot.spec.ts index 1bfd482fd94..01ef2e44606 100644 --- a/src/api/providers/__tests__/moonshot.spec.ts +++ b/src/api/providers/__tests__/moonshot.spec.ts @@ -23,8 +23,7 @@ vi.mock("@ai-sdk/openai-compatible", () => ({ }), })) -import type { Anthropic } from "@anthropic-ai/sdk" - +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import { moonshotDefaultModelId } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" @@ -121,7 +120,7 @@ describe("MoonshotHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [ @@ -344,7 +343,7 @@ describe("MoonshotHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 73327a3012c..b735c3966a7 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -1,608 +1,417 @@ // npx vitest run api/providers/__tests__/native-ollama.spec.ts -import { NativeOllamaHandler } from "../native-ollama" -import { ApiHandlerOptions } from "../../../shared/api" -import { getOllamaModels } from "../fetchers/ollama" +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -// Mock the ollama package -const mockChat = vitest.fn() -vitest.mock("ollama", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - Ollama: vitest.fn().mockImplementation(() => ({ - chat: mockChat, - })), - Message: vitest.fn(), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -// Mock the getOllamaModels function -vitest.mock("../fetchers/ollama", () => ({ - getOllamaModels: vitest.fn(), +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "llama2", + provider: "ollama", + })) + }), +})) + +vi.mock("../fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + getModelsFromCache: vi.fn().mockReturnValue(undefined), })) -const mockGetOllamaModels = vitest.mocked(getOllamaModels) +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" + +import { ollamaDefaultModelInfo } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { NativeOllamaHandler } from "../native-ollama" describe("NativeOllamaHandler", () => { let handler: NativeOllamaHandler + let mockOptions: ApiHandlerOptions beforeEach(() => { - vitest.clearAllMocks() - - // Default mock for getOllamaModels - mockGetOllamaModels.mockResolvedValue({ - llama2: { - contextWindow: 4096, - maxTokens: 4096, - supportsImages: false, - supportsPromptCache: false, - }, - }) - - const options: ApiHandlerOptions = { - apiModelId: "llama2", + vi.clearAllMocks() + mockOptions = { ollamaModelId: "llama2", ollamaBaseUrl: "http://localhost:11434", + ollamaApiKey: "test-key", } - - handler = new NativeOllamaHandler(options) + handler = new NativeOllamaHandler(mockOptions) }) - describe("createMessage", () => { - it("should stream messages from Ollama", async () => { - // Mock the chat response as an async generator - mockChat.mockImplementation(async function* () { - yield { - message: { content: "Hello" }, - eval_count: undefined, - prompt_eval_count: undefined, - } - yield { - message: { content: " world" }, - eval_count: 2, - prompt_eval_count: 10, - } - }) - - const systemPrompt = "You are a helpful assistant" - const messages = [{ role: "user" as const, content: "Hi there" }] - - const stream = handler.createMessage(systemPrompt, messages) - const results = [] - - for await (const chunk of stream) { - results.push(chunk) - } - - expect(results).toHaveLength(3) - expect(results[0]).toEqual({ type: "text", text: "Hello" }) - expect(results[1]).toEqual({ type: "text", text: " world" }) - expect(results[2]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 2 }) + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(NativeOllamaHandler) + expect(handler.getModel().id).toBe("llama2") }) - it("should not include num_ctx by default", async () => { - // Mock the chat response - mockChat.mockImplementation(async function* () { - yield { message: { content: "Response" } } - }) - - const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) - - // Consume the stream - for await (const _ of stream) { - // consume stream - } - - // Verify that num_ctx was NOT included in the options - expect(mockChat).toHaveBeenCalledWith( + it("should configure the provider with correct base URL", () => { + expect(createOpenAICompatible).toHaveBeenCalledWith( expect.objectContaining({ - options: expect.not.objectContaining({ - num_ctx: expect.anything(), - }), + name: "ollama", + baseURL: "http://localhost:11434/v1", }), ) }) - it("should include num_ctx when explicitly set via ollamaNumCtx", async () => { - const options: ApiHandlerOptions = { - apiModelId: "llama2", - ollamaModelId: "llama2", - ollamaBaseUrl: "http://localhost:11434", - ollamaNumCtx: 8192, // Explicitly set num_ctx - } - - handler = new NativeOllamaHandler(options) - - // Mock the chat response - mockChat.mockImplementation(async function* () { - yield { message: { content: "Response" } } + it("should strip trailing slashes from base URL", () => { + new NativeOllamaHandler({ + ...mockOptions, + ollamaBaseUrl: "http://localhost:11434/", }) - - const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) - - // Consume the stream - for await (const _ of stream) { - // consume stream - } - - // Verify that num_ctx was included with the specified value - expect(mockChat).toHaveBeenCalledWith( + expect(createOpenAICompatible).toHaveBeenCalledWith( expect.objectContaining({ - options: expect.objectContaining({ - num_ctx: 8192, - }), + baseURL: "http://localhost:11434/v1", }), ) }) - it("should handle DeepSeek R1 models with reasoning detection", async () => { - const options: ApiHandlerOptions = { - apiModelId: "deepseek-r1", - ollamaModelId: "deepseek-r1", - ollamaBaseUrl: "http://localhost:11434", - } - - handler = new NativeOllamaHandler(options) - - // Mock response with thinking tags - mockChat.mockImplementation(async function* () { - yield { message: { content: "Let me think" } } - yield { message: { content: " about this" } } - yield { message: { content: "The answer is 42" } } + it("should use default base URL when not provided", () => { + new NativeOllamaHandler({ + ...mockOptions, + ollamaBaseUrl: undefined, }) - - const stream = handler.createMessage("System", [{ role: "user" as const, content: "Question?" }]) - const results = [] - - for await (const chunk of stream) { - results.push(chunk) - } - - // Should detect reasoning vs regular text - expect(results.some((r) => r.type === "reasoning")).toBe(true) - expect(results.some((r) => r.type === "text")).toBe(true) - }) - }) - - describe("completePrompt", () => { - it("should complete a prompt without streaming", async () => { - mockChat.mockResolvedValue({ - message: { content: "This is the response" }, - }) - - const result = await handler.completePrompt("Tell me a joke") - - expect(mockChat).toHaveBeenCalledWith({ - model: "llama2", - messages: [{ role: "user", content: "Tell me a joke" }], - stream: false, - options: { - temperature: 0, - }, - }) - expect(result).toBe("This is the response") + expect(createOpenAICompatible).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "http://localhost:11434/v1", + }), + ) }) - it("should not include num_ctx in completePrompt by default", async () => { - mockChat.mockResolvedValue({ - message: { content: "Response" }, + it("should use 'ollama' as default API key when not provided", () => { + new NativeOllamaHandler({ + ...mockOptions, + ollamaApiKey: undefined, }) - - await handler.completePrompt("Test prompt") - - // Verify that num_ctx was NOT included in the options - expect(mockChat).toHaveBeenCalledWith( + expect(createOpenAICompatible).toHaveBeenCalledWith( expect.objectContaining({ - options: expect.not.objectContaining({ - num_ctx: expect.anything(), - }), + apiKey: "ollama", }), ) }) - it("should include num_ctx in completePrompt when explicitly set", async () => { - const options: ApiHandlerOptions = { - apiModelId: "llama2", - ollamaModelId: "llama2", - ollamaBaseUrl: "http://localhost:11434", - ollamaNumCtx: 4096, // Explicitly set num_ctx - } - - handler = new NativeOllamaHandler(options) - - mockChat.mockResolvedValue({ - message: { content: "Response" }, + it("should use provided API key", () => { + new NativeOllamaHandler({ + ...mockOptions, + ollamaApiKey: "my-secret-key", }) - - await handler.completePrompt("Test prompt") - - // Verify that num_ctx was included with the specified value - expect(mockChat).toHaveBeenCalledWith( + expect(createOpenAICompatible).toHaveBeenCalledWith( expect.objectContaining({ - options: expect.objectContaining({ - num_ctx: 4096, - }), + apiKey: "my-secret-key", }), ) }) }) - describe("error handling", () => { - it("should handle connection refused errors", async () => { - const error = new Error("ECONNREFUSED") as any - error.code = "ECONNREFUSED" - mockChat.mockRejectedValue(error) - - const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) - - await expect(async () => { - for await (const _ of stream) { - // consume stream - } - }).rejects.toThrow("Ollama service is not running") + describe("getModel", () => { + it("should return model info using defaults when cache is empty", () => { + const model = handler.getModel() + expect(model.id).toBe("llama2") + expect(model.info).toEqual(ollamaDefaultModelInfo) }) - it("should handle model not found errors", async () => { - const error = new Error("Not found") as any - error.status = 404 - mockChat.mockRejectedValue(error) + it("should return empty string model ID when no model is configured", () => { + const handlerNoModel = new NativeOllamaHandler({ + ...mockOptions, + ollamaModelId: undefined, + }) + const model = handlerNoModel.getModel() + expect(model.id).toBe("") + expect(model.info).toEqual(ollamaDefaultModelInfo) + }) - const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) - await expect(async () => { - for await (const _ of stream) { - // consume stream - } - }).rejects.toThrow("Model llama2 not found in Ollama") + it("should use default temperature of 0", () => { + const handlerNoTemp = new NativeOllamaHandler({ + ...mockOptions, + modelTemperature: undefined, + }) + const model = handlerNoTemp.getModel() + expect(model.temperature).toBe(0) }) - }) - describe("getModel", () => { - it("should return the configured model", () => { - const model = handler.getModel() - expect(model.id).toBe("llama2") - expect(model.info).toBeDefined() + it("should use custom temperature when specified", () => { + const handlerCustomTemp = new NativeOllamaHandler({ + ...mockOptions, + modelTemperature: 0.7, + }) + const model = handlerCustomTemp.getModel() + expect(model.temperature).toBe(0.7) }) }) - describe("tool calling", () => { - it("should include tools when tools are provided", async () => { - // Model metadata should not gate tool inclusion; metadata.tools controls it. - mockGetOllamaModels.mockResolvedValue({ - "llama3.2": { - contextWindow: 128000, - maxTokens: 4096, - supportsImages: true, - supportsPromptCache: false, - }, - }) + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: NeutralMessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] - const options: ApiHandlerOptions = { - apiModelId: "llama3.2", - ollamaModelId: "llama3.2", - ollamaBaseUrl: "http://localhost:11434", + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world" } } - handler = new NativeOllamaHandler(options) - - // Mock the chat response - mockChat.mockImplementation(async function* () { - yield { message: { content: "I will use the tool" } } + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, }) - const tools = [ - { - type: "function" as const, - function: { - name: "get_weather", - description: "Get the weather for a location", - parameters: { - type: "object", - properties: { - location: { type: "string", description: "The city name" }, - }, - required: ["location"], - }, - }, - }, - ] - - const stream = handler.createMessage( - "System", - [{ role: "user" as const, content: "What's the weather?" }], - { taskId: "test", tools }, - ) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) - // Consume the stream - for await (const _ of stream) { - // consume stream + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - // Verify tools were passed to the API - expect(mockChat).toHaveBeenCalledWith( - expect.objectContaining({ - tools: [ - { - type: "function", - function: { - name: "get_weather", - description: "Get the weather for a location", - parameters: { - type: "object", - properties: { - location: { type: "string", description: "The city name" }, - }, - required: ["location"], - }, - }, - }, - ], - }), - ) + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(2) + expect(textChunks[0].text).toBe("Hello") + expect(textChunks[1].text).toBe(" world") }) - it("should include tools even when model metadata doesn't advertise tool support", async () => { - // Model metadata should not gate tool inclusion; metadata.tools controls it. - mockGetOllamaModels.mockResolvedValue({ - llama2: { - contextWindow: 4096, - maxTokens: 4096, - supportsImages: false, - supportsPromptCache: false, - }, + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, }) - // Mock the chat response - mockChat.mockImplementation(async function* () { - yield { message: { content: "Response without tools" } } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, }) - const tools = [ - { - type: "function" as const, - function: { - name: "get_weather", - description: "Get the weather", - parameters: { type: "object", properties: {} }, - }, - }, - ] + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }], { - taskId: "test", - tools, + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(5) + }) + + it("should delegate to super.createMessage when num_ctx is not set", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }), }) - // Consume the stream + const stream = handler.createMessage(systemPrompt, messages) for await (const _ of stream) { - // consume stream + // consume } - // Verify tools were passed - expect(mockChat).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.any(Array), + // Should be called without providerOptions (base class call) + expect(mockStreamText).toHaveBeenCalledWith( + expect.not.objectContaining({ + providerOptions: expect.anything(), }), ) }) - it("should not include tools when no tools are provided", async () => { - // Model metadata should not gate tool inclusion; metadata.tools controls it. - mockGetOllamaModels.mockResolvedValue({ - "llama3.2": { - contextWindow: 128000, - maxTokens: 4096, - supportsImages: true, - supportsPromptCache: false, - }, + it("should pass num_ctx via providerOptions when ollamaNumCtx is set", async () => { + const handlerWithNumCtx = new NativeOllamaHandler({ + ...mockOptions, + ollamaNumCtx: 8192, }) - const options: ApiHandlerOptions = { - apiModelId: "llama3.2", - ollamaModelId: "llama3.2", - ollamaBaseUrl: "http://localhost:11434", + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } } - handler = new NativeOllamaHandler(options) - - // Mock the chat response - mockChat.mockImplementation(async function* () { - yield { message: { content: "Response" } } - }) - - const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }], { - taskId: "test", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }), }) - // Consume the stream + const stream = handlerWithNumCtx.createMessage(systemPrompt, messages) for await (const _ of stream) { - // consume stream + // consume } - // Verify tools were NOT passed - expect(mockChat).toHaveBeenCalledWith( - expect.not.objectContaining({ - tools: expect.anything(), + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: { ollama: { num_ctx: 8192 } }, }), ) }) - it("should yield tool_call_partial when model returns tool calls", async () => { - // Model metadata should not gate tool inclusion; metadata.tools controls it. - mockGetOllamaModels.mockResolvedValue({ - "llama3.2": { - contextWindow: 128000, - maxTokens: 4096, - supportsImages: true, - supportsPromptCache: false, - }, - }) - - const options: ApiHandlerOptions = { - apiModelId: "llama3.2", - ollamaModelId: "llama3.2", - ollamaBaseUrl: "http://localhost:11434", + it("should handle errors through handleAiSdkError", async () => { + async function* mockFullStream() { + yield undefined // Need a yield before throwing (C9) + throw new Error("Connection refused") } - handler = new NativeOllamaHandler(options) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({}), + }) - // Mock the chat response with tool calls - mockChat.mockImplementation(async function* () { - yield { - message: { - content: "", - tool_calls: [ - { - function: { - name: "get_weather", - arguments: { location: "San Francisco" }, - }, - }, - ], - }, + const stream = handler.createMessage(systemPrompt, messages) + await expect(async () => { + for await (const _ of stream) { + // consume } + }).rejects.toThrow() + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", }) - const tools = [ - { - type: "function" as const, - function: { - name: "get_weather", - description: "Get the weather for a location", - parameters: { - type: "object", - properties: { - location: { type: "string" }, - }, - required: ["location"], - }, - }, - }, - ] + const result = await handler.completePrompt("Test prompt") - const stream = handler.createMessage( - "System", - [{ role: "user" as const, content: "What's the weather in SF?" }], - { taskId: "test", tools }, + expect(result).toBe("Test completion") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), ) + }) - const results = [] - for await (const chunk of stream) { - results.push(chunk) - } - - // Should yield a tool_call_partial chunk - const toolCallChunk = results.find((r) => r.type === "tool_call_partial") - expect(toolCallChunk).toBeDefined() - expect(toolCallChunk).toEqual({ - type: "tool_call_partial", - index: 0, - id: "ollama-tool-0", - name: "get_weather", - arguments: JSON.stringify({ location: "San Francisco" }), + it("should delegate to super.completePrompt when num_ctx is not set", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", }) + + await handler.completePrompt("Test prompt") + + // Should be called without providerOptions (base class call) + expect(mockGenerateText).toHaveBeenCalledWith( + expect.not.objectContaining({ + providerOptions: expect.anything(), + }), + ) }) - it("should yield tool_call_end events after tool_call_partial chunks", async () => { - // Model metadata should not gate tool inclusion; metadata.tools controls it. - mockGetOllamaModels.mockResolvedValue({ - "llama3.2": { - contextWindow: 128000, - maxTokens: 4096, - supportsImages: true, - supportsPromptCache: false, - }, + it("should pass num_ctx via providerOptions when ollamaNumCtx is set", async () => { + const handlerWithNumCtx = new NativeOllamaHandler({ + ...mockOptions, + ollamaNumCtx: 16384, }) - const options: ApiHandlerOptions = { - apiModelId: "llama3.2", - ollamaModelId: "llama3.2", - ollamaBaseUrl: "http://localhost:11434", - } + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) - handler = new NativeOllamaHandler(options) + await handlerWithNumCtx.completePrompt("Test prompt") - // Mock the chat response with multiple tool calls - mockChat.mockImplementation(async function* () { + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: { ollama: { num_ctx: 16384 } }, + }), + ) + }) + }) + + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: NeutralMessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { yield { - message: { - content: "", - tool_calls: [ - { - function: { - name: "get_weather", - arguments: { location: "San Francisco" }, - }, - }, - { - function: { - name: "get_time", - arguments: { timezone: "PST" }, - }, - }, - ], - }, + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }), }) - const tools = [ - { - type: "function" as const, - function: { - name: "get_weather", - description: "Get the weather for a location", - parameters: { - type: "object", - properties: { location: { type: "string" } }, - required: ["location"], - }, - }, - }, - { - type: "function" as const, - function: { - name: "get_time", - description: "Get the current time in a timezone", - parameters: { - type: "object", - properties: { timezone: { type: "string" } }, - required: ["timezone"], + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, }, }, - }, - ] - - const stream = handler.createMessage( - "System", - [{ role: "user" as const, content: "What's the weather and time in SF?" }], - { taskId: "test", tools }, - ) + ], + }) - const results = [] + const chunks: any[] = [] for await (const chunk of stream) { - results.push(chunk) + chunks.push(chunk) } - // Should yield tool_call_partial chunks - const toolCallPartials = results.filter((r) => r.type === "tool_call_partial") - expect(toolCallPartials).toHaveLength(2) - - // Should yield tool_call_end events for each tool call - const toolCallEnds = results.filter((r) => r.type === "tool_call_end") - expect(toolCallEnds).toHaveLength(2) - expect(toolCallEnds[0]).toEqual({ type: "tool_call_end", id: "ollama-tool-0" }) - expect(toolCallEnds[1]).toEqual({ type: "tool_call_end", id: "ollama-tool-1" }) - - // tool_call_end should come after tool_call_partial - // Find the last tool_call_partial index - let lastPartialIndex = -1 - for (let i = results.length - 1; i >= 0; i--) { - if (results[i].type === "tool_call_partial") { - lastPartialIndex = i - break - } - } - const firstEndIndex = results.findIndex((r) => r.type === "tool_call_end") - expect(firstEndIndex).toBeGreaterThan(lastPartialIndex) + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].name).toBe("read_file") + expect(toolCallStartChunks[0].id).toBe("tool-call-1") }) }) }) diff --git a/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts b/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts index 608f639ed44..9d4567383e7 100644 --- a/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts +++ b/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts @@ -1,5 +1,37 @@ // cd src && npx vitest run api/providers/__tests__/openai-codex-native-tool-calls.spec.ts +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockCreateOpenAI, mockCaptureException } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockCreateOpenAI: vi.fn(), + mockCaptureException: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + } +}) + +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: mockCreateOpenAI.mockImplementation(() => ({ + responses: vi.fn(() => ({ + modelId: "gpt-5.2-2025-12-11", + provider: "openai.responses", + })), + })), +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: (...args: unknown[]) => mockCaptureException(...args), + }, + }, +})) + import { beforeEach, describe, expect, it, vi } from "vitest" import { OpenAiCodexHandler } from "../openai-codex" @@ -12,7 +44,7 @@ describe("OpenAiCodexHandler native tool calls", () => { let mockOptions: ApiHandlerOptions beforeEach(() => { - vi.restoreAllMocks() + vi.clearAllMocks() NativeToolCallParser.clearRawChunkState() NativeToolCallParser.clearAllStreamingToolCalls() @@ -23,52 +55,20 @@ describe("OpenAiCodexHandler native tool calls", () => { handler = new OpenAiCodexHandler(mockOptions) }) - it("yields tool_call_partial chunks when API returns function_call-only response", async () => { + it("yields tool_call_start and tool_call_delta chunks when API returns function_call-only response", async () => { vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") - // Mock OpenAI SDK streaming (preferred path). - ;(handler as any).client = { - responses: { - create: vi.fn().mockResolvedValue({ - async *[Symbol.asyncIterator]() { - yield { - type: "response.output_item.added", - item: { - type: "function_call", - call_id: "call_1", - name: "attempt_completion", - arguments: "", - }, - output_index: 0, - } - yield { - type: "response.function_call_arguments.delta", - delta: '{"result":"hi"}', - // Note: intentionally omit call_id + name to simulate tool-call-only streams. - item_id: "fc_1", - output_index: 0, - } - yield { - type: "response.completed", - response: { - id: "resp_1", - status: "completed", - output: [ - { - type: "function_call", - call_id: "call_1", - name: "attempt_completion", - arguments: '{"result":"hi"}', - }, - ], - usage: { input_tokens: 1, output_tokens: 1 }, - }, - } - }, - }), - }, - } + // Mock AI SDK streamText to return tool-call stream parts + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "tool-input-start" as const, id: "call_1", toolName: "attempt_completion" } + yield { type: "tool-input-delta" as const, id: "call_1", delta: '{"result":"hi"}' } + })(), + usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), + }) const stream = handler.createMessage("system", [{ role: "user", content: "hello" } as any], { taskId: "t", @@ -89,12 +89,20 @@ describe("OpenAiCodexHandler native tool calls", () => { } } - const toolChunks = chunks.filter((c) => c.type === "tool_call_partial") - expect(toolChunks.length).toBeGreaterThan(0) - expect(toolChunks[0]).toMatchObject({ - type: "tool_call_partial", + // AI SDK emits tool-input-start → tool_call_start, tool-input-delta → tool_call_delta + const toolStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + expect(toolStartChunks.length).toBeGreaterThan(0) + expect(toolStartChunks[0]).toMatchObject({ + type: "tool_call_start", id: "call_1", name: "attempt_completion", }) + expect(toolDeltaChunks.length).toBeGreaterThan(0) + expect(toolDeltaChunks[0]).toMatchObject({ + type: "tool_call_delta", + id: "call_1", + delta: '{"result":"hi"}', + }) }) }) diff --git a/src/api/providers/__tests__/openai-native-reasoning.spec.ts b/src/api/providers/__tests__/openai-native-reasoning.spec.ts index ebad23ee118..fff45ea7112 100644 --- a/src/api/providers/__tests__/openai-native-reasoning.spec.ts +++ b/src/api/providers/__tests__/openai-native-reasoning.spec.ts @@ -1,7 +1,7 @@ // npx vitest run api/providers/__tests__/openai-native-reasoning.spec.ts -import type { Anthropic } from "@anthropic-ai/sdk" import type { ModelMessage } from "ai" +import type { NeutralMessageParam } from "../../../core/task-persistence" import { stripPlainTextReasoningBlocks, @@ -16,15 +16,13 @@ describe("OpenAI Native reasoning helpers", () => { // ─────────────────────────────────────────────────────────── describe("stripPlainTextReasoningBlocks", () => { it("passes through user messages unchanged", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] + const messages: NeutralMessageParam[] = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] const result = stripPlainTextReasoningBlocks(messages) expect(result).toEqual(messages) }) it("passes through assistant messages with only text blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, ] const result = stripPlainTextReasoningBlocks(messages) @@ -32,20 +30,20 @@ describe("OpenAI Native reasoning helpers", () => { }) it("passes through string-content assistant messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [{ role: "assistant", content: "Hello" }] + const messages: NeutralMessageParam[] = [{ role: "assistant", content: "Hello" }] const result = stripPlainTextReasoningBlocks(messages) expect(result).toEqual(messages) }) it("strips plain-text reasoning blocks from assistant content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "assistant", content: [ { type: "reasoning", text: "Let me think...", - } as unknown as Anthropic.Messages.ContentBlockParam, + }, { type: "text", text: "The answer is 42" }, ], }, @@ -56,14 +54,14 @@ describe("OpenAI Native reasoning helpers", () => { }) it("removes assistant messages whose content becomes empty after filtering", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "assistant", content: [ { type: "reasoning", text: "Thinking only...", - } as unknown as Anthropic.Messages.ContentBlockParam, + }, ], }, ] @@ -71,25 +69,25 @@ describe("OpenAI Native reasoning helpers", () => { expect(result).toHaveLength(0) }) - it("preserves tool_use blocks alongside stripped reasoning", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + it("preserves tool-call blocks alongside stripped reasoning", () => { + const messages: NeutralMessageParam[] = [ { role: "assistant", content: [ - { type: "reasoning", text: "Thinking..." } as unknown as Anthropic.Messages.ContentBlockParam, - { type: "tool_use", id: "call_1", name: "read_file", input: { path: "a.ts" } }, + { type: "reasoning", text: "Thinking..." }, + { type: "tool-call", toolCallId: "call_1", toolName: "read_file", input: { path: "a.ts" } }, ], }, ] const result = stripPlainTextReasoningBlocks(messages) expect(result).toHaveLength(1) expect(result[0].content).toEqual([ - { type: "tool_use", id: "call_1", name: "read_file", input: { path: "a.ts" } }, + { type: "tool-call", toolCallId: "call_1", toolName: "read_file", input: { path: "a.ts" } }, ]) }) it("does NOT strip blocks that have encrypted_content (those are not plain-text reasoning)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "assistant", content: [ @@ -97,7 +95,7 @@ describe("OpenAI Native reasoning helpers", () => { type: "reasoning", text: "summary", encrypted_content: "abc123", - } as unknown as Anthropic.Messages.ContentBlockParam, + } as unknown as NeutralMessageParam["content"] extends (infer U)[] ? U : never, { type: "text", text: "Response" }, ], }, @@ -109,12 +107,12 @@ describe("OpenAI Native reasoning helpers", () => { }) it("handles multiple messages correctly", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [{ type: "text", text: "Q1" }] }, { role: "assistant", content: [ - { type: "reasoning", text: "Think1" } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "reasoning", text: "Think1" }, { type: "text", text: "A1" }, ], }, @@ -122,7 +120,7 @@ describe("OpenAI Native reasoning helpers", () => { { role: "assistant", content: [ - { type: "reasoning", text: "Think2" } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "reasoning", text: "Think2" }, { type: "text", text: "A2" }, ], }, @@ -139,7 +137,7 @@ describe("OpenAI Native reasoning helpers", () => { // ─────────────────────────────────────────────────────────── describe("collectEncryptedReasoningItems", () => { it("returns empty array when no encrypted reasoning items exist", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [{ type: "text", text: "Hello" }] }, { role: "assistant", content: [{ type: "text", text: "Hi" }] }, ] @@ -157,7 +155,7 @@ describe("OpenAI Native reasoning helpers", () => { summary: [{ type: "summary_text", text: "I thought about it" }], }, { role: "assistant", content: [{ type: "text", text: "Hi" }] }, - ] as unknown as Anthropic.Messages.MessageParam[] + ] as unknown as NeutralMessageParam[] const result = collectEncryptedReasoningItems(messages) expect(result).toHaveLength(1) @@ -187,7 +185,7 @@ describe("OpenAI Native reasoning helpers", () => { summary: [{ type: "summary_text", text: "Summary 2" }], }, { role: "assistant", content: [{ type: "text", text: "A2" }] }, - ] as unknown as Anthropic.Messages.MessageParam[] + ] as unknown as NeutralMessageParam[] const result = collectEncryptedReasoningItems(messages) expect(result).toHaveLength(2) @@ -201,7 +199,7 @@ describe("OpenAI Native reasoning helpers", () => { const messages = [ { type: "reasoning", id: "rs_x", text: "plain reasoning" }, { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] as unknown as Anthropic.Messages.MessageParam[] + ] as unknown as NeutralMessageParam[] const result = collectEncryptedReasoningItems(messages) expect(result).toEqual([]) @@ -215,7 +213,7 @@ describe("OpenAI Native reasoning helpers", () => { encrypted_content: "enc_data", }, { role: "assistant", content: [{ type: "text", text: "Hi" }] }, - ] as unknown as Anthropic.Messages.MessageParam[] + ] as unknown as NeutralMessageParam[] const result = collectEncryptedReasoningItems(messages) expect(result).toHaveLength(1) @@ -248,7 +246,7 @@ describe("OpenAI Native reasoning helpers", () => { summary: [{ type: "summary_text", text: "I considered the question" }], }, { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, - ] as unknown as Anthropic.Messages.MessageParam[] + ] as unknown as NeutralMessageParam[] // AI SDK messages (after filtering encrypted items + converting) const aiSdkMessages: ModelMessage[] = [ @@ -304,7 +302,7 @@ describe("OpenAI Native reasoning helpers", () => { summary: [{ type: "summary_text", text: "Thought 2" }], }, { role: "assistant", content: [{ type: "text", text: "A2" }] }, - ] as unknown as Anthropic.Messages.MessageParam[] + ] as unknown as NeutralMessageParam[] const aiSdkMessages: ModelMessage[] = [ { role: "user", content: "Q1" }, @@ -362,7 +360,7 @@ describe("OpenAI Native reasoning helpers", () => { ], }, { role: "assistant", content: [{ type: "text", text: "Response" }] }, - ] as unknown as Anthropic.Messages.MessageParam[] + ] as unknown as NeutralMessageParam[] const aiSdkMessages: ModelMessage[] = [ { role: "user", content: "Hi" }, @@ -397,7 +395,7 @@ describe("OpenAI Native reasoning helpers", () => { encrypted_content: "enc_nosummary", }, { role: "assistant", content: [{ type: "text", text: "Response" }] }, - ] as unknown as Anthropic.Messages.MessageParam[] + ] as unknown as NeutralMessageParam[] const aiSdkMessages: ModelMessage[] = [ { role: "user", content: "Hi" }, @@ -437,7 +435,7 @@ describe("OpenAI Native reasoning helpers", () => { summary: [{ type: "summary_text", text: "Step B" }], }, { role: "assistant", content: [{ type: "text", text: "Done" }] }, - ] as unknown as Anthropic.Messages.MessageParam[] + ] as unknown as NeutralMessageParam[] const aiSdkMessages: ModelMessage[] = [ { role: "user", content: "Hi" }, @@ -477,15 +475,20 @@ describe("OpenAI Native reasoning helpers", () => { expect((content[2] as Record).type).toBe("text") }) - it("handles tool messages splitting (user messages with tool_results create extra tool-role messages)", () => { + it("handles tool messages splitting (user messages with tool-results create extra tool-role messages)", () => { // Original: [user_with_tool_result, encrypted_reasoning, assistant] // After filtering: [user_with_tool_result, assistant] - // AI SDK: [tool, user, assistant] (tool_result split into tool + user messages) + // AI SDK: [tool, user, assistant] (tool-result split into tool + user messages) const originalMessages = [ { role: "user", content: [ - { type: "tool_result", tool_use_id: "call_1", content: "result" }, + { + type: "tool-result", + toolCallId: "call_1", + toolName: "some_tool", + output: { type: "text", value: "result" }, + }, { type: "text", text: "Continue" }, ], }, @@ -496,9 +499,9 @@ describe("OpenAI Native reasoning helpers", () => { summary: [{ type: "summary_text", text: "Thought after tool" }], }, { role: "assistant", content: [{ type: "text", text: "OK" }] }, - ] as unknown as Anthropic.Messages.MessageParam[] + ] as unknown as NeutralMessageParam[] - // AI SDK messages after conversion (tool_result splits into tool + user) + // AI SDK messages after conversion (tool-result splits into tool + user) const aiSdkMessages: ModelMessage[] = [ { role: "tool", @@ -539,7 +542,7 @@ describe("OpenAI Native reasoning helpers", () => { id: "rs_orphan", encrypted_content: "enc_orphan", }, - ] as unknown as Anthropic.Messages.MessageParam[] + ] as unknown as NeutralMessageParam[] const aiSdkMessages: ModelMessage[] = [{ role: "user", content: "Hi" }] diff --git a/src/api/providers/__tests__/openai-native-tools.spec.ts b/src/api/providers/__tests__/openai-native-tools.spec.ts index d873b7457bb..b0ad1930080 100644 --- a/src/api/providers/__tests__/openai-native-tools.spec.ts +++ b/src/api/providers/__tests__/openai-native-tools.spec.ts @@ -3,17 +3,46 @@ import OpenAI from "openai" import { OpenAiHandler } from "../openai" +import { OpenAiNativeHandler } from "../openai-native" +import type { ApiHandlerOptions } from "../../../shared/api" + +// Mocks for AI SDK (used by both OpenAiHandler and OpenAiNativeHandler) +const { mockStreamText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: vi.fn(), + } +}) + +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: vi.fn(() => ({ + chat: vi.fn(() => ({ + modelId: "test-model", + provider: "openai.chat", + })), + responses: vi.fn(() => ({ + modelId: "gpt-4o", + provider: "openai.responses", + })), + })), +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vi.fn(), + }, + }, +})) describe("OpenAiHandler native tools", () => { it("includes tools in request when tools are provided via metadata (regression test)", async () => { - const mockCreate = vi.fn().mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) - // Set openAiCustomModelInfo without any tool capability flags; tools should // still be passed whenever metadata.tools is present. const handler = new OpenAiHandler({ @@ -26,15 +55,13 @@ describe("OpenAiHandler native tools", () => { }, } as unknown as import("../../../shared/api").ApiHandlerOptions) - // Patch the OpenAI client call - const mockClient = { - chat: { - completions: { - create: mockCreate, - }, - }, - } as unknown as OpenAI - ;(handler as unknown as { client: OpenAI }).client = mockClient + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "Test response" } + })(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + }) const tools: OpenAI.Chat.ChatCompletionTool[] = [ { @@ -51,91 +78,53 @@ describe("OpenAiHandler native tools", () => { taskId: "test-task-id", tools, }) - await stream.next() + for await (const _chunk of stream) { + // consume stream + } - expect(mockCreate).toHaveBeenCalledWith( + // Verify streamText was called with tools (converted via convertToolsForAiSdk) + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - type: "function", - function: expect.objectContaining({ name: "test_tool" }), - }), - ]), - parallel_tool_calls: true, + tools: expect.objectContaining({ + test_tool: expect.any(Object), + }), }), - expect.anything(), ) }) }) -// Use vi.hoisted to define mock functions for AI SDK -const { mockStreamText } = vi.hoisted(() => ({ - mockStreamText: vi.fn(), -})) - -vi.mock("ai", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - streamText: mockStreamText, - generateText: vi.fn(), - } -}) - -vi.mock("@ai-sdk/openai", () => ({ - createOpenAI: vi.fn(() => { - const provider = vi.fn(() => ({ - modelId: "gpt-4o", - provider: "openai", - })) - ;(provider as any).responses = vi.fn(() => ({ - modelId: "gpt-4o", - provider: "openai.responses", - })) - return provider - }), -})) - -import { OpenAiNativeHandler } from "../openai-native" -import type { ApiHandlerOptions } from "../../../shared/api" - -describe("OpenAiNativeHandler tool handling with AI SDK", () => { - function createMockStreamReturn() { - async function* mockFullStream() { - yield { type: "text-delta", text: "test" } - } - - return { - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - } - } - +describe("OpenAiNativeHandler MCP tool schema handling", () => { beforeEach(() => { vi.clearAllMocks() }) - it("should pass tools through convertToolsForOpenAI and convertToolsForAiSdk to streamText", async () => { - mockStreamText.mockReturnValue(createMockStreamReturn()) - + it("should pass MCP tools to streamText via convertToolsForAiSdk", async () => { const handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", apiModelId: "gpt-4o", } as ApiHandlerOptions) - const tools: OpenAI.Chat.ChatCompletionTool[] = [ + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "test" } + })(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), + }) + + const mcpTools: OpenAI.Chat.ChatCompletionTool[] = [ { type: "function", function: { - name: "read_file", - description: "Read a file from the filesystem", + name: "mcp--github--get_me", + description: "Get current GitHub user", parameters: { type: "object", properties: { - path: { type: "string", description: "File path" }, + token: { type: "string", description: "API token" }, }, + required: ["token"], }, }, }, @@ -143,41 +132,50 @@ describe("OpenAiNativeHandler tool handling with AI SDK", () => { const stream = handler.createMessage("system prompt", [], { taskId: "test-task-id", - tools, + tools: mcpTools, }) - for await (const _ of stream) { - // consume + + for await (const _chunk of stream) { + // consume stream } + // Verify streamText was called with tools converted via convertToolsForAiSdk expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ - read_file: expect.anything(), + "mcp--github--get_me": expect.any(Object), }), }), ) }) - it("should pass MCP tools to streamText", async () => { - mockStreamText.mockReturnValue(createMockStreamReturn()) - + it("should pass regular tools to streamText via convertToolsForAiSdk", async () => { const handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", apiModelId: "gpt-4o", } as ApiHandlerOptions) - const mcpTools: OpenAI.Chat.ChatCompletionTool[] = [ + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "test" } + })(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), + }) + + const regularTools: OpenAI.Chat.ChatCompletionTool[] = [ { type: "function", function: { - name: "mcp--github--get_me", - description: "Get current GitHub user", + name: "read_file", + description: "Read a file from the filesystem", parameters: { type: "object", properties: { - token: { type: "string", description: "API token" }, + path: { type: "string", description: "File path" }, + encoding: { type: "string", description: "File encoding" }, }, - required: ["token"], }, }, }, @@ -185,38 +183,39 @@ describe("OpenAiNativeHandler tool handling with AI SDK", () => { const stream = handler.createMessage("system prompt", [], { taskId: "test-task-id", - tools: mcpTools, + tools: regularTools, }) - for await (const _ of stream) { - // consume + + for await (const _chunk of stream) { + // consume stream } + // Verify streamText was called with converted tools expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ - "mcp--github--get_me": expect.anything(), + read_file: expect.any(Object), }), }), ) }) - it("should pass both regular and MCP tools together", async () => { - mockStreamText.mockReturnValue(createMockStreamReturn()) - + it("should handle tools with nested objects via convertToolsForAiSdk", async () => { const handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", apiModelId: "gpt-4o", } as ApiHandlerOptions) - const mixedTools: OpenAI.Chat.ChatCompletionTool[] = [ - { - type: "function", - function: { - name: "read_file", - description: "Read a file", - parameters: { type: "object", properties: { path: { type: "string" } } }, - }, - }, + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "test" } + })(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), + }) + + const mcpToolsWithNestedObjects: OpenAI.Chat.ChatCompletionTool[] = [ { type: "function", function: { @@ -224,8 +223,24 @@ describe("OpenAiNativeHandler tool handling with AI SDK", () => { description: "Create a Linear issue", parameters: { type: "object", - properties: { title: { type: "string" } }, - required: ["title"], + properties: { + title: { type: "string" }, + metadata: { + type: "object", + properties: { + priority: { type: "number" }, + labels: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }, + }, + }, }, }, }, @@ -233,65 +248,42 @@ describe("OpenAiNativeHandler tool handling with AI SDK", () => { const stream = handler.createMessage("system prompt", [], { taskId: "test-task-id", - tools: mixedTools, + tools: mcpToolsWithNestedObjects, }) - for await (const _ of stream) { - // consume - } - - const callArgs = mockStreamText.mock.calls[0][0] - expect(callArgs.tools).toBeDefined() - expect(callArgs.tools.read_file).toBeDefined() - expect(callArgs.tools["mcp--linear--create_issue"]).toBeDefined() - }) - - it("should pass parallelToolCalls in provider options", async () => { - mockStreamText.mockReturnValue(createMockStreamReturn()) - - const handler = new OpenAiNativeHandler({ - openAiNativeApiKey: "test-key", - apiModelId: "gpt-4o", - } as ApiHandlerOptions) - const stream = handler.createMessage("system prompt", [], { - taskId: "test-task-id", - parallelToolCalls: false, - }) - for await (const _ of stream) { - // consume + for await (const _chunk of stream) { + // consume stream } + // Verify tools are passed through to streamText expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - providerOptions: expect.objectContaining({ - openai: expect.objectContaining({ - parallelToolCalls: false, - }), + tools: expect.objectContaining({ + "mcp--linear--create_issue": expect.any(Object), }), }), ) }) - it("should handle tool call streaming events", async () => { - async function* mockFullStream() { - yield { type: "tool-input-start", id: "call_abc", toolName: "read_file" } - yield { type: "tool-input-delta", id: "call_abc", delta: '{"path":' } - yield { type: "tool-input-delta", id: "call_abc", delta: '"/tmp/test.txt"}' } - yield { type: "tool-input-end", id: "call_abc" } - } + it("should handle tool calls in AI SDK stream", async () => { + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + } as ApiHandlerOptions) mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), + fullStream: (async function* () { + // AI SDK tool call stream events + yield { type: "tool-input-start", id: "call_123", toolName: "read_file" } + yield { type: "tool-input-delta", id: "call_123", delta: '{"path":' } + yield { type: "tool-input-delta", id: "call_123", delta: '"/tmp/test.txt"}' } + yield { type: "tool-input-end", id: "call_123" } + })(), usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + response: Promise.resolve({ messages: [] }), }) - const handler = new OpenAiNativeHandler({ - openAiNativeApiKey: "test-key", - apiModelId: "gpt-4o", - } as ApiHandlerOptions) - const stream = handler.createMessage("system prompt", [], { taskId: "test-task-id", }) @@ -301,16 +293,21 @@ describe("OpenAiNativeHandler tool handling with AI SDK", () => { chunks.push(chunk) } - const toolStart = chunks.filter((c) => c.type === "tool_call_start") - expect(toolStart).toHaveLength(1) - expect(toolStart[0].id).toBe("call_abc") - expect(toolStart[0].name).toBe("read_file") - - const toolDeltas = chunks.filter((c) => c.type === "tool_call_delta") - expect(toolDeltas).toHaveLength(2) - - const toolEnd = chunks.filter((c) => c.type === "tool_call_end") - expect(toolEnd).toHaveLength(1) - expect(toolEnd[0].id).toBe("call_abc") + // Verify tool call start + const startChunks = chunks.filter((c) => c.type === "tool_call_start") + expect(startChunks).toHaveLength(1) + expect(startChunks[0].id).toBe("call_123") + expect(startChunks[0].name).toBe("read_file") + + // Verify tool call deltas + const deltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + expect(deltaChunks).toHaveLength(2) + expect(deltaChunks[0].delta).toBe('{"path":') + expect(deltaChunks[1].delta).toBe('"/tmp/test.txt"}') + + // Verify tool call end + const endChunks = chunks.filter((c) => c.type === "tool_call_end") + expect(endChunks).toHaveLength(1) + expect(endChunks[0].id).toBe("call_123") }) }) diff --git a/src/api/providers/__tests__/openai-native-usage.spec.ts b/src/api/providers/__tests__/openai-native-usage.spec.ts index 5742d7282bb..4a589b3e94f 100644 --- a/src/api/providers/__tests__/openai-native-usage.spec.ts +++ b/src/api/providers/__tests__/openai-native-usage.spec.ts @@ -1,8 +1,10 @@ // npx vitest run api/providers/__tests__/openai-native-usage.spec.ts -const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText, mockCreateOpenAI } = vi.hoisted(() => ({ mockStreamText: vi.fn(), mockGenerateText: vi.fn(), + mockCreateOpenAI: vi.fn(), })) vi.mock("ai", async (importOriginal) => { @@ -15,51 +17,85 @@ vi.mock("ai", async (importOriginal) => { }) vi.mock("@ai-sdk/openai", () => ({ - createOpenAI: vi.fn(() => { - const provider = vi.fn(() => ({ - modelId: "gpt-4.1", - provider: "openai", - })) - ;(provider as any).responses = vi.fn(() => ({ - modelId: "gpt-4.1", + createOpenAI: mockCreateOpenAI.mockImplementation(() => ({ + responses: vi.fn(() => ({ + modelId: "gpt-4o", provider: "openai.responses", - })) - return provider - }), + })), + })), })) -import type { Anthropic } from "@anthropic-ai/sdk" +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vi.fn(), + }, + }, +})) +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import { openAiNativeModels } from "@roo-code/types" import { OpenAiNativeHandler } from "../openai-native" -import type { ApiHandlerOptions } from "../../../shared/api" -describe("OpenAiNativeHandler - usage metrics", () => { +// Helper: create a mock fullStream generator +function createMockFullStream(parts: Array<{ type: string; text?: string }> = [{ type: "text-delta", text: "ok" }]) { + return async function* () { + for (const part of parts) { + yield part + } + } +} + +// Helper: mock streamText return with full options +function mockStreamTextWithUsage( + usage: Record, + providerMetadata: Record = {}, + response: any = { messages: [] }, +) { + mockStreamText.mockReturnValue({ + fullStream: createMockFullStream()(), + usage: Promise.resolve(usage), + providerMetadata: Promise.resolve(providerMetadata), + response: Promise.resolve(response), + }) +} + +const systemPrompt = "You are a helpful assistant." +const messages: NeutralMessageParam[] = [{ role: "user", content: "Hello!" }] + +describe("OpenAiNativeHandler - usage processing via createMessage", () => { let handler: OpenAiNativeHandler - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] beforeEach(() => { handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", - apiModelId: "gpt-4.1", + apiModelId: "gpt-4o", }) vi.clearAllMocks() }) - describe("basic token counts", () => { - it("should handle basic input and output tokens", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } + describe("basic usage metrics", () => { + it("should emit usage chunk with input and output tokens", async () => { + mockStreamTextWithUsage({ inputTokens: 100, outputTokens: 50 }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk).toMatchObject({ + type: "usage", + inputTokens: 100, + outputTokens: 50, }) + }) + + it("should handle zero tokens", async () => { + mockStreamTextWithUsage({ inputTokens: 0, outputTokens: 0 }) const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -67,22 +103,42 @@ describe("OpenAiNativeHandler - usage metrics", () => { chunks.push(chunk) } - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0].inputTokens).toBe(100) - expect(usageChunks[0].outputTokens).toBe(50) + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk).toMatchObject({ + type: "usage", + inputTokens: 0, + outputTokens: 0, + }) }) - it("should handle zero tokens", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "" } + it("should handle undefined token fields gracefully", async () => { + mockStreamTextWithUsage({}) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk).toMatchObject({ + type: "usage", + inputTokens: 0, + outputTokens: 0, + }) + }) + }) + + describe("cache token metrics", () => { + it("should include cached input tokens from usage details", async () => { + mockStreamTextWithUsage({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 30, + }, }) const stream = handler.createMessage(systemPrompt, messages) @@ -91,31 +147,51 @@ describe("OpenAiNativeHandler - usage metrics", () => { chunks.push(chunk) } - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0].inputTokens).toBe(0) - expect(usageChunks[0].outputTokens).toBe(0) + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.cacheReadTokens).toBe(30) }) - }) - describe("cache metrics", () => { - it("should handle cached input tokens from usage details", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } + it("should include cached input tokens from provider metadata as fallback", async () => { + mockStreamTextWithUsage({ inputTokens: 100, outputTokens: 50 }, { openai: { cachedInputTokens: 25 } }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.cacheReadTokens).toBe(25) + }) + + it("should include cache creation tokens from provider metadata", async () => { + mockStreamTextWithUsage( + { inputTokens: 100, outputTokens: 50 }, + { openai: { cacheCreationInputTokens: 20 } }, + ) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.cacheWriteTokens).toBe(20) + }) + + it("should include both cache read and write tokens", async () => { + mockStreamTextWithUsage( + { inputTokens: 100, outputTokens: 50, - details: { - cachedInputTokens: 30, - }, - }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + details: { cachedInputTokens: 30 }, + }, + { openai: { cacheCreationInputTokens: 20 } }, + ) const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -123,21 +199,16 @@ describe("OpenAiNativeHandler - usage metrics", () => { chunks.push(chunk) } - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0].cacheReadTokens).toBe(30) + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.cacheReadTokens).toBe(30) + expect(usageChunk.cacheWriteTokens).toBe(20) }) it("should handle no cache information", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 50, outputTokens: 25 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + mockStreamTextWithUsage({ + inputTokens: 100, + outputTokens: 50, }) const stream = handler.createMessage(systemPrompt, messages) @@ -146,31 +217,39 @@ describe("OpenAiNativeHandler - usage metrics", () => { chunks.push(chunk) } - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0].cacheReadTokens).toBeUndefined() - expect(usageChunks[0].cacheWriteTokens).toBeUndefined() + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + // cacheReadTokens and cacheWriteTokens should be undefined when 0 + expect(usageChunk.cacheReadTokens).toBeUndefined() + expect(usageChunk.cacheWriteTokens).toBeUndefined() }) }) describe("reasoning tokens", () => { - it("should handle reasoning tokens in usage details", async () => { - async function* mockFullStream() { - yield { type: "reasoning-delta", text: "thinking..." } - yield { type: "text-delta", text: "answer" } + it("should include reasoning tokens from usage details", async () => { + mockStreamTextWithUsage({ + inputTokens: 100, + outputTokens: 150, + details: { + reasoningTokens: 50, + }, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ - inputTokens: 100, - outputTokens: 50, - details: { - reasoningTokens: 30, - }, - }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.reasoningTokens).toBe(50) + }) + + it("should omit reasoning tokens when not present", async () => { + mockStreamTextWithUsage({ + inputTokens: 100, + outputTokens: 50, }) const stream = handler.createMessage(systemPrompt, messages) @@ -179,25 +258,43 @@ describe("OpenAiNativeHandler - usage metrics", () => { chunks.push(chunk) } - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0].reasoningTokens).toBe(30) + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.reasoningTokens).toBeUndefined() }) + }) - it("should omit reasoning tokens when not present", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "answer" } + describe("cost calculation", () => { + it("should calculate cost for gpt-4o", async () => { + mockStreamTextWithUsage({ + inputTokens: 100, + outputTokens: 50, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.totalCost).toBeGreaterThan(0) + + // gpt-4o pricing: input $2.5/M, output $10/M + const expectedCost = (100 / 1_000_000) * 2.5 + (50 / 1_000_000) * 10 + expect(usageChunk.totalCost).toBeCloseTo(expectedCost, 10) + }) + + it("should calculate cost with cache tokens for gpt-4o", async () => { + mockStreamTextWithUsage( + { inputTokens: 100, outputTokens: 50, - }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + details: { cachedInputTokens: 30 }, + }, + { openai: { cacheCreationInputTokens: 20 } }, + ) const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -205,57 +302,65 @@ describe("OpenAiNativeHandler - usage metrics", () => { chunks.push(chunk) } - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0].reasoningTokens).toBeUndefined() + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.totalCost).toBeGreaterThan(0) }) - }) - describe("cost calculation", () => { - it("should include totalCost in usage metrics", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } - } + it("should calculate cost for gpt-5.1", async () => { + const gpt51Handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-5.1", + }) - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ - inputTokens: 1000, - outputTokens: 500, - }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + mockStreamTextWithUsage({ + inputTokens: 100, + outputTokens: 50, }) - const stream = handler.createMessage(systemPrompt, messages) + const stream = gpt51Handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(typeof usageChunks[0].totalCost).toBe("number") - expect(usageChunks[0].totalCost).toBeGreaterThanOrEqual(0) + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + + // gpt-5.1 pricing: input $1.25/M, output $10/M + const expectedCost = (100 / 1_000_000) * 1.25 + (50 / 1_000_000) * 10 + expect(usageChunk.totalCost).toBeCloseTo(expectedCost, 10) }) - it("should handle all details together", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } + it("should calculate cost for codex-mini-latest", async () => { + const codexHandler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "codex-mini-latest", + }) + + mockStreamTextWithUsage({ + inputTokens: 50, + outputTokens: 10, + }) + + const stream = codexHandler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ - inputTokens: 200, - outputTokens: 100, - details: { - cachedInputTokens: 50, - reasoningTokens: 25, - }, - }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + + // codex-mini-latest pricing: input $1.5/M, output $6/M + const expectedCost = (50 / 1_000_000) * 1.5 + (10 / 1_000_000) * 6 + expect(usageChunk.totalCost).toBeCloseTo(expectedCost, 10) + }) + + it("should handle cost calculation with no cache reads", async () => { + mockStreamTextWithUsage({ + inputTokens: 100, + outputTokens: 50, }) const stream = handler.createMessage(systemPrompt, messages) @@ -264,92 +369,347 @@ describe("OpenAiNativeHandler - usage metrics", () => { chunks.push(chunk) } - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0].inputTokens).toBe(200) - expect(usageChunks[0].outputTokens).toBe(100) - expect(usageChunks[0].cacheReadTokens).toBe(50) - expect(usageChunks[0].reasoningTokens).toBe(25) - expect(typeof usageChunks[0].totalCost).toBe("number") + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.totalCost).toBeGreaterThan(0) }) }) - describe("prompt cache retention", () => { - it("should set promptCacheRetention=24h for gpt-5.1 models that support prompt caching", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } + describe("service tier pricing", () => { + it("should apply priority tier pricing when service tier is returned", async () => { + const gpt4oHandler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + openAiNativeServiceTier: "priority", + }) + + mockStreamTextWithUsage({ inputTokens: 100, outputTokens: 50 }, { openai: { serviceTier: "priority" } }) + + const stream = gpt4oHandler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + + // Priority tier for gpt-4o: input $4.25/M, output $17/M + const expectedCost = (100 / 1_000_000) * 4.25 + (50 / 1_000_000) * 17.0 + expect(usageChunk.totalCost).toBeCloseTo(expectedCost, 10) + }) + + it("should use default pricing when service tier is 'default'", async () => { + const gpt4oHandler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + openAiNativeServiceTier: "default", }) - const h = new OpenAiNativeHandler({ + mockStreamTextWithUsage({ inputTokens: 100, outputTokens: 50 }, { openai: { serviceTier: "default" } }) + + const stream = gpt4oHandler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + + // Default tier for gpt-4o: input $2.5/M, output $10/M + const expectedCost = (100 / 1_000_000) * 2.5 + (50 / 1_000_000) * 10 + expect(usageChunk.totalCost).toBeCloseTo(expectedCost, 10) + }) + + it("should apply flex tier pricing for gpt-5.1", async () => { + const gpt51Handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", apiModelId: "gpt-5.1", + openAiNativeServiceTier: "flex", }) - const stream = h.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + mockStreamTextWithUsage({ inputTokens: 1000, outputTokens: 500 }, { openai: { serviceTier: "flex" } }) + + const stream = gpt51Handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - const callArgs = mockStreamText.mock.calls[0][0] - const modelInfo = openAiNativeModels["gpt-5.1"] - if (modelInfo.supportsPromptCache && modelInfo.promptCacheRetention === "24h") { - expect(callArgs.providerOptions.openai.promptCacheRetention).toBe("24h") + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + + // Flex tier for gpt-5.1: input $0.625/M, output $5/M + const expectedCost = (1000 / 1_000_000) * 0.625 + (500 / 1_000_000) * 5.0 + expect(usageChunk.totalCost).toBeCloseTo(expectedCost, 10) + }) + + it("should pass service tier in providerOptions for models with tiers", async () => { + const gpt4oHandler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + openAiNativeServiceTier: "priority", + }) + + mockStreamTextWithUsage({ inputTokens: 10, outputTokens: 5 }) + + const stream = gpt4oHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.serviceTier).toBe("priority") }) - it("should not set promptCacheRetention for non-gpt-5.1 models", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } + it("should not pass invalid service tier in providerOptions", async () => { + const gpt4oHandler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + openAiNativeServiceTier: "nonexistent_tier" as any, + }) + + mockStreamTextWithUsage({ inputTokens: 10, outputTokens: 5 }) + + const stream = gpt4oHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.serviceTier).toBeUndefined() + }) + }) +}) + +describe("OpenAiNativeHandler - prompt cache retention via providerOptions", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should set promptCacheRetention=24h for gpt-5.1 models that support prompt caching", async () => { + const modelIds = ["gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini"] + + for (const modelId of modelIds) { + vi.clearAllMocks() + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: modelId, + }) + mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), + fullStream: (async function* () { + yield { type: "text-delta", text: "ok" } + })(), usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + response: Promise.resolve({ messages: [] }), }) const stream = handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + for await (const _chunk of stream) { + // consume stream } const callArgs = mockStreamText.mock.calls[0][0] - expect(callArgs.providerOptions.openai.promptCacheRetention).toBeUndefined() - }) + expect(callArgs.providerOptions?.openai?.promptCacheRetention).toBe("24h") + } + }) - it("should not set promptCacheRetention when the model does not support prompt caching", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } - } + it("should not set promptCacheRetention for non-gpt-5.1 models even if they support prompt caching", async () => { + const modelIds = ["gpt-5", "gpt-4o"] + + for (const modelId of modelIds) { + vi.clearAllMocks() + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: modelId, + }) mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), + fullStream: (async function* () { + yield { type: "text-delta", text: "ok" } + })(), usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + response: Promise.resolve({ messages: [] }), }) - // o3-mini doesn't support prompt caching - const h = new OpenAiNativeHandler({ - openAiNativeApiKey: "test-key", - apiModelId: "o3-mini-high", - }) - - const stream = h.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } const callArgs = mockStreamText.mock.calls[0][0] - expect(callArgs.providerOptions.openai.promptCacheRetention).toBeUndefined() + expect(callArgs.providerOptions?.openai?.promptCacheRetention).toBeUndefined() + } + }) + + it("should not set promptCacheRetention when the model does not support prompt caching", async () => { + const modelId = "codex-mini-latest" + expect(openAiNativeModels[modelId as keyof typeof openAiNativeModels].supportsPromptCache).toBe(false) + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: modelId, + }) + + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "ok" } + })(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.promptCacheRetention).toBeUndefined() + }) +}) + +describe("OpenAiNativeHandler - buildProviderOptions via streamText args", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should always include store: false", async () => { + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + }) + + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "ok" } + })(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.store).toBe(false) + }) + + it("should default parallelToolCalls to true", async () => { + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + }) + + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "ok" } + })(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.parallelToolCalls).toBe(true) + }) + + it("should respect parallelToolCalls from metadata", async () => { + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + }) + + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "ok" } + })(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test", + parallelToolCalls: false, + }) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.parallelToolCalls).toBe(false) + }) + + it("should set reasoningEffort and related fields for models with reasoning support", async () => { + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "o3", + }) + + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "ok" } + })(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + const openaiOpts = callArgs.providerOptions?.openai + + // o3 has reasoningEffort: "medium" by default + expect(openaiOpts?.reasoningEffort).toBe("medium") + expect(openaiOpts?.include).toEqual(["reasoning.encrypted_content"]) + expect(openaiOpts?.reasoningSummary).toBe("auto") + }) + + it("should not set reasoning fields for models without reasoning support", async () => { + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4.1", + }) + + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "ok" } + })(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + const openaiOpts = callArgs.providerOptions?.openai + + expect(openaiOpts?.reasoningEffort).toBeUndefined() + expect(openaiOpts?.include).toBeUndefined() + expect(openaiOpts?.reasoningSummary).toBeUndefined() }) }) diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index e7981520c37..58f50ce0f74 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -1,9 +1,11 @@ // npx vitest run api/providers/__tests__/openai-native.spec.ts // Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls -const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ +const { mockStreamText, mockGenerateText, mockCreateOpenAI, mockCaptureException } = vi.hoisted(() => ({ mockStreamText: vi.fn(), mockGenerateText: vi.fn(), + mockCreateOpenAI: vi.fn(), + mockCaptureException: vi.fn(), })) vi.mock("ai", async (importOriginal) => { @@ -16,40 +18,75 @@ vi.mock("ai", async (importOriginal) => { }) vi.mock("@ai-sdk/openai", () => ({ - createOpenAI: vi.fn(() => { - const provider = vi.fn(() => ({ - modelId: "gpt-4.1", - provider: "openai", - })) - // Add .responses() method that returns the same mock model - ;(provider as any).responses = vi.fn(() => ({ + createOpenAI: mockCreateOpenAI.mockImplementation(() => ({ + responses: vi.fn(() => ({ modelId: "gpt-4.1", provider: "openai.responses", - })) - return provider - }), + })), + })), })) -import type { Anthropic } from "@anthropic-ai/sdk" +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: (...args: unknown[]) => mockCaptureException(...args), + }, + }, +})) -import { openAiNativeDefaultModelId, openAiNativeModels } from "@roo-code/types" +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" +import { ApiProviderError, openAiNativeModels } from "@roo-code/types" -import { OpenAiNativeHandler } from "../openai-native" import type { ApiHandlerOptions } from "../../../shared/api" +import { OpenAiNativeHandler } from "../openai-native" + +// Helper: create a standard mock fullStream generator +function createMockFullStream( + parts: Array<{ + type: string + text?: string + id?: string + toolName?: string + delta?: string + }>, +) { + return async function* () { + for (const part of parts) { + yield part + } + } +} + +// Helper: create default mock return value for streamText +function mockStreamTextReturn( + parts: Array<{ + type: string + text?: string + id?: string + toolName?: string + delta?: string + }>, + usage = { inputTokens: 10, outputTokens: 5 }, + providerMetadata: Record = {}, + response: any = { messages: [] }, +) { + mockStreamText.mockReturnValue({ + fullStream: createMockFullStream(parts)(), + usage: Promise.resolve(usage), + providerMetadata: Promise.resolve(providerMetadata), + response: Promise.resolve(response), + }) +} + describe("OpenAiNativeHandler", () => { let handler: OpenAiNativeHandler let mockOptions: ApiHandlerOptions const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", - content: [ - { - type: "text" as const, - text: "Hello!", - }, - ], + content: "Hello!", }, ] @@ -76,83 +113,52 @@ describe("OpenAiNativeHandler", () => { expect(handlerWithoutKey).toBeInstanceOf(OpenAiNativeHandler) }) - it("should default enableResponsesReasoningSummary to true", () => { - const opts: ApiHandlerOptions = { + it("should pass undefined baseURL when openAiNativeBaseUrl is empty string", async () => { + const handlerWithEmptyBase = new OpenAiNativeHandler({ apiModelId: "gpt-4.1", openAiNativeApiKey: "test-key", - } - const h = new OpenAiNativeHandler(opts) - expect(h).toBeInstanceOf(OpenAiNativeHandler) - // enableResponsesReasoningSummary should have been set to true in constructor - expect(opts.enableResponsesReasoningSummary).toBe(true) - }) + openAiNativeBaseUrl: "", + }) - it("should preserve explicit enableResponsesReasoningSummary=false", () => { - const opts: ApiHandlerOptions = { - apiModelId: "gpt-4.1", - openAiNativeApiKey: "test-key", - enableResponsesReasoningSummary: false, + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handlerWithEmptyBase.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - new OpenAiNativeHandler(opts) - expect(opts.enableResponsesReasoningSummary).toBe(false) - }) - }) - describe("getModel", () => { - it("should return model info for gpt-4.1", () => { - const modelInfo = handler.getModel() - expect(modelInfo.id).toBe("gpt-4.1") - expect(modelInfo.info).toBeDefined() - expect(modelInfo.info.maxTokens).toBe(32768) - expect(modelInfo.info.contextWindow).toBe(1047576) + expect(mockCreateOpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined })) }) - it("should handle undefined model ID and return default", () => { - const handlerWithoutModel = new OpenAiNativeHandler({ - openAiNativeApiKey: "test-api-key", + it("should pass custom baseURL when openAiNativeBaseUrl is a valid URL", async () => { + const handlerWithCustomBase = new OpenAiNativeHandler({ + apiModelId: "gpt-4.1", + openAiNativeApiKey: "test-key", + openAiNativeBaseUrl: "https://custom-openai.example.com/v1", }) - const modelInfo = handlerWithoutModel.getModel() - expect(modelInfo.id).toBe(openAiNativeDefaultModelId) - expect(modelInfo.info).toBeDefined() - }) - it("should fall back to default model for invalid model ID", () => { - const handlerWithInvalidModel = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "invalid-model", - }) - const model = handlerWithInvalidModel.getModel() - expect(model.id).toBe(openAiNativeDefaultModelId) - }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - it("should strip o3-mini suffix from model ID", () => { - const handlerO3 = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "o3-mini-high", - }) - const model = handlerO3.getModel() - expect(model.id).toBe("o3-mini") + const stream = handlerWithCustomBase.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + expect(mockCreateOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: "https://custom-openai.example.com/v1" }), + ) }) + }) - it("should include model parameters from getModelParams", () => { - const model = handler.getModel() - expect(model).toHaveProperty("maxTokens") + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) }) }) describe("createMessage", () => { it("should handle streaming responses", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } - yield { type: "text-delta", text: " response" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 2 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + mockStreamTextReturn([{ type: "text-delta", text: "Test response" }]) const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -160,23 +166,14 @@ describe("OpenAiNativeHandler", () => { chunks.push(chunk) } - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks).toHaveLength(2) - expect(textChunks[0].text).toBe("Test") - expect(textChunks[1].text).toBe(" response") + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response") }) it("should include usage information", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + mockStreamTextReturn([{ type: "text-delta", text: "Test response" }], { inputTokens: 10, outputTokens: 5 }) const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -184,30 +181,19 @@ describe("OpenAiNativeHandler", () => { chunks.push(chunk) } - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0].inputTokens).toBe(10) - expect(usageChunks[0].outputTokens).toBe(20) + const usageChunk = chunks.find((chunk) => chunk.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk?.inputTokens).toBe(10) + expect(usageChunk?.outputTokens).toBe(5) }) - it("should handle cached tokens in usage details", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ - inputTokens: 100, - outputTokens: 50, - details: { - cachedInputTokens: 30, - reasoningTokens: 10, - }, - }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + it("should handle tool calls via AI SDK stream parts", async () => { + mockStreamTextReturn([ + { type: "tool-input-start", id: "call_1", toolName: "test_tool" }, + { type: "tool-input-delta", id: "call_1", delta: '{"arg":' }, + { type: "tool-input-delta", id: "call_1", delta: '"value"}' }, + { type: "tool-input-end", id: "call_1" }, + ]) const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -215,720 +201,1050 @@ describe("OpenAiNativeHandler", () => { chunks.push(chunk) } - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0].inputTokens).toBe(100) - expect(usageChunks[0].outputTokens).toBe(50) - expect(usageChunks[0].cacheReadTokens).toBe(30) - expect(usageChunks[0].reasoningTokens).toBe(10) - }) - - it("should handle reasoning stream parts", async () => { - async function* mockFullStream() { - yield { type: "reasoning-delta", text: "thinking..." } - yield { type: "text-delta", text: "answer" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const toolCallStart = chunks.filter((c) => c.type === "tool_call_start") + expect(toolCallStart).toHaveLength(1) + expect(toolCallStart[0].id).toBe("call_1") + expect(toolCallStart[0].name).toBe("test_tool") - const reasoningChunks = chunks.filter((c) => c.type === "reasoning") - expect(reasoningChunks).toHaveLength(1) - expect(reasoningChunks[0].text).toBe("thinking...") + const toolCallDeltas = chunks.filter((c) => c.type === "tool_call_delta") + expect(toolCallDeltas).toHaveLength(2) - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks).toHaveLength(1) - expect(textChunks[0].text).toBe("answer") + const toolCallEnd = chunks.filter((c) => c.type === "tool_call_end") + expect(toolCallEnd).toHaveLength(1) }) - it("should handle tool calls in stream", async () => { - async function* mockFullStream() { - yield { type: "tool-input-start", id: "call_1", toolName: "test_tool" } - yield { type: "tool-input-delta", id: "call_1", delta: '{"arg":"val"}' } - yield { type: "tool-input-end", id: "call_1" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + it("should pass system prompt to streamText", async () => { + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + for await (const _chunk of stream) { + // consume stream } - expect(chunks.some((c) => c.type === "tool_call_start")).toBe(true) - expect(chunks.some((c) => c.type === "tool_call_delta")).toBe(true) - expect(chunks.some((c) => c.type === "tool_call_end")).toBe(true) + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.system).toBe(systemPrompt) }) - it("should handle API errors", async () => { - const error = new Error("API Error") - ;(error as any).name = "AI_APICallError" - ;(error as any).status = 500 - - // Suppress unhandled rejection warnings for dangling promises - const rejectedUsage = Promise.reject(error) - const rejectedMeta = Promise.reject(error) - const rejectedContent = Promise.reject(error) - rejectedUsage.catch(() => {}) - rejectedMeta.catch(() => {}) - rejectedContent.catch(() => {}) + it("should pass temperature 0 as default for models that support temperature", async () => { + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - async function* errorStream() { - yield { type: "text-delta", text: "" } - throw error + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - mockStreamText.mockReturnValue({ - fullStream: errorStream(), - usage: rejectedUsage, - providerMetadata: rejectedMeta, - content: rejectedContent, - }) - - const stream = handler.createMessage(systemPrompt, messages) - await expect(async () => { - for await (const _chunk of stream) { - // drain - } - }).rejects.toThrow("OpenAI Native") + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.temperature).toBe(0) }) - it("should pass system prompt to streamText", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + it("should use provider.responses() for the language model", async () => { + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) const stream = handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + for await (const _chunk of stream) { + // consume stream } - expect(mockStreamText).toHaveBeenCalledWith( - expect.objectContaining({ - system: systemPrompt, - }), - ) + // Verify createOpenAI was called and .responses() was used + expect(mockCreateOpenAI).toHaveBeenCalled() + const provider = mockCreateOpenAI.mock.results[0].value + expect(provider.responses).toHaveBeenCalledWith("gpt-4.1") }) - it("should pass temperature when model supports it", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + it("should set store: false in providerOptions", async () => { + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) const stream = handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + for await (const _chunk of stream) { + // consume stream } - // gpt-4.1 supports temperature - expect(mockStreamText).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: expect.any(Number), - }), - ) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.store).toBe(false) }) - it("should use user-specified temperature", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + it("should set parallelToolCalls in providerOptions", async () => { + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - const handlerWithTemp = new OpenAiNativeHandler({ - ...mockOptions, - modelTemperature: 0.7, + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + parallelToolCalls: false, }) - - const stream = handlerWithTemp.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + for await (const _chunk of stream) { + // consume stream } - expect(mockStreamText).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.7, - }), - ) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.parallelToolCalls).toBe(false) }) - it("should pass store: false in provider options", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + it("should include session tracking headers via createOpenAI", async () => { + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - const stream = handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + const stream = handler.createMessage(systemPrompt, messages, { taskId: "task-123" }) + for await (const _chunk of stream) { + // consume stream } - expect(mockStreamText).toHaveBeenCalledWith( + expect(mockCreateOpenAI).toHaveBeenCalledWith( expect.objectContaining({ - providerOptions: expect.objectContaining({ - openai: expect.objectContaining({ - store: false, - }), + apiKey: "test-api-key", + headers: expect.objectContaining({ + originator: "roo-code", + session_id: "task-123", + "User-Agent": expect.stringContaining("roo-code/"), }), }), ) }) - it("should capture responseId from provider metadata", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({ - openai: { - responseId: "resp_test123", - serviceTier: "default", - }, - }), - content: Promise.resolve([]), - }) + it("should handle reasoning stream parts", async () => { + mockStreamTextReturn([ + { type: "reasoning", text: "Thinking about it..." }, + { type: "text-delta", text: "The answer is..." }, + ]) const stream = handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - expect(handler.getResponseId()).toBe("resp_test123") - }) + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("Thinking about it...") - it("should capture encrypted content from reasoning parts", async () => { - async function* mockFullStream() { - yield { type: "reasoning-delta", text: "thinking" } - yield { type: "text-delta", text: "answer" } - } + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("The answer is...") + }) + it("should handle API errors in stream", async () => { mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({ - openai: { responseId: "resp_test" }, - }), - content: Promise.resolve([ - { - type: "reasoning", - text: "thinking", - providerMetadata: { - openai: { - reasoningEncryptedContent: "encrypted_payload", - itemId: "item_123", - }, - }, - }, - { - type: "text", - text: "answer", - }, - ]), + fullStream: (async function* () { + yield { type: "text-delta", text: "" } + throw new Error("API Error") + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), }) const stream = handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume - } - const encrypted = handler.getEncryptedContent() - expect(encrypted).toBeDefined() - expect(encrypted!.encrypted_content).toBe("encrypted_payload") - expect(encrypted!.id).toBe("item_123") + await expect(async () => { + for await (const _chunk of stream) { + // Should throw + } + }).rejects.toThrow("OpenAI Native: API Error") }) - it("should reset state between requests", async () => { - // First request with metadata - async function* mockFullStream1() { - yield { type: "text-delta", text: "first" } - } - - mockStreamText.mockReturnValue({ - fullStream: mockFullStream1(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({ - openai: { responseId: "resp_1" }, - }), - content: Promise.resolve([]), - }) - - let stream = handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume - } - expect(handler.getResponseId()).toBe("resp_1") - - // Second request should reset state - async function* mockFullStream2() { - yield { type: "text-delta", text: "second" } - } + it("should handle rate limiting", async () => { + const rateLimitError = new Error("Rate limit exceeded") + ;(rateLimitError as any).status = 429 mockStreamText.mockReturnValue({ - fullStream: mockFullStream2(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + fullStream: (async function* () { + yield { type: "text-delta", text: "" } + throw rateLimitError + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + response: Promise.resolve({ messages: [] }), }) - stream = handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume - } + const stream = handler.createMessage(systemPrompt, messages) - // Should be reset since second request had no responseId - expect(handler.getResponseId()).toBeUndefined() - expect(handler.getEncryptedContent()).toBeUndefined() + await expect(async () => { + for await (const _chunk of stream) { + // Should throw + } + }).rejects.toThrow("Rate limit exceeded") }) }) describe("GPT-5 models", () => { - it("should pass reasoning effort in provider options for GPT-5", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Response" } + it("should handle GPT-5.1 model", async () => { + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + }) + + mockStreamTextReturn([{ type: "text-delta", text: "Hello world" }]) + + const stream = gpt5Handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Hello world") + + // Verify correct model is passed + const provider = mockCreateOpenAI.mock.results[0].value + expect(provider.responses).toHaveBeenCalledWith("gpt-5.1") + }) + it("should not send temperature for GPT-5 models", async () => { const gpt5Handler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "gpt-5.1", }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + const stream = gpt5Handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + for await (const _chunk of stream) { + // consume stream } - expect(mockStreamText).toHaveBeenCalledWith( - expect.objectContaining({ - providerOptions: expect.objectContaining({ - openai: expect.objectContaining({ - reasoningEffort: expect.any(String), - reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], - }), - }), - }), - ) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.temperature).toBeUndefined() }) - it("should pass verbosity in provider options for models that support it", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Response" } + it("should set default textVerbosity 'medium' for GPT-5 models that support verbosity", async () => { + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + }) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.textVerbosity).toBe("medium") + }) + it("should support custom verbosity for GPT-5", async () => { const gpt5Handler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "gpt-5.1", verbosity: "low", }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + const stream = gpt5Handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + for await (const _chunk of stream) { + // consume stream } - expect(mockStreamText).toHaveBeenCalledWith( - expect.objectContaining({ - providerOptions: expect.objectContaining({ - openai: expect.objectContaining({ - textVerbosity: "low", - }), - }), - }), - ) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.textVerbosity).toBe("low") }) - it("should support xhigh reasoning effort for GPT-5.1 Codex Max", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Response" } + it("should support high verbosity for GPT-5", async () => { + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + verbosity: "high", + }) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.textVerbosity).toBe("high") + }) + + it("should set default reasoning effort from model info for GPT-5.1", async () => { + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", }) - const codexHandler = new OpenAiNativeHandler({ + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + // gpt-5.1 has reasoningEffort: "medium" in model info + expect(callArgs.providerOptions?.openai?.reasoningEffort).toBe("medium") + expect(callArgs.providerOptions?.openai?.include).toEqual(["reasoning.encrypted_content"]) + expect(callArgs.providerOptions?.openai?.reasoningSummary).toBe("auto") + }) + + it("should support minimal reasoning effort for GPT-5", async () => { + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5", + reasoningEffort: "minimal" as any, + }) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.reasoningEffort).toBe("minimal") + }) + + it("should support low reasoning effort for GPT-5", async () => { + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + reasoningEffort: "low", + }) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.reasoningEffort).toBe("low") + expect(callArgs.providerOptions?.openai?.reasoningSummary).toBe("auto") + }) + + it("should support xhigh reasoning effort for GPT-5.1 Codex Max", async () => { + const codexMaxHandler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "gpt-5.1-codex-max", reasoningEffort: "xhigh", }) - const stream = codexHandler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = codexMaxHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - expect(mockStreamText).toHaveBeenCalledWith( - expect.objectContaining({ - providerOptions: expect.objectContaining({ - openai: expect.objectContaining({ - reasoningEffort: "xhigh", - }), - }), - }), - ) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.reasoningEffort).toBe("xhigh") }) it("should omit reasoning when selection is 'disable'", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "No reasoning" } + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + reasoningEffort: "disable" as any, + }) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.reasoningEffort).toBeUndefined() + expect(callArgs.providerOptions?.openai?.include).toBeUndefined() + expect(callArgs.providerOptions?.openai?.reasoningSummary).toBeUndefined() + }) - const h = new OpenAiNativeHandler({ + it("should support both verbosity and reasoning effort together for GPT-5", async () => { + const gpt5Handler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "gpt-5.1", - reasoningEffort: "disable" as any, + verbosity: "high", + reasoningEffort: "low", }) - const stream = h.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } const callArgs = mockStreamText.mock.calls[0][0] - expect(callArgs.providerOptions.openai.reasoningEffort).toBeUndefined() - expect(callArgs.providerOptions.openai.include).toBeUndefined() - expect(callArgs.providerOptions.openai.reasoningSummary).toBeUndefined() + expect(callArgs.providerOptions?.openai?.textVerbosity).toBe("high") + expect(callArgs.providerOptions?.openai?.reasoningEffort).toBe("low") + expect(callArgs.providerOptions?.openai?.reasoningSummary).toBe("auto") }) - it("should not pass temperature for models that don't support it", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Response" } + it("should handle GPT-5 Mini model", async () => { + const gpt5MiniHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5-mini-2025-08-07", + }) + + mockStreamTextReturn([{ type: "text-delta", text: "Response" }]) + + const stream = gpt5MiniHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + const provider = mockCreateOpenAI.mock.results[0].value + expect(provider.responses).toHaveBeenCalledWith("gpt-5-mini-2025-08-07") + }) + + it("should handle GPT-5 Nano model", async () => { + const gpt5NanoHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5-nano-2025-08-07", }) + mockStreamTextReturn([{ type: "text-delta", text: "Nano response" }]) + + const stream = gpt5NanoHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const provider = mockCreateOpenAI.mock.results[0].value + expect(provider.responses).toHaveBeenCalledWith("gpt-5-nano-2025-08-07") + }) + + it("should include usage information with cost for GPT-5", async () => { const gpt5Handler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "gpt-5.1", }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }], { inputTokens: 100, outputTokens: 20 }) + const stream = gpt5Handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - const callArgs = mockStreamText.mock.calls[0][0] - // GPT-5 models have supportsTemperature: false - const gpt51Info = openAiNativeModels["gpt-5.1"] - if (gpt51Info.supportsTemperature === false) { - expect(callArgs.temperature).toBeUndefined() - } + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0]).toMatchObject({ + type: "usage", + inputTokens: 100, + outputTokens: 20, + totalCost: expect.any(Number), + }) + + // Verify cost calculation (GPT-5.1 pricing: input $1.25/M, output $10/M) + const expectedInputCost = (100 / 1_000_000) * 1.25 + const expectedOutputCost = (20 / 1_000_000) * 10.0 + const expectedTotalCost = expectedInputCost + expectedOutputCost + expect(usageChunks[0].totalCost).toBeCloseTo(expectedTotalCost, 10) }) + }) + + describe("Verbosity gating for non-GPT-5 models", () => { + it("should omit textVerbosity for gpt-4.1", async () => { + const gpt41Handler = new OpenAiNativeHandler({ + apiModelId: "gpt-4.1", + openAiNativeApiKey: "test-api-key", + verbosity: "high", + }) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - it("should not include verbosity for non-GPT-5 models", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Response" } + const stream = gpt41Handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.textVerbosity).toBeUndefined() + }) + + it("should omit textVerbosity for gpt-4o", async () => { + const gpt4oHandler = new OpenAiNativeHandler({ + apiModelId: "gpt-4o", + openAiNativeApiKey: "test-api-key", + verbosity: "low", }) - // gpt-4.1 does not support verbosity - const stream = handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = gpt4oHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } const callArgs = mockStreamText.mock.calls[0][0] - expect(callArgs.providerOptions.openai.textVerbosity).toBeUndefined() + expect(callArgs.providerOptions?.openai?.textVerbosity).toBeUndefined() }) + }) - it("should handle GPT-5 models with multiple stream chunks", async () => { - async function* mockFullStream() { - yield { type: "reasoning-delta", text: "reasoning step 1" } - yield { type: "reasoning-delta", text: " step 2" } - yield { type: "text-delta", text: "Hello" } - yield { type: "text-delta", text: " world" } - } + describe("completePrompt", () => { + it("should handle non-streaming completion", async () => { + mockGenerateText.mockResolvedValue({ text: "This is the completion response" }) - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ - inputTokens: 100, - outputTokens: 50, - details: { reasoningTokens: 20 }, + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("This is the completion response") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", }), - providerMetadata: Promise.resolve({ - openai: { responseId: "resp_gpt5_test" }, + ) + }) + + it("should handle SDK errors in completePrompt", async () => { + mockGenerateText.mockRejectedValue(new Error("API Error")) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow( + "OpenAI Native completion error: API Error", + ) + }) + + it("should return empty string when no text in response", async () => { + mockGenerateText.mockResolvedValue({ text: "" }) + + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("") + }) + + it("should pass providerOptions including store: false to generateText", async () => { + mockGenerateText.mockResolvedValue({ text: "response" }) + + await handler.completePrompt("Test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + store: false, + }), + }), }), - content: Promise.resolve([]), + ) + }) + + it("should use provider.responses() for language model in completePrompt", async () => { + mockGenerateText.mockResolvedValue({ text: "response" }) + + await handler.completePrompt("Test prompt") + + expect(mockCreateOpenAI).toHaveBeenCalled() + const provider = mockCreateOpenAI.mock.results[0].value + expect(provider.responses).toHaveBeenCalledWith("gpt-4.1") + }) + }) + + describe("getModel", () => { + it("should return model info", () => { + const modelInfo = handler.getModel() + expect(modelInfo.id).toBe(mockOptions.apiModelId) + expect(modelInfo.info).toBeDefined() + expect(modelInfo.info.maxTokens).toBe(32768) + expect(modelInfo.info.contextWindow).toBe(1047576) + }) + + it("should handle undefined model ID", () => { + const handlerWithoutModel = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-api-key", }) + const modelInfo = handlerWithoutModel.getModel() + expect(modelInfo.id).toBe("gpt-5.1-codex-max") // Default model + expect(modelInfo.info).toBeDefined() + }) - const gpt5Handler = new OpenAiNativeHandler({ + it("should use 0 as the default temperature", () => { + const model = handler.getModel() + expect(model.temperature).toBe(0) + }) + + it("should respect user-provided temperature", () => { + const handlerWithTemp = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5.1", + modelTemperature: 0.7, }) + const model = handlerWithTemp.getModel() + expect(model.temperature).toBe(0.7) + }) - const stream = gpt5Handler.createMessage(systemPrompt, messages) + it("should strip o3-mini suffix from model id", () => { + const o3Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "o3-mini-high", + }) + const model = o3Handler.getModel() + expect(model.id).toBe("o3-mini") + }) + }) + + describe("error telemetry", () => { + const errorMessages: NeutralMessageParam[] = [ + { + role: "user", + content: "Hello", + }, + ] + const errorSystemPrompt = "You are a helpful assistant" + + beforeEach(() => { + mockCaptureException.mockClear() + }) + + it("should capture telemetry on createMessage error", async () => { + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "" } + throw new Error("Stream error occurred") + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), + }) + + const stream = handler.createMessage(errorSystemPrompt, errorMessages) + + await expect(async () => { + for await (const _chunk of stream) { + // Should throw + } + }).rejects.toThrow() + + // Verify telemetry was captured + expect(mockCaptureException).toHaveBeenCalledTimes(1) + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Stream error occurred", + provider: "OpenAI Native", + modelId: "gpt-4.1", + operation: "createMessage", + }), + ) + + // Verify it's an ApiProviderError + const capturedError = mockCaptureException.mock.calls[0][0] + expect(capturedError).toBeInstanceOf(ApiProviderError) + }) + + it("should capture telemetry on completePrompt error", async () => { + mockGenerateText.mockRejectedValue(new Error("API Error")) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow() + + // Verify telemetry was captured + expect(mockCaptureException).toHaveBeenCalledTimes(1) + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "API Error", + provider: "OpenAI Native", + modelId: "gpt-4.1", + operation: "completePrompt", + }), + ) + + // Verify it's an ApiProviderError + const capturedError = mockCaptureException.mock.calls[0][0] + expect(capturedError).toBeInstanceOf(ApiProviderError) + }) + + it("should still throw the error after capturing telemetry", async () => { + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "" } + throw new Error("Server Error") + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), + }) + + const stream = handler.createMessage(errorSystemPrompt, errorMessages) + + // Verify the error is still thrown + await expect(async () => { + for await (const _chunk of stream) { + // Should throw + } + }).rejects.toThrow() + + // Telemetry should have been captured before the error was thrown + expect(mockCaptureException).toHaveBeenCalled() + }) + }) + + describe("Codex Mini Model", () => { + it("should handle codex-mini-latest streaming response", async () => { + const codexHandler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-api-key", + apiModelId: "codex-mini-latest", + }) + + mockStreamTextReturn( + [ + { type: "text-delta", text: "Hello" }, + { type: "text-delta", text: " from" }, + { type: "text-delta", text: " Codex" }, + { type: "text-delta", text: " Mini!" }, + ], + { inputTokens: 50, outputTokens: 10 }, + ) + + const stream = codexHandler.createMessage("You are a helpful coding assistant.", [ + { role: "user", content: "Write a hello world function" }, + ]) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } - const reasoning = chunks.filter((c) => c.type === "reasoning") - expect(reasoning).toHaveLength(2) + // Verify text chunks + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(4) + expect(textChunks.map((c) => c.text).join("")).toBe("Hello from Codex Mini!") - const text = chunks.filter((c) => c.type === "text") - expect(text).toHaveLength(2) + // Verify usage data + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0]).toMatchObject({ + type: "usage", + inputTokens: 50, + outputTokens: 10, + totalCost: expect.any(Number), + }) - const usage = chunks.filter((c) => c.type === "usage") - expect(usage).toHaveLength(1) - expect(usage[0].reasoningTokens).toBe(20) + // Verify cost is calculated correctly (Codex Mini: $1.5/M input, $6/M output) + const expectedCost = (50 / 1_000_000) * 1.5 + (10 / 1_000_000) * 6 + expect(usageChunks[0].totalCost).toBeCloseTo(expectedCost, 10) }) - }) - describe("service tier", () => { - it("should pass service tier in provider options when supported", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Response" } - } + it("should handle codex-mini-latest non-streaming completion", async () => { + const codexHandler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-api-key", + apiModelId: "codex-mini-latest", + }) + + mockGenerateText.mockResolvedValue({ text: "def hello_world():\n print('Hello, World!')" }) + + const result = await codexHandler.completePrompt("Write a hello world function in Python") + + expect(result).toBe("def hello_world():\n print('Hello, World!')") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Write a hello world function in Python", + }), + ) + + // Verify the model is correct + const provider = mockCreateOpenAI.mock.results[0].value + expect(provider.responses).toHaveBeenCalledWith("codex-mini-latest") + }) + + it("should handle codex-mini-latest API errors", async () => { + const codexHandler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-api-key", + apiModelId: "codex-mini-latest", + }) mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + fullStream: (async function* () { + yield { type: "text-delta", text: "" } + throw new Error("Rate limit exceeded") + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + response: Promise.resolve({ messages: [] }), }) - const tierHandler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - openAiNativeServiceTier: "flex", + const stream = codexHandler.createMessage("You are a helpful assistant.", [ + { role: "user", content: "Hello" }, + ]) + + await expect(async () => { + for await (const _chunk of stream) { + // consume stream + } + }).rejects.toThrow("Rate limit exceeded") + }) + + it("should not set temperature for codex-mini-latest (supportsTemperature: false)", async () => { + const codexHandler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-api-key", + apiModelId: "codex-mini-latest", }) - const stream = tierHandler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = codexHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } const callArgs = mockStreamText.mock.calls[0][0] - // Tier should be passed when model supports it - const model = tierHandler.getModel() - const allowedTiers = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) - if (allowedTiers.has("flex")) { - expect(callArgs.providerOptions.openai.serviceTier).toBe("flex") - } + expect(callArgs.temperature).toBeUndefined() + }) + }) + + describe("getEncryptedContent", () => { + it("should return undefined initially", () => { + expect(handler.getEncryptedContent()).toBeUndefined() }) - it("should capture service tier from provider metadata", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Test" } + it("should extract encrypted content from response messages", async () => { + mockStreamText.mockReturnValue({ + fullStream: createMockFullStream([{ type: "text-delta", text: "response" }])(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ + messages: [ + { + content: [ + { + type: "reasoning", + providerMetadata: { + openai: { + reasoningEncryptedContent: "enc_abc123", + itemId: "item_456", + }, + }, + }, + ], + }, + ], + }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } + const encrypted = handler.getEncryptedContent() + expect(encrypted).toEqual({ + encrypted_content: "enc_abc123", + id: "item_456", + }) + }) + }) + + describe("getResponseId", () => { + it("should return undefined initially", () => { + expect(handler.getResponseId()).toBeUndefined() + }) + + it("should extract response ID from provider metadata", async () => { mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), + fullStream: createMockFullStream([{ type: "text-delta", text: "response" }])(), usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), providerMetadata: Promise.resolve({ openai: { - responseId: "resp_123", - serviceTier: "flex", + responseId: "resp_test_123", }, }), - content: Promise.resolve([]), + response: Promise.resolve({ messages: [] }), }) - const tierHandler = new OpenAiNativeHandler({ + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + expect(handler.getResponseId()).toBe("resp_test_123") + }) + }) + + describe("service tier pricing", () => { + it("should extract service tier from provider metadata", async () => { + const gpt4oHandler = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5.1", - openAiNativeServiceTier: "flex", + apiModelId: "gpt-4o", + openAiNativeServiceTier: "priority", }) - const stream = tierHandler.createMessage(systemPrompt, messages) + mockStreamText.mockReturnValue({ + fullStream: createMockFullStream([{ type: "text-delta", text: "response" }])(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({ + openai: { + serviceTier: "priority", + }, + }), + response: Promise.resolve({ messages: [] }), + }) + + const stream = gpt4oHandler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } - // Usage should include totalCost (calculated with tier pricing) - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(typeof usageChunks[0].totalCost).toBe("number") + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + // Priority tier for gpt-4o: input $4.25/M, output $17/M + const expectedCost = (100 / 1_000_000) * 4.25 + (50 / 1_000_000) * 17.0 + expect(usageChunk.totalCost).toBeCloseTo(expectedCost, 10) }) - }) - describe("prompt cache retention", () => { - it("should pass promptCacheRetention for models that support it", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Response" } + it("should pass service tier in providerOptions when model supports it", async () => { + const gpt4oHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-4o", + openAiNativeServiceTier: "priority", + }) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = gpt4oHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), - }) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.serviceTier).toBe("priority") + }) + }) - const h = new OpenAiNativeHandler({ + describe("prompt cache retention", () => { + it("should set promptCacheRetention for gpt-5.1 models", async () => { + const gpt51Handler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "gpt-5.1", }) - const stream = h.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = gpt51Handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } const callArgs = mockStreamText.mock.calls[0][0] - const modelInfo = openAiNativeModels["gpt-5.1"] - if (modelInfo.supportsPromptCache && modelInfo.promptCacheRetention === "24h") { - expect(callArgs.providerOptions.openai.promptCacheRetention).toBe("24h") - } + expect(callArgs.providerOptions?.openai?.promptCacheRetention).toBe("24h") }) - it("should not pass promptCacheRetention for models without support", async () => { - async function* mockFullStream() { - yield { type: "text-delta", text: "Response" } + it("should not set promptCacheRetention for models without it", async () => { + // gpt-4.1 has supportsPromptCache but no promptCacheRetention + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - mockStreamText.mockReturnValue({ - fullStream: mockFullStream(), - usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), - providerMetadata: Promise.resolve({}), - content: Promise.resolve([]), + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.promptCacheRetention).toBeUndefined() + }) + + it("should not set promptCacheRetention for codex-mini-latest", async () => { + const codexHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "codex-mini-latest", }) - // gpt-4.1 doesn't have promptCacheRetention: "24h" - const stream = handler.createMessage(systemPrompt, messages) - for await (const _ of stream) { - // consume + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = codexHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } const callArgs = mockStreamText.mock.calls[0][0] - expect(callArgs.providerOptions.openai.promptCacheRetention).toBeUndefined() + expect(callArgs.providerOptions?.openai?.promptCacheRetention).toBeUndefined() }) }) - describe("completePrompt", () => { - it("should complete prompt using generateText", async () => { - mockGenerateText.mockResolvedValue({ - text: "This is the completion response", - usage: { inputTokens: 10, outputTokens: 5 }, - }) + describe("conversation formatting", () => { + it("should convert messages for AI SDK and pass to streamText", async () => { + mockStreamTextReturn([{ type: "text-delta", text: "Response" }]) - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("This is the completion response") - expect(mockGenerateText).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: "Test prompt", - providerOptions: expect.objectContaining({ - openai: expect.objectContaining({ - store: false, - }), - }), - }), - ) - }) + const multiMessages: NeutralMessageParam[] = [ + { role: "user", content: "First question" }, + { role: "assistant", content: "First answer" }, + { role: "user", content: "Second question" }, + ] - it("should handle errors in completePrompt", async () => { - mockGenerateText.mockRejectedValue(new Error("API Error")) + const stream = handler.createMessage(systemPrompt, multiMessages) + for await (const _chunk of stream) { + // consume stream + } - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI Native") + const callArgs = mockStreamText.mock.calls[0][0] + // Messages are converted via convertToAiSdkMessages + expect(callArgs.messages).toBeDefined() + expect(Array.isArray(callArgs.messages)).toBe(true) + expect(callArgs.messages.length).toBeGreaterThan(0) }) + }) - it("should return empty string when no text in response", async () => { - mockGenerateText.mockResolvedValue({ - text: "", - usage: { inputTokens: 10, outputTokens: 0 }, + describe("usage with cache tokens", () => { + it("should include cache read tokens from usage details", async () => { + mockStreamText.mockReturnValue({ + fullStream: createMockFullStream([{ type: "text-delta", text: "response" }])(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 30, + }, + }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), }) - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") - }) - }) + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - describe("isAiSdkProvider", () => { - it("should return true", () => { - expect(handler.isAiSdkProvider()).toBe(true) + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.cacheReadTokens).toBe(30) }) - }) - describe("getEncryptedContent", () => { - it("should return undefined when no encrypted content has been captured", () => { - expect(handler.getEncryptedContent()).toBeUndefined() + it("should include cache write tokens from provider metadata", async () => { + mockStreamText.mockReturnValue({ + fullStream: createMockFullStream([{ type: "text-delta", text: "response" }])(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({ + openai: { + cacheCreationInputTokens: 20, + }, + }), + response: Promise.resolve({ messages: [] }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.cacheWriteTokens).toBe(20) }) - }) - describe("getResponseId", () => { - it("should return undefined when no response ID has been captured", () => { - expect(handler.getResponseId()).toBeUndefined() + it("should include reasoning tokens from usage details", async () => { + mockStreamText.mockReturnValue({ + fullStream: createMockFullStream([{ type: "text-delta", text: "response" }])(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 150, + details: { + reasoningTokens: 50, + }, + }), + providerMetadata: Promise.resolve({}), + response: Promise.resolve({ messages: [] }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk.reasoningTokens).toBe(50) }) }) }) diff --git a/src/api/providers/__tests__/openai-timeout.spec.ts b/src/api/providers/__tests__/openai-timeout.spec.ts index 2a09fd94ffa..d70da4e5728 100644 --- a/src/api/providers/__tests__/openai-timeout.spec.ts +++ b/src/api/providers/__tests__/openai-timeout.spec.ts @@ -1,143 +1,184 @@ // npx vitest run api/providers/__tests__/openai-timeout.spec.ts - -import { OpenAiHandler } from "../openai" -import { ApiHandlerOptions } from "../../../shared/api" - -// Mock the timeout config utility -vitest.mock("../utils/timeout-config", () => ({ - getApiRequestTimeout: vitest.fn(), +// +// NOTE: The OpenAiHandler now uses @ai-sdk/openai (createOpenAI) which does not +// expose a `timeout` option directly. Timeouts are managed at the fetch level. +// These tests verify provider creation for different configurations instead. + +const { mockStreamText, mockGenerateText, mockCreateOpenAI } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockCreateOpenAI: vi.fn(), })) -import { getApiRequestTimeout } from "../utils/timeout-config" - -// Mock OpenAI and AzureOpenAI -const mockOpenAIConstructor = vitest.fn() -const mockAzureOpenAIConstructor = vitest.fn() - -vitest.mock("openai", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: vitest.fn().mockImplementation((config) => { - mockOpenAIConstructor(config) - return { - chat: { - completions: { - create: vitest.fn(), - }, - }, - } - }), - AzureOpenAI: vitest.fn().mockImplementation((config) => { - mockAzureOpenAIConstructor(config) - return { - chat: { - completions: { - create: vitest.fn(), - }, - }, - } - }), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -describe("OpenAiHandler timeout configuration", () => { +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: mockCreateOpenAI.mockImplementation(() => ({ + chat: vi.fn(() => ({ + modelId: "test-model", + provider: "openai.chat", + })), + })), +})) + +import { OpenAiHandler } from "../openai" +import type { ApiHandlerOptions } from "../../../shared/api" + +describe("OpenAiHandler provider configuration", () => { beforeEach(() => { - vitest.clearAllMocks() + vi.clearAllMocks() }) - it("should use default timeout for standard OpenAI", () => { - ;(getApiRequestTimeout as any).mockReturnValue(600000) - + it("should create provider with standard OpenAI config", async () => { const options: ApiHandlerOptions = { - apiModelId: "gpt-4", openAiModelId: "gpt-4", openAiApiKey: "test-key", } - new OpenAiHandler(options) + const handler = new OpenAiHandler(options) - expect(getApiRequestTimeout).toHaveBeenCalled() - expect(mockOpenAIConstructor).toHaveBeenCalledWith( + // Need to trigger createMessage to invoke createProvider + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "response" } + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage("system", [{ role: "user", content: "Hello" }]) + for await (const _chunk of stream) { + // consume + } + + expect(mockCreateOpenAI).toHaveBeenCalledWith( expect.objectContaining({ baseURL: "https://api.openai.com/v1", apiKey: "test-key", - timeout: 600000, // 600 seconds in milliseconds }), ) }) - it("should use custom timeout for OpenAI-compatible providers", () => { - ;(getApiRequestTimeout as any).mockReturnValue(1800000) // 30 minutes - + it("should create provider with custom base URL for OpenAI-compatible providers", async () => { const options: ApiHandlerOptions = { - apiModelId: "custom-model", openAiModelId: "custom-model", openAiBaseUrl: "http://localhost:8080/v1", openAiApiKey: "test-key", } - new OpenAiHandler(options) + const handler = new OpenAiHandler(options) + + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "response" } + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) - expect(mockOpenAIConstructor).toHaveBeenCalledWith( + const stream = handler.createMessage("system", [{ role: "user", content: "Hello" }]) + for await (const _chunk of stream) { + // consume + } + + expect(mockCreateOpenAI).toHaveBeenCalledWith( expect.objectContaining({ baseURL: "http://localhost:8080/v1", - timeout: 1800000, // 1800 seconds in milliseconds }), ) }) - it("should use timeout for Azure OpenAI", () => { - ;(getApiRequestTimeout as any).mockReturnValue(900000) // 15 minutes - + it("should create provider with Azure OpenAI config including api-key header", async () => { const options: ApiHandlerOptions = { - apiModelId: "gpt-4", openAiModelId: "gpt-4", openAiBaseUrl: "https://myinstance.openai.azure.com", openAiApiKey: "test-key", openAiUseAzure: true, } - new OpenAiHandler(options) + const handler = new OpenAiHandler(options) + + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "response" } + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) - expect(mockAzureOpenAIConstructor).toHaveBeenCalledWith( + const stream = handler.createMessage("system", [{ role: "user", content: "Hello" }]) + for await (const _chunk of stream) { + // consume + } + + expect(mockCreateOpenAI).toHaveBeenCalledWith( expect.objectContaining({ - timeout: 900000, // 900 seconds in milliseconds + headers: expect.objectContaining({ + "api-key": "test-key", + }), }), ) }) - it("should use timeout for Azure AI Inference", () => { - ;(getApiRequestTimeout as any).mockReturnValue(1200000) // 20 minutes - + it("should create provider with Azure AI Inference config using /models baseURL", async () => { const options: ApiHandlerOptions = { - apiModelId: "deepseek", openAiModelId: "deepseek", openAiBaseUrl: "https://myinstance.services.ai.azure.com", openAiApiKey: "test-key", } - new OpenAiHandler(options) + const handler = new OpenAiHandler(options) + + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "response" } + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) - expect(mockOpenAIConstructor).toHaveBeenCalledWith( + const stream = handler.createMessage("system", [{ role: "user", content: "Hello" }]) + for await (const _chunk of stream) { + // consume + } + + expect(mockCreateOpenAI).toHaveBeenCalledWith( expect.objectContaining({ - timeout: 1200000, // 1200 seconds in milliseconds + baseURL: "https://myinstance.services.ai.azure.com/models", }), ) }) - it("should handle zero timeout (no timeout)", () => { - ;(getApiRequestTimeout as any).mockReturnValue(0) - + it("should use default base URL when none provided", async () => { const options: ApiHandlerOptions = { - apiModelId: "gpt-4", openAiModelId: "gpt-4", } - new OpenAiHandler(options) + const handler = new OpenAiHandler(options) + + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "response" } + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) - expect(mockOpenAIConstructor).toHaveBeenCalledWith( + const stream = handler.createMessage("system", [{ role: "user", content: "Hello" }]) + for await (const _chunk of stream) { + // consume + } + + expect(mockCreateOpenAI).toHaveBeenCalledWith( expect.objectContaining({ - timeout: 0, // No timeout + baseURL: "https://api.openai.com/v1", }), ) }) diff --git a/src/api/providers/__tests__/openai-usage-tracking.spec.ts b/src/api/providers/__tests__/openai-usage-tracking.spec.ts index fc80360eee7..cc696f90465 100644 --- a/src/api/providers/__tests__/openai-usage-tracking.spec.ts +++ b/src/api/providers/__tests__/openai-usage-tracking.spec.ts @@ -1,94 +1,32 @@ // npx vitest run api/providers/__tests__/openai-usage-tracking.spec.ts -import { Anthropic } from "@anthropic-ai/sdk" +const { mockStreamText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), +})) -import { ApiHandlerOptions } from "../../../shared/api" -import { OpenAiHandler } from "../openai" - -const mockCreate = vitest.fn() - -vitest.mock("openai", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: vitest.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate.mockImplementation(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - choices: [ - { - message: { role: "assistant", content: "Test response", refusal: null }, - finish_reason: "stop", - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - } - - // Return a stream with multiple chunks that include usage metrics - return { - [Symbol.asyncIterator]: async function* () { - // First chunk with partial usage - yield { - choices: [ - { - delta: { content: "Test " }, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 2, - total_tokens: 12, - }, - } - - // Second chunk with updated usage - yield { - choices: [ - { - delta: { content: "response" }, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 4, - total_tokens: 14, - }, - } - - // Final chunk with complete usage - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - }, - } - }), - }, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: vi.fn(), } }) -describe("OpenAiHandler with usage tracking fix", () => { +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: vi.fn(() => ({ + chat: vi.fn(() => ({ + modelId: "gpt-4", + provider: "openai.chat", + })), + })), +})) + +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" +import type { ApiHandlerOptions } from "../../../shared/api" +import { OpenAiHandler } from "../openai" + +describe("OpenAiHandler with usage tracking (AI SDK)", () => { let handler: OpenAiHandler let mockOptions: ApiHandlerOptions @@ -99,12 +37,12 @@ describe("OpenAiHandler with usage tracking fix", () => { openAiBaseUrl: "https://api.openai.com/v1", } handler = new OpenAiHandler(mockOptions) - mockCreate.mockClear() + vi.clearAllMocks() }) describe("usage metrics with streaming", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [ @@ -117,6 +55,21 @@ describe("OpenAiHandler with usage tracking fix", () => { ] it("should only yield usage metrics once at the end of the stream", async () => { + // AI SDK provides usage once after the stream completes + async function* mockFullStream() { + yield { type: "text-delta", text: "Test " } + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }), + providerMetadata: Promise.resolve({}), + }) + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { @@ -136,51 +89,36 @@ describe("OpenAiHandler with usage tracking fix", () => { type: "usage", inputTokens: 10, outputTokens: 5, + cacheWriteTokens: undefined, + cacheReadTokens: undefined, }) - // Check the usage chunk is the last one reported from the API + // Check the usage chunk is the last one const lastChunk = chunks[chunks.length - 1] expect(lastChunk.type).toBe("usage") expect(lastChunk.inputTokens).toBe(10) expect(lastChunk.outputTokens).toBe(5) }) - it("should handle case where usage is only in the final chunk", async () => { - // Override the mock for this specific test - mockCreate.mockImplementationOnce(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - choices: [{ message: { role: "assistant", content: "Test response" } }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - } - - return { - [Symbol.asyncIterator]: async function* () { - // First chunk with no usage - yield { - choices: [{ delta: { content: "Test " }, index: 0 }], - usage: null, - } - - // Second chunk with no usage - yield { - choices: [{ delta: { content: "response" }, index: 0 }], - usage: null, - } - - // Final chunk with usage data - yield { - choices: [{ delta: {}, index: 0 }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } + it("should handle case where usage includes cache details", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: { + cachedInputTokens: 3, }, - } + }), + providerMetadata: Promise.resolve({ + openai: { + cacheCreationInputTokens: 7, + }, + }), }) const stream = handler.createMessage(systemPrompt, messages) @@ -189,39 +127,27 @@ describe("OpenAiHandler with usage tracking fix", () => { chunks.push(chunk) } - // Check usage metrics + // Check usage metrics include cache info const usageChunks = chunks.filter((chunk) => chunk.type === "usage") expect(usageChunks).toHaveLength(1) expect(usageChunks[0]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 5, + cacheReadTokens: 3, + cacheWriteTokens: 7, }) }) it("should handle case where no usage is provided", async () => { - // Override the mock for this specific test - mockCreate.mockImplementationOnce(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - choices: [{ message: { role: "assistant", content: "Test response" } }], - usage: null, - } - } - - return { - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" }, index: 0 }], - usage: null, - } - yield { - choices: [{ delta: {}, index: 0 }], - usage: null, - } - }, - } + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve(undefined), + providerMetadata: Promise.resolve({}), }) const stream = handler.createMessage(systemPrompt, messages) @@ -230,7 +156,7 @@ describe("OpenAiHandler with usage tracking fix", () => { chunks.push(chunk) } - // Check we don't have any usage chunks + // Check we don't have any usage chunks when usage is undefined const usageChunks = chunks.filter((chunk) => chunk.type === "usage") expect(usageChunks).toHaveLength(0) }) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 73b542dbc73..b74ee9443d3 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1,81 +1,69 @@ // npx vitest run api/providers/__tests__/openai.spec.ts -import { OpenAiHandler, getOpenAiModels } from "../openai" -import { ApiHandlerOptions } from "../../../shared/api" -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" -import { openAiModelInfoSaneDefaults } from "@roo-code/types" -import { Package } from "../../../shared/package" -import axios from "axios" - -const mockCreate = vitest.fn() +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText, mockCreateOpenAI } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockCreateOpenAI: vi.fn(), +})) -vitest.mock("openai", () => { - const mockConstructor = vitest.fn() +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: mockConstructor.mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate.mockImplementation(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - choices: [ - { - message: { role: "assistant", content: "Test response", refusal: null }, - finish_reason: "stop", - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - } - - return { - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - }, - } - }), - }, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: mockCreateOpenAI.mockImplementation(() => ({ + chat: vi.fn(() => ({ + modelId: "gpt-4", + provider: "openai.chat", + })), + })), +})) + // Mock axios for getOpenAiModels tests -vitest.mock("axios", () => ({ +vi.mock("axios", () => ({ default: { - get: vitest.fn(), + get: vi.fn(), }, })) +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" +import { openAiModelInfoSaneDefaults } from "@roo-code/types" +import axios from "axios" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { OpenAiHandler, getOpenAiModels } from "../openai" + +// Helper: create a standard mock fullStream generator +function createMockFullStream( + parts: Array<{ type: string; text?: string; id?: string; toolName?: string; delta?: string }>, +) { + return async function* () { + for (const part of parts) { + yield part + } + } +} + +// Helper: create default mock return value for streamText +function mockStreamTextReturn( + parts: Array<{ type: string; text?: string; id?: string; toolName?: string; delta?: string }>, + usage = { inputTokens: 10, outputTokens: 5 }, + providerMetadata: Record = {}, +) { + mockStreamText.mockReturnValue({ + fullStream: createMockFullStream(parts)(), + usage: Promise.resolve(usage), + providerMetadata: Promise.resolve(providerMetadata), + }) +} + describe("OpenAiHandler", () => { let handler: OpenAiHandler let mockOptions: ApiHandlerOptions @@ -87,7 +75,7 @@ describe("OpenAiHandler", () => { openAiBaseUrl: "https://api.openai.com/v1", } handler = new OpenAiHandler(mockOptions) - mockCreate.mockClear() + vi.clearAllMocks() }) describe("constructor", () => { @@ -104,25 +92,17 @@ describe("OpenAiHandler", () => { }) expect(handlerWithCustomUrl).toBeInstanceOf(OpenAiHandler) }) + }) - it("should set default headers correctly", () => { - // Check that the OpenAI constructor was called with correct parameters - expect(vi.mocked(OpenAI)).toHaveBeenCalledWith({ - baseURL: expect.any(String), - apiKey: expect.any(String), - defaultHeaders: { - "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", - "X-Title": "Roo Code", - "User-Agent": `RooCode/${Package.version}`, - }, - timeout: expect.any(Number), - }) + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) }) }) describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [ @@ -134,79 +114,9 @@ describe("OpenAiHandler", () => { }, ] - it("should handle non-streaming mode", async () => { - const handler = new OpenAiHandler({ - ...mockOptions, - openAiStreamingEnabled: false, - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks.length).toBeGreaterThan(0) - const textChunk = chunks.find((chunk) => chunk.type === "text") - const usageChunk = chunks.find((chunk) => chunk.type === "usage") - - expect(textChunk).toBeDefined() - expect(textChunk?.text).toBe("Test response") - expect(usageChunk).toBeDefined() - expect(usageChunk?.inputTokens).toBe(10) - expect(usageChunk?.outputTokens).toBe(5) - }) - - it("should handle tool calls in non-streaming mode", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [ - { - message: { - role: "assistant", - content: null, - tool_calls: [ - { - id: "call_1", - type: "function", - function: { - name: "test_tool", - arguments: '{"arg":"value"}', - }, - }, - ], - }, - finish_reason: "tool_calls", - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - }) - - const handler = new OpenAiHandler({ - ...mockOptions, - openAiStreamingEnabled: false, - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0]).toEqual({ - type: "tool_call", - id: "call_1", - name: "test_tool", - arguments: '{"arg":"value"}', - }) - }) - it("should handle streaming responses", async () => { + mockStreamTextReturn([{ type: "text-delta", text: "Test response" }]) + const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { @@ -219,49 +129,8 @@ describe("OpenAiHandler", () => { expect(textChunks[0].text).toBe("Test response") }) - it("should handle tool calls in streaming responses", async () => { - mockCreate.mockImplementation(async (options) => { - return { - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_1", - function: { name: "test_tool", arguments: "" }, - }, - ], - }, - finish_reason: null, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [{ index: 0, function: { arguments: '{"arg":' } }], - }, - finish_reason: null, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [{ index: 0, function: { arguments: '"value"}' } }], - }, - finish_reason: "tool_calls", - }, - ], - } - }, - } - }) + it("should include usage information", async () => { + mockStreamTextReturn([{ type: "text-delta", text: "Test response" }], { inputTokens: 10, outputTokens: 5 }) const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -269,70 +138,19 @@ describe("OpenAiHandler", () => { chunks.push(chunk) } - // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly - const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - expect(toolCallPartialChunks).toHaveLength(3) - // First chunk has id and name - expect(toolCallPartialChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_1", - name: "test_tool", - arguments: "", - }) - // Subsequent chunks have arguments - expect(toolCallPartialChunks[1]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '{"arg":', - }) - expect(toolCallPartialChunks[2]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"value"}', - }) - - // Verify tool_call_end event is emitted when finish_reason is "tool_calls" - const toolCallEndChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - expect(toolCallEndChunks).toHaveLength(1) + const usageChunk = chunks.find((chunk) => chunk.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk?.inputTokens).toBe(10) + expect(usageChunk?.outputTokens).toBe(5) }) - it("should yield tool calls even when finish_reason is not set (fallback behavior)", async () => { - mockCreate.mockImplementation(async (options) => { - return { - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_fallback", - function: { name: "fallback_tool", arguments: '{"test":"fallback"}' }, - }, - ], - }, - finish_reason: null, - }, - ], - } - // Stream ends without finish_reason being set to "tool_calls" - yield { - choices: [ - { - delta: {}, - finish_reason: "stop", // Different finish reason - }, - ], - } - }, - } - }) + it("should handle tool calls via AI SDK stream parts", async () => { + mockStreamTextReturn([ + { type: "tool-input-start", id: "call_1", toolName: "test_tool" }, + { type: "tool-input-delta", id: "call_1", delta: '{"arg":' }, + { type: "tool-input-delta", id: "call_1", delta: '"value"}' }, + { type: "tool-input-end", id: "call_1" }, + ]) const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -340,16 +158,16 @@ describe("OpenAiHandler", () => { chunks.push(chunk) } - // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly - const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - expect(toolCallPartialChunks).toHaveLength(1) - expect(toolCallPartialChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_fallback", - name: "fallback_tool", - arguments: '{"test":"fallback"}', - }) + const toolCallStart = chunks.filter((c) => c.type === "tool_call_start") + expect(toolCallStart).toHaveLength(1) + expect(toolCallStart[0].id).toBe("call_1") + expect(toolCallStart[0].name).toBe("test_tool") + + const toolCallDeltas = chunks.filter((c) => c.type === "tool_call_delta") + expect(toolCallDeltas).toHaveLength(2) + + const toolCallEnd = chunks.filter((c) => c.type === "tool_call_end") + expect(toolCallEnd).toHaveLength(1) }) it("should include reasoning_effort when reasoning effort is enabled", async () => { @@ -364,14 +182,17 @@ describe("OpenAiHandler", () => { }, } const reasoningHandler = new OpenAiHandler(reasoningOptions) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + const stream = reasoningHandler.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call for await (const _chunk of stream) { + // consume stream } - // Assert the mockCreate was called with reasoning_effort - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.reasoning_effort).toBe("high") + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.reasoningEffort).toBe("high") }) it("should not include reasoning_effort when reasoning effort is disabled", async () => { @@ -381,17 +202,20 @@ describe("OpenAiHandler", () => { openAiCustomModelInfo: { contextWindow: 128_000, supportsPromptCache: false }, } const noReasoningHandler = new OpenAiHandler(noReasoningOptions) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + const stream = noReasoningHandler.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call for await (const _chunk of stream) { + // consume stream } - // Assert the mockCreate was called without reasoning_effort - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.reasoning_effort).toBeUndefined() + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions).toBeUndefined() }) - it("should include max_tokens when includeMaxTokens is true", async () => { + it("should include maxOutputTokens when includeMaxTokens is true", async () => { const optionsWithMaxTokens: ApiHandlerOptions = { ...mockOptions, includeMaxTokens: true, @@ -402,17 +226,20 @@ describe("OpenAiHandler", () => { }, } const handlerWithMaxTokens = new OpenAiHandler(optionsWithMaxTokens) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + const stream = handlerWithMaxTokens.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call for await (const _chunk of stream) { + // consume stream } - // Assert the mockCreate was called with max_tokens - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.max_completion_tokens).toBe(4096) + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.maxOutputTokens).toBe(4096) }) - it("should not include max_tokens when includeMaxTokens is false", async () => { + it("should not include maxOutputTokens when includeMaxTokens is false", async () => { const optionsWithoutMaxTokens: ApiHandlerOptions = { ...mockOptions, includeMaxTokens: false, @@ -423,20 +250,22 @@ describe("OpenAiHandler", () => { }, } const handlerWithoutMaxTokens = new OpenAiHandler(optionsWithoutMaxTokens) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + const stream = handlerWithoutMaxTokens.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call for await (const _chunk of stream) { + // consume stream } - // Assert the mockCreate was called without max_tokens - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.max_completion_tokens).toBeUndefined() + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.maxOutputTokens).toBeUndefined() }) - it("should not include max_tokens when includeMaxTokens is undefined", async () => { + it("should not include maxOutputTokens when includeMaxTokens is undefined", async () => { const optionsWithUndefinedMaxTokens: ApiHandlerOptions = { ...mockOptions, - // includeMaxTokens is not set, should not include max_tokens openAiCustomModelInfo: { contextWindow: 128_000, maxTokens: 4096, @@ -444,63 +273,97 @@ describe("OpenAiHandler", () => { }, } const handlerWithDefaultMaxTokens = new OpenAiHandler(optionsWithUndefinedMaxTokens) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + const stream = handlerWithDefaultMaxTokens.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call for await (const _chunk of stream) { + // consume stream } - // Assert the mockCreate was called without max_tokens - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.max_completion_tokens).toBeUndefined() + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.maxOutputTokens).toBeUndefined() }) it("should use user-configured modelMaxTokens instead of model default maxTokens", async () => { const optionsWithUserMaxTokens: ApiHandlerOptions = { ...mockOptions, includeMaxTokens: true, - modelMaxTokens: 32000, // User-configured value + modelMaxTokens: 32000, openAiCustomModelInfo: { contextWindow: 128_000, - maxTokens: 4096, // Model's default value (should not be used) + maxTokens: 4096, supportsPromptCache: false, }, } const handlerWithUserMaxTokens = new OpenAiHandler(optionsWithUserMaxTokens) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + const stream = handlerWithUserMaxTokens.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call for await (const _chunk of stream) { + // consume stream } - // Assert the mockCreate was called with user-configured modelMaxTokens (32000), not model default maxTokens (4096) - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.max_completion_tokens).toBe(32000) + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.maxOutputTokens).toBe(32000) }) it("should fallback to model default maxTokens when user modelMaxTokens is not set", async () => { const optionsWithoutUserMaxTokens: ApiHandlerOptions = { ...mockOptions, includeMaxTokens: true, - // modelMaxTokens is not set openAiCustomModelInfo: { contextWindow: 128_000, - maxTokens: 4096, // Model's default value (should be used as fallback) + maxTokens: 4096, supportsPromptCache: false, }, } const handlerWithoutUserMaxTokens = new OpenAiHandler(optionsWithoutUserMaxTokens) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + const stream = handlerWithoutUserMaxTokens.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call for await (const _chunk of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.maxOutputTokens).toBe(4096) + }) + + it("should pass system prompt to streamText", async () => { + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - // Assert the mockCreate was called with model default maxTokens (4096) as fallback - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.max_completion_tokens).toBe(4096) + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.system).toBe(systemPrompt) + }) + + it("should pass temperature 0 as default", async () => { + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.temperature).toBe(0) }) }) describe("error handling", () => { - const testMessages: Anthropic.Messages.MessageParam[] = [ + const testMessages: NeutralMessageParam[] = [ { role: "user", content: [ @@ -513,7 +376,14 @@ describe("OpenAiHandler", () => { ] it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta" as const, textDelta: "" } + throw new Error("API Error") + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) const stream = handler.createMessage("system prompt", testMessages) @@ -526,9 +396,16 @@ describe("OpenAiHandler", () => { it("should handle rate limiting", async () => { const rateLimitError = new Error("Rate limit exceeded") - rateLimitError.name = "Error" ;(rateLimitError as any).status = 429 - mockCreate.mockRejectedValueOnce(rateLimitError) + + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta" as const, textDelta: "" } + throw rateLimitError + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) const stream = handler.createMessage("system prompt", testMessages) @@ -542,26 +419,24 @@ describe("OpenAiHandler", () => { describe("completePrompt", () => { it("should complete prompt successfully", async () => { + mockGenerateText.mockResolvedValue({ text: "Test response" }) + const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") - expect(mockCreate).toHaveBeenCalledWith( - { - model: mockOptions.openAiModelId, - messages: [{ role: "user", content: "Test prompt" }], - }, - {}, + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), ) }) it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) + mockGenerateText.mockRejectedValue(new Error("API Error")) await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error: API Error") }) it("should handle empty response", async () => { - mockCreate.mockImplementationOnce(() => ({ - choices: [{ message: { content: "" } }], - })) + mockGenerateText.mockResolvedValue({ text: "" }) const result = await handler.completePrompt("Test prompt") expect(result).toBe("") }) @@ -585,10 +460,35 @@ describe("OpenAiHandler", () => { expect(model.id).toBe("") expect(model.info).toBeDefined() }) + + it("should use sane defaults when no custom model info is provided", () => { + const model = handler.getModel() + expect(model.info).toBe(openAiModelInfoSaneDefaults) + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) + + it("should use 0 as the default temperature", () => { + const model = handler.getModel() + expect(model.temperature).toBe(0) + }) + + it("should respect user-provided temperature", () => { + const handlerWithTemp = new OpenAiHandler({ + ...mockOptions, + modelTemperature: 0.7, + }) + const model = handlerWithTemp.getModel() + expect(model.temperature).toBe(0.7) + }) }) describe("Azure AI Inference Service", () => { - const azureOptions = { + const azureOptions: ApiHandlerOptions = { ...mockOptions, openAiBaseUrl: "https://test.services.ai.azure.com", openAiModelId: "deepseek-v3", @@ -601,159 +501,82 @@ describe("OpenAiHandler", () => { expect(azureHandler.getModel().id).toBe(azureOptions.openAiModelId) }) - it("should handle streaming responses with Azure AI Inference Service", async () => { + it("should create provider with /models appended to baseURL for Azure AI Inference", async () => { const azureHandler = new OpenAiHandler(azureOptions) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello!", - }, - ] - const stream = azureHandler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + mockStreamTextReturn([{ type: "text-delta", text: "Test response" }]) - expect(chunks.length).toBeGreaterThan(0) - const textChunks = chunks.filter((chunk) => chunk.type === "text") - expect(textChunks).toHaveLength(1) - expect(textChunks[0].text).toBe("Test response") + const stream = azureHandler.createMessage("You are a helpful assistant.", [ + { role: "user", content: "Hello!" }, + ]) + for await (const _chunk of stream) { + // consume stream + } - // Verify the API call was made with correct Azure AI Inference Service path - expect(mockCreate).toHaveBeenCalledWith( - { - model: azureOptions.openAiModelId, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: "Hello!" }, - ], - stream: true, - stream_options: { include_usage: true }, - temperature: 0, - tools: undefined, - tool_choice: undefined, - parallel_tool_calls: true, - }, - { path: "/models/chat/completions" }, + // Verify createOpenAI was called with /models appended to baseURL + expect(mockCreateOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://test.services.ai.azure.com/models", + }), ) - - // Verify max_tokens is NOT included when not explicitly set - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).not.toHaveProperty("max_completion_tokens") }) - it("should handle non-streaming responses with Azure AI Inference Service", async () => { - const azureHandler = new OpenAiHandler({ - ...azureOptions, - openAiStreamingEnabled: false, - }) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello!", - }, - ] + it("should handle streaming responses with Azure AI Inference Service", async () => { + const azureHandler = new OpenAiHandler(azureOptions) - const stream = azureHandler.createMessage(systemPrompt, messages) + mockStreamTextReturn([{ type: "text-delta", text: "Test response" }]) + + const stream = azureHandler.createMessage("You are a helpful assistant.", [ + { role: "user", content: "Hello!" }, + ]) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } expect(chunks.length).toBeGreaterThan(0) - const textChunk = chunks.find((chunk) => chunk.type === "text") - const usageChunk = chunks.find((chunk) => chunk.type === "usage") - - expect(textChunk).toBeDefined() - expect(textChunk?.text).toBe("Test response") - expect(usageChunk).toBeDefined() - expect(usageChunk?.inputTokens).toBe(10) - expect(usageChunk?.outputTokens).toBe(5) - - // Verify the API call was made with correct Azure AI Inference Service path - expect(mockCreate).toHaveBeenCalledWith( - { - model: azureOptions.openAiModelId, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: "Hello!" }, - ], - tools: undefined, - tool_choice: undefined, - parallel_tool_calls: true, - }, - { path: "/models/chat/completions" }, - ) - - // Verify max_tokens is NOT included when not explicitly set - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).not.toHaveProperty("max_completion_tokens") + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response") }) it("should handle completePrompt with Azure AI Inference Service", async () => { const azureHandler = new OpenAiHandler(azureOptions) + mockGenerateText.mockResolvedValue({ text: "Test response" }) + const result = await azureHandler.completePrompt("Test prompt") expect(result).toBe("Test response") - expect(mockCreate).toHaveBeenCalledWith( - { - model: azureOptions.openAiModelId, - messages: [{ role: "user", content: "Test prompt" }], - }, - { path: "/models/chat/completions" }, - ) - - // Verify max_tokens is NOT included when includeMaxTokens is not set - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).not.toHaveProperty("max_completion_tokens") }) }) - describe("Grok xAI Provider", () => { - const grokOptions = { - ...mockOptions, - openAiBaseUrl: "https://api.x.ai/v1", - openAiModelId: "grok-1", - } - - it("should initialize with Grok xAI configuration", () => { - const grokHandler = new OpenAiHandler(grokOptions) - expect(grokHandler).toBeInstanceOf(OpenAiHandler) - expect(grokHandler.getModel().id).toBe(grokOptions.openAiModelId) - }) + describe("Azure OpenAI", () => { + it("should create provider with api-key header for Azure OpenAI", async () => { + const azureOptions: ApiHandlerOptions = { + ...mockOptions, + openAiBaseUrl: "https://myresource.openai.azure.com", + openAiUseAzure: true, + } + const azureHandler = new OpenAiHandler(azureOptions) - it("should exclude stream_options when streaming with Grok xAI", async () => { - const grokHandler = new OpenAiHandler(grokOptions) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello!", - }, - ] + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - const stream = grokHandler.createMessage(systemPrompt, messages) - await stream.next() + const stream = azureHandler.createMessage("system", [{ role: "user", content: "Hello!" }]) + for await (const _chunk of stream) { + // consume stream + } - expect(mockCreate).toHaveBeenCalledWith( + expect(mockCreateOpenAI).toHaveBeenCalledWith( expect.objectContaining({ - model: grokOptions.openAiModelId, - stream: true, + headers: expect.objectContaining({ + "api-key": "test-api-key", + }), }), - {}, ) - - const mockCalls = mockCreate.mock.calls - const lastCall = mockCalls[mockCalls.length - 1] - expect(lastCall[0]).not.toHaveProperty("stream_options") }) }) describe("O3 Family Models", () => { - const o3Options = { + const o3Options: ApiHandlerOptions = { ...mockOptions, openAiModelId: "o3-mini", openAiCustomModelInfo: { @@ -764,83 +587,96 @@ describe("OpenAiHandler", () => { }, } - it("should handle O3 model with streaming and include max_completion_tokens when includeMaxTokens is true", async () => { + it("should use developer systemMessageMode for O3 models", async () => { + const o3Handler = new OpenAiHandler(o3Options) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = o3Handler.createMessage("You are a helpful assistant.", []) + for await (const _chunk of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.systemMessageMode).toBe("developer") + }) + + it("should prepend 'Formatting re-enabled' to system prompt for O3 models", async () => { + const o3Handler = new OpenAiHandler(o3Options) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = o3Handler.createMessage("You are a helpful assistant.", []) + for await (const _chunk of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.system).toBe("Formatting re-enabled\nYou are a helpful assistant.") + }) + + it("should pass undefined temperature for O3 models", async () => { + const o3Handler = new OpenAiHandler(o3Options) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = o3Handler.createMessage("You are a helpful assistant.", []) + for await (const _chunk of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.temperature).toBeUndefined() + }) + + it("should handle O3 model with maxOutputTokens when includeMaxTokens is true", async () => { const o3Handler = new OpenAiHandler({ ...o3Options, includeMaxTokens: true, modelMaxTokens: 32000, - modelTemperature: 0.5, }) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello!", - }, - ] - const stream = o3Handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = o3Handler.createMessage("You are a helpful assistant.", []) + for await (const _chunk of stream) { + // consume stream } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "o3-mini", - messages: [ - { - role: "developer", - content: "Formatting re-enabled\nYou are a helpful assistant.", - }, - { role: "user", content: "Hello!" }, - ], - stream: true, - stream_options: { include_usage: true }, - reasoning_effort: "medium", - temperature: undefined, - // O3 models do not support deprecated max_tokens but do support max_completion_tokens - max_completion_tokens: 32000, - }), - {}, - ) + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.maxOutputTokens).toBe(32000) }) - it("should handle tool calls with O3 model in streaming mode", async () => { + it("should handle O3 model without maxOutputTokens when includeMaxTokens is false", async () => { + const o3Handler = new OpenAiHandler({ + ...o3Options, + includeMaxTokens: false, + }) + + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = o3Handler.createMessage("You are a helpful assistant.", []) + for await (const _chunk of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.maxOutputTokens).toBeUndefined() + }) + + it("should handle tool calls with O3 model", async () => { const o3Handler = new OpenAiHandler(o3Options) - mockCreate.mockImplementation(async (options) => { - return { - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_1", - function: { name: "test_tool", arguments: "" }, - }, - ], - }, - finish_reason: null, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [{ index: 0, function: { arguments: "{}" } }], - }, - finish_reason: "tool_calls", - }, - ], - } - }, - } - }) + mockStreamTextReturn([ + { type: "tool-input-start", id: "call_1", toolName: "test_tool" }, + { type: "tool-input-delta", id: "call_1", delta: "{}" }, + { type: "tool-input-end", id: "call_1" }, + ]) const stream = o3Handler.createMessage("system", []) const chunks: any[] = [] @@ -848,296 +684,214 @@ describe("OpenAiHandler", () => { chunks.push(chunk) } - // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly - const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - expect(toolCallPartialChunks).toHaveLength(2) - expect(toolCallPartialChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_1", - name: "test_tool", - arguments: "", - }) - expect(toolCallPartialChunks[1]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: "{}", + const toolCallStart = chunks.filter((c) => c.type === "tool_call_start") + expect(toolCallStart).toHaveLength(1) + expect(toolCallStart[0].name).toBe("test_tool") + }) + + it("should detect o1 models as O3 family", async () => { + const o1Handler = new OpenAiHandler({ + ...mockOptions, + openAiModelId: "o1-preview", }) - // Verify tool_call_end event is emitted when finish_reason is "tool_calls" - const toolCallEndChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - expect(toolCallEndChunks).toHaveLength(1) - }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - it("should yield tool calls for O3 model even when finish_reason is not set (fallback behavior)", async () => { - const o3Handler = new OpenAiHandler(o3Options) + const stream = o1Handler.createMessage("system", []) + for await (const _chunk of stream) { + // consume stream + } - mockCreate.mockImplementation(async (options) => { - return { - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_o3_fallback", - function: { name: "o3_fallback_tool", arguments: '{"o3":"test"}' }, - }, - ], - }, - finish_reason: null, - }, - ], - } - // Stream ends with different finish reason - yield { - choices: [ - { - delta: {}, - finish_reason: "length", // Different finish reason - }, - ], - } - }, - } + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.systemMessageMode).toBe("developer") + }) + + it("should detect o4 models as O3 family", async () => { + const o4Handler = new OpenAiHandler({ + ...mockOptions, + openAiModelId: "o4-mini", }) - const stream = o3Handler.createMessage("system", []) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = o4Handler.createMessage("system", []) + for await (const _chunk of stream) { + // consume stream } - // Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly - const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - expect(toolCallPartialChunks).toHaveLength(1) - expect(toolCallPartialChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_o3_fallback", - name: "o3_fallback_tool", - arguments: '{"o3":"test"}', - }) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.systemMessageMode).toBe("developer") }) - it("should handle O3 model with streaming and exclude max_tokens when includeMaxTokens is false", async () => { - const o3Handler = new OpenAiHandler({ + it("should handle O3 model with Azure AI Inference Service", async () => { + const o3AzureHandler = new OpenAiHandler({ ...o3Options, + openAiBaseUrl: "https://test.services.ai.azure.com", includeMaxTokens: false, - modelTemperature: 0.7, }) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello!", - }, - ] - const stream = o3Handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = o3AzureHandler.createMessage("You are a helpful assistant.", []) + for await (const _chunk of stream) { + // consume stream } - expect(mockCreate).toHaveBeenCalledWith( + // Verify Azure AI Inference baseURL with /models + expect(mockCreateOpenAI).toHaveBeenCalledWith( expect.objectContaining({ - model: "o3-mini", - messages: [ - { - role: "developer", - content: "Formatting re-enabled\nYou are a helpful assistant.", - }, - { role: "user", content: "Hello!" }, - ], - stream: true, - stream_options: { include_usage: true }, - reasoning_effort: "medium", - temperature: undefined, + baseURL: "https://test.services.ai.azure.com/models", }), - {}, ) - // Verify max_tokens is NOT included - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).not.toHaveProperty("max_completion_tokens") + // Verify O3 family settings + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openai?.systemMessageMode).toBe("developer") + expect(callArgs.temperature).toBeUndefined() + expect(callArgs.maxOutputTokens).toBeUndefined() }) + }) - it("should handle O3 model non-streaming with reasoning_effort and max_completion_tokens when includeMaxTokens is true", async () => { - const o3Handler = new OpenAiHandler({ - ...o3Options, - openAiStreamingEnabled: false, - includeMaxTokens: true, - modelTemperature: 0.3, - }) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello!", - }, - ] + describe("processUsageMetrics", () => { + it("should correctly process usage metrics", () => { + class TestOpenAiHandler extends OpenAiHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } - const stream = o3Handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + const testHandler = new TestOpenAiHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "o3-mini", - messages: [ - { - role: "developer", - content: "Formatting re-enabled\nYou are a helpful assistant.", - }, - { role: "user", content: "Hello!" }, - ], - reasoning_effort: "medium", - temperature: undefined, - // O3 models do not support deprecated max_tokens but do support max_completion_tokens - max_completion_tokens: 65536, // Using default maxTokens from o3Options - }), - {}, - ) + const result = testHandler.testProcessUsageMetrics(usage) - // Verify stream is not set - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).not.toHaveProperty("stream") + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) }) - it("should handle tool calls with O3 model in non-streaming mode", async () => { - const o3Handler = new OpenAiHandler({ - ...o3Options, - openAiStreamingEnabled: false, - }) + it("should handle cache metrics from usage.details", () => { + class TestOpenAiHandler extends OpenAiHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } - mockCreate.mockResolvedValueOnce({ - choices: [ - { - message: { - role: "assistant", - content: null, - tool_calls: [ - { - id: "call_1", - type: "function", - function: { - name: "test_tool", - arguments: "{}", - }, - }, - ], - }, - finish_reason: "tool_calls", - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - }) + const testHandler = new TestOpenAiHandler(mockOptions) - const stream = o3Handler.createMessage("system", []) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 25, + reasoningTokens: 30, + }, } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call") - expect(toolCallChunks).toHaveLength(1) - expect(toolCallChunks[0]).toEqual({ - type: "tool_call", - id: "call_1", - name: "test_tool", - arguments: "{}", - }) + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.cacheReadTokens).toBe(25) }) - it("should use default temperature of 0 when not specified for O3 models", async () => { - const o3Handler = new OpenAiHandler({ - ...o3Options, - // No modelTemperature specified - }) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello!", + it("should handle cache metrics from providerMetadata", () => { + class TestOpenAiHandler extends OpenAiHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestOpenAiHandler(mockOptions) + + const usage = { inputTokens: 100, outputTokens: 50 } + const providerMetadata = { + openai: { + cacheCreationInputTokens: 80, + cachedInputTokens: 20, }, - ] + } - const stream = o3Handler.createMessage(systemPrompt, messages) - await stream.next() + const result = testHandler.testProcessUsageMetrics(usage, providerMetadata) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: undefined, // Temperature is not supported for O3 models - }), - {}, - ) + expect(result.cacheWriteTokens).toBe(80) + expect(result.cacheReadTokens).toBe(20) }) - it("should handle O3 model with Azure AI Inference Service respecting includeMaxTokens", async () => { - const o3AzureHandler = new OpenAiHandler({ - ...o3Options, - openAiBaseUrl: "https://test.services.ai.azure.com", - includeMaxTokens: false, // Should NOT include max_tokens + it("should handle missing cache metrics gracefully", () => { + class TestOpenAiHandler extends OpenAiHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) + } + } + + const testHandler = new TestOpenAiHandler(mockOptions) + + const usage = { inputTokens: 100, outputTokens: 50 } + const result = testHandler.testProcessUsageMetrics(usage) + + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() + }) + }) + + describe("provider creation", () => { + it("should pass custom headers to createOpenAI", async () => { + const handlerWithHeaders = new OpenAiHandler({ + ...mockOptions, + openAiHeaders: { "X-Custom": "value" }, }) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello!", - }, - ] - const stream = o3AzureHandler.createMessage(systemPrompt, messages) - await stream.next() + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handlerWithHeaders.createMessage("system", [{ role: "user", content: "Hello!" }]) + for await (const _chunk of stream) { + // consume stream + } - expect(mockCreate).toHaveBeenCalledWith( + expect(mockCreateOpenAI).toHaveBeenCalledWith( expect.objectContaining({ - model: "o3-mini", + headers: expect.objectContaining({ + "X-Custom": "value", + }), }), - { path: "/models/chat/completions" }, ) - - // Verify max_tokens is NOT included when includeMaxTokens is false - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).not.toHaveProperty("max_completion_tokens") }) - it("should NOT include max_tokens for O3 model with Azure AI Inference Service even when includeMaxTokens is true", async () => { - const o3AzureHandler = new OpenAiHandler({ - ...o3Options, - openAiBaseUrl: "https://test.services.ai.azure.com", - includeMaxTokens: true, // Should include max_tokens + it("should use default baseURL when none provided", async () => { + const handlerNoUrl = new OpenAiHandler({ + ...mockOptions, + openAiBaseUrl: undefined, }) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello!", - }, - ] - const stream = o3AzureHandler.createMessage(systemPrompt, messages) - await stream.next() + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - expect(mockCreate).toHaveBeenCalledWith( + const stream = handlerNoUrl.createMessage("system", [{ role: "user", content: "Hello!" }]) + for await (const _chunk of stream) { + // consume stream + } + + expect(mockCreateOpenAI).toHaveBeenCalledWith( expect.objectContaining({ - model: "o3-mini", - // O3 models do not support max_tokens + baseURL: "https://api.openai.com/v1", }), - { path: "/models/chat/completions" }, ) }) + + it("should use provider.chat() to create model", async () => { + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage("system", [{ role: "user", content: "Hello!" }]) + for await (const _chunk of stream) { + // consume stream + } + + // Verify the mock provider's chat method was called + const mockProviderInstance = mockCreateOpenAI.mock.results[0]?.value + expect(mockProviderInstance?.chat).toHaveBeenCalled() + }) }) }) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index e03abea6352..1c6fab28775 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -1,20 +1,32 @@ -// pnpm --filter roo-cline test api/providers/__tests__/openrouter.spec.ts +// npx vitest run api/providers/__tests__/openrouter.spec.ts -vitest.mock("vscode", () => ({})) +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText, mockCreateOpenRouter } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockCreateOpenRouter: vi.fn(), +})) -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) -import { OpenRouterHandler } from "../openrouter" -import { ApiHandlerOptions } from "../../../shared/api" -import { Package } from "../../../shared/package" +vi.mock("@openrouter/ai-sdk-provider", () => ({ + createOpenRouter: mockCreateOpenRouter.mockImplementation(() => ({ + chat: vi.fn((id: string) => ({ modelId: id, provider: "openrouter" })), + })), +})) -vitest.mock("openai") -vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) })) +vi.mock("delay", () => ({ default: vi.fn(() => Promise.resolve()) })) -const mockCaptureException = vitest.fn() +const mockCaptureException = vi.fn() -vitest.mock("@roo-code/telemetry", () => ({ +vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { instance: { captureException: (...args: unknown[]) => mockCaptureException(...args), @@ -22,9 +34,9 @@ vitest.mock("@roo-code/telemetry", () => ({ }, })) -vitest.mock("../fetchers/modelCache", () => ({ - getModels: vitest.fn().mockImplementation(() => { - return Promise.resolve({ +vi.mock("../fetchers/modelCache", () => ({ + getModels: vi.fn().mockImplementation(() => + Promise.resolve({ "anthropic/claude-sonnet-4": { maxTokens: 8192, contextWindow: 200000, @@ -34,8 +46,7 @@ vitest.mock("../fetchers/modelCache", () => ({ outputPrice: 15, cacheWritesPrice: 3.75, cacheReadsPrice: 0.3, - description: "Claude 3.7 Sonnet", - thinking: false, + description: "Claude Sonnet 4", }, "anthropic/claude-sonnet-4.5": { maxTokens: 8192, @@ -47,7 +58,6 @@ vitest.mock("../fetchers/modelCache", () => ({ cacheWritesPrice: 3.75, cacheReadsPrice: 0.3, description: "Claude 4.5 Sonnet", - thinking: false, }, "anthropic/claude-3.7-sonnet:thinking": { maxTokens: 128000, @@ -80,30 +90,156 @@ vitest.mock("../fetchers/modelCache", () => ({ excludedTools: ["existing_excluded"], includedTools: ["existing_included"], }, - }) - }), + "deepseek/deepseek-r1": { + maxTokens: 8192, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.55, + outputPrice: 2.19, + description: "DeepSeek R1", + }, + "google/gemini-2.5-pro-preview": { + maxTokens: 65536, + contextWindow: 1048576, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 1.25, + outputPrice: 10, + description: "Gemini 2.5 Pro Preview", + }, + }), + ), })) +vi.mock("../fetchers/modelEndpointCache", () => ({ + getModelEndpoints: vi.fn().mockImplementation(() => Promise.resolve({})), +})) + +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" +import { DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" +import { OpenRouterHandler } from "../openrouter" + +// Helper: create a standard mock fullStream async generator +function createMockFullStream( + parts: Array<{ type: string; text?: string; id?: string; toolName?: string; delta?: string }>, +) { + return async function* () { + for (const part of parts) { + yield part + } + } +} + +// Helper: set up mock return value for streamText +function mockStreamTextReturn( + parts: Array<{ type: string; text?: string; id?: string; toolName?: string; delta?: string }>, + usage = { inputTokens: 10, outputTokens: 5 }, + providerMetadata: Record = {}, +) { + mockStreamText.mockReturnValue({ + fullStream: createMockFullStream(parts)(), + usage: Promise.resolve(usage), + providerMetadata: Promise.resolve(providerMetadata), + }) +} + describe("OpenRouterHandler", () => { const mockOptions: ApiHandlerOptions = { openRouterApiKey: "test-key", openRouterModelId: "anthropic/claude-sonnet-4", } - beforeEach(() => vitest.clearAllMocks()) + beforeEach(() => vi.clearAllMocks()) - it("initializes with correct options", () => { - const handler = new OpenRouterHandler(mockOptions) - expect(handler).toBeInstanceOf(OpenRouterHandler) + describe("constructor", () => { + it("should initialize with provided options", () => { + const handler = new OpenRouterHandler(mockOptions) + expect(handler).toBeInstanceOf(OpenRouterHandler) + }) - expect(OpenAI).toHaveBeenCalledWith({ - baseURL: "https://openrouter.ai/api/v1", - apiKey: mockOptions.openRouterApiKey, - defaultHeaders: { - "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", - "X-Title": "Roo Code", - "User-Agent": `RooCode/${Package.version}`, - }, + it("should create provider with correct apiKey and headers", async () => { + const handler = new OpenRouterHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage("test", [{ role: "user", content: "hello" }]) + for await (const _chunk of stream) { + // consume stream + } + + expect(mockCreateOpenRouter).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: "test-key", + headers: expect.objectContaining({ + "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", + "X-Title": "Roo Code", + }), + compatibility: "strict", + }), + ) + }) + + it("should use 'not-provided' when API key is not set", async () => { + const handler = new OpenRouterHandler({}) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage("test", [{ role: "user", content: "hello" }]) + for await (const _chunk of stream) { + // consume stream + } + + expect(mockCreateOpenRouter).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: "not-provided", + }), + ) + }) + + it("should pass custom baseURL when provided", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterBaseUrl: "https://custom.openrouter.ai/api/v1", + }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage("test", [{ role: "user", content: "hello" }]) + for await (const _chunk of stream) { + // consume stream + } + + expect(mockCreateOpenRouter).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://custom.openrouter.ai/api/v1", + }), + ) + }) + + it("should pass undefined baseURL when empty string provided", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterBaseUrl: "", + }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage("test", [{ role: "user", content: "hello" }]) + for await (const _chunk of stream) { + // consume stream + } + + expect(mockCreateOpenRouter).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: undefined, + }), + ) + }) + }) + + describe("isAiSdkProvider", () => { + it("should return true", () => { + const handler = new OpenRouterHandler(mockOptions) + expect(handler.isAiSdkProvider()).toBe(true) }) }) @@ -116,7 +252,6 @@ describe("OpenRouterHandler", () => { id: mockOptions.openRouterModelId, maxTokens: 8192, temperature: 0, - reasoningEffort: undefined, topP: undefined, }) }) @@ -137,7 +272,7 @@ describe("OpenRouterHandler", () => { }) const result = await handler.fetchModel() - // With the new clamping logic, 128000 tokens (64% of 200000 context window) + // With clamping logic, 128000 tokens (64% of 200000 context window) // gets clamped to 20% of context window: 200000 * 0.2 = 40000 expect(result.maxTokens).toBe(40000) expect(result.reasoningBudget).toBeUndefined() @@ -204,129 +339,282 @@ describe("OpenRouterHandler", () => { }) describe("createMessage", () => { - it("generates correct stream chunks", async () => { + const systemPrompt = "You are a helpful assistant." + const messages: NeutralMessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle streaming text responses", async () => { const handler = new OpenRouterHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "Test response" }]) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: mockOptions.openRouterModelId, - choices: [{ delta: { content: "test response" } }], - } - yield { - id: "test-id", - choices: [{ delta: {} }], - usage: { prompt_tokens: 10, completion_tokens: 20, cost: 0.001 }, - } - }, + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - // Mock OpenAI chat.completions.create - const mockCreate = vitest.fn().mockResolvedValue(mockStream) - - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any - - const systemPrompt = "test system prompt" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "test message" }] + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response") + }) - const generator = handler.createMessage(systemPrompt, messages) - const chunks = [] + it("should include usage information", async () => { + const handler = new OpenRouterHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "Test response" }], { inputTokens: 10, outputTokens: 20 }) - for await (const chunk of generator) { + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { chunks.push(chunk) } - // Verify stream chunks - expect(chunks).toHaveLength(2) // One text chunk and one usage chunk - expect(chunks[0]).toEqual({ type: "text", text: "test response" }) - expect(chunks[1]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20, totalCost: 0.001 }) + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk?.inputTokens).toBe(10) + expect(usageChunk?.outputTokens).toBe(20) + }) - // Verify OpenAI client was called with correct parameters. - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - max_tokens: 8192, - messages: [ - { - content: [ - { cache_control: { type: "ephemeral" }, text: "test system prompt", type: "text" }, - ], - role: "system", - }, - { - content: [{ cache_control: { type: "ephemeral" }, text: "test message", type: "text" }], - role: "user", + it("should include OpenRouter cost in usage from providerMetadata", async () => { + const handler = new OpenRouterHandler(mockOptions) + mockStreamTextReturn( + [{ type: "text-delta", text: "response" }], + { inputTokens: 10, outputTokens: 20 }, + { + openrouter: { + usage: { + cost: 0.001, + costDetails: { upstreamInferenceCost: 0.0005 }, + promptTokensDetails: { cachedTokens: 5 }, + completionTokensDetails: { reasoningTokens: 3 }, }, - ], - model: "anthropic/claude-sonnet-4", - stream: true, - stream_options: { include_usage: true }, - temperature: 0, - top_p: undefined, - }), - { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, + }, + }, ) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk?.totalCost).toBe(0.0015) // 0.001 + 0.0005 + expect(usageChunk?.cacheReadTokens).toBe(5) + expect(usageChunk?.reasoningTokens).toBe(3) }) - it("adds cache control for supported models", async () => { + it("should handle tool calls via AI SDK stream parts", async () => { + const handler = new OpenRouterHandler(mockOptions) + mockStreamTextReturn([ + { type: "tool-input-start", id: "call_1", toolName: "test_tool" }, + { type: "tool-input-delta", id: "call_1", delta: '{"arg":' }, + { type: "tool-input-delta", id: "call_1", delta: '"value"}' }, + { type: "tool-input-end", id: "call_1" }, + ]) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallStart = chunks.filter((c) => c.type === "tool_call_start") + expect(toolCallStart).toHaveLength(1) + expect(toolCallStart[0].id).toBe("call_1") + expect(toolCallStart[0].name).toBe("test_tool") + + const toolCallDeltas = chunks.filter((c) => c.type === "tool_call_delta") + expect(toolCallDeltas).toHaveLength(2) + + const toolCallEnd = chunks.filter((c) => c.type === "tool_call_end") + expect(toolCallEnd).toHaveLength(1) + }) + + it("should pass system prompt as string for non-caching models", async () => { const handler = new OpenRouterHandler({ ...mockOptions, - openRouterModelId: "anthropic/claude-3.5-sonnet", + openRouterModelId: "openai/gpt-4o", // Not in OPEN_ROUTER_PROMPT_CACHING_MODELS }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: "test-id", - choices: [{ delta: { content: "test response" } }], - } - }, + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - const mockCreate = vitest.fn().mockResolvedValue(mockStream) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.system).toBe(systemPrompt) + }) - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "message 1" }, - { role: "assistant", content: "response 1" }, - { role: "user", content: "message 2" }, - ] + it("should apply cache control for prompt-caching models", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "anthropic/claude-sonnet-4", + }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) - await handler.createMessage("test system", messages).next() + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } - expect(mockCreate).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalled() + const callArgs = mockStreamText.mock.calls[0][0] + // System prompt should be wrapped with cache control + expect(callArgs.system).toEqual( expect.objectContaining({ - messages: expect.arrayContaining([ - expect.objectContaining({ - role: "system", - content: expect.arrayContaining([ - expect.objectContaining({ cache_control: { type: "ephemeral" } }), - ]), - }), - ]), + role: "system", + content: systemPrompt, + providerOptions: expect.objectContaining({ + openrouter: { cacheControl: { type: "ephemeral" } }, + }), }), - { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, ) }) - it("handles API errors and captures telemetry", async () => { + it("should pass temperature 0 as default", async () => { const handler = new OpenRouterHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { error: { message: "API Error", code: 500 } } - }, + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream } - const mockCreate = vitest.fn().mockResolvedValue(mockStream) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.temperature).toBe(0) + }) - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow("OpenRouter API Error 500: API Error") + it("should include providerOptions with usage include", async () => { + const handler = new OpenRouterHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openrouter?.usage).toEqual({ include: true }) + }) + + it("should include provider routing when specific provider is set", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterSpecificProvider: "Anthropic", + }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openrouter?.provider).toEqual({ + order: ["Anthropic"], + only: ["Anthropic"], + allow_fallbacks: false, + }) + }) + + it("should add x-anthropic-beta header for anthropic models", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "anthropic/claude-sonnet-4", + }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.headers).toEqual({ + "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14", + }) + }) + + it("should not add x-anthropic-beta header for non-anthropic models", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "openai/gpt-4o", + }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.headers).toBeUndefined() + }) + + it("should include maxOutputTokens when model has maxTokens", async () => { + const handler = new OpenRouterHandler(mockOptions) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.maxOutputTokens).toBe(8192) + }) + + it("should exclude reasoning for Gemini 2.5 Pro when reasoning is undefined", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "google/gemini-2.5-pro-preview", + }) + mockStreamTextReturn([{ type: "text-delta", text: "response" }]) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // consume stream + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.openrouter?.reasoning).toEqual({ exclude: true }) + }) + }) + + describe("error handling", () => { + const testMessages: NeutralMessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello" }], + }, + ] + + it("should handle API errors and capture telemetry", async () => { + const handler = new OpenRouterHandler(mockOptions) + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta" as const, text: "" } + throw new Error("API Error") + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage("test", testMessages) + + await expect(async () => { + for await (const _chunk of stream) { + // consume + } + }).rejects.toThrow("API Error") expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ @@ -334,21 +622,28 @@ describe("OpenRouterHandler", () => { provider: "OpenRouter", modelId: mockOptions.openRouterModelId, operation: "createMessage", - errorCode: 500, - status: 500, }), ) }) - it("captures telemetry when createMessage throws an exception", async () => { + it("should capture telemetry when createMessage throws an exception", async () => { const handler = new OpenRouterHandler(mockOptions) - const mockCreate = vitest.fn().mockRejectedValue(new Error("Connection failed")) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta" as const, text: "" } + throw new Error("Connection failed") + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow() + const stream = handler.createMessage("test", testMessages) + + await expect(async () => { + for await (const _chunk of stream) { + // consume + } + }).rejects.toThrow() expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ @@ -360,18 +655,27 @@ describe("OpenRouterHandler", () => { ) }) - it("passes SDK exceptions with status 429 to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + it("should handle rate limiting (status 429)", async () => { const handler = new OpenRouterHandler(mockOptions) - const error = new Error("Rate limit exceeded: free-models-per-day") as any - error.status = 429 + const rateLimitError = new Error("Rate limit exceeded: free-models-per-day") as any + rateLimitError.status = 429 + + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta" as const, text: "" } + throw rateLimitError + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + const stream = handler.createMessage("test", testMessages) - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow("Rate limit exceeded") + await expect(async () => { + for await (const _chunk of stream) { + // consume + } + }).rejects.toThrow("Rate limit exceeded") expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ @@ -383,16 +687,26 @@ describe("OpenRouterHandler", () => { ) }) - it("passes SDK exceptions with 429 in message to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + it("should handle rate limit errors with 429 in message", async () => { const handler = new OpenRouterHandler(mockOptions) const error = new Error("429 Rate limit exceeded: free-models-per-day") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow("429 Rate limit exceeded") + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta" as const, text: "" } + throw error + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage("test", testMessages) + + await expect(async () => { + for await (const _chunk of stream) { + // consume + } + }).rejects.toThrow("429 Rate limit exceeded") expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ @@ -404,195 +718,75 @@ describe("OpenRouterHandler", () => { ) }) - it("passes SDK exceptions containing 'rate limit' to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + it("should handle errors containing 'rate limit' text", async () => { const handler = new OpenRouterHandler(mockOptions) const error = new Error("Request failed due to rate limit") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any - - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow("rate limit") - - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Request failed due to rate limit", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "createMessage", - }), - ) - }) - it("passes 429 rate limit errors from stream to telemetry (filtering happens in PostHogTelemetryClient)", async () => { - const handler = new OpenRouterHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { error: { message: "Rate limit exceeded", code: 429 } } - }, - } + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta" as const, text: "" } + throw error + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) - const mockCreate = vitest.fn().mockResolvedValue(mockStream) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + const stream = handler.createMessage("test", testMessages) - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow("OpenRouter API Error 429: Rate limit exceeded") + await expect(async () => { + for await (const _chunk of stream) { + // consume + } + }).rejects.toThrow("rate limit") expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ - message: "Rate limit exceeded", + message: "Request failed due to rate limit", provider: "OpenRouter", modelId: mockOptions.openRouterModelId, operation: "createMessage", - errorCode: 429, - status: 429, }), ) }) - - it("yields tool_call_end events when finish_reason is tool_calls", async () => { - // Import NativeToolCallParser to set up state - const { NativeToolCallParser } = await import("../../../core/assistant-message/NativeToolCallParser") - - // Clear any previous state - NativeToolCallParser.clearRawChunkState() - - const handler = new OpenRouterHandler(mockOptions) - - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: "test-id", - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_openrouter_test", - function: { name: "read_file", arguments: '{"path":"test.ts"}' }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - id: "test-id", - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(mockStream) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any - - const generator = handler.createMessage("test", []) - const chunks = [] - - for await (const chunk of generator) { - // Simulate what Task.ts does: when we receive tool_call_partial, - // process it through NativeToolCallParser to populate rawChunkTracker - if (chunk.type === "tool_call_partial") { - NativeToolCallParser.processRawChunk({ - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - }) - } - chunks.push(chunk) - } - - // Should have tool_call_partial and tool_call_end - const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - - expect(partialChunks).toHaveLength(1) - expect(endChunks).toHaveLength(1) - expect(endChunks[0].id).toBe("call_openrouter_test") - }) }) describe("completePrompt", () => { - it("returns correct response", async () => { + it("should complete prompt successfully", async () => { const handler = new OpenRouterHandler(mockOptions) - const mockResponse = { choices: [{ message: { content: "test completion" } }] } - - const mockCreate = vitest.fn().mockResolvedValue(mockResponse) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + mockGenerateText.mockResolvedValue({ text: "test completion" }) const result = await handler.completePrompt("test prompt") expect(result).toBe("test completion") - - expect(mockCreate).toHaveBeenCalledWith( - { - model: mockOptions.openRouterModelId, - max_tokens: 8192, - temperature: 0, - messages: [{ role: "user", content: "test prompt" }], - stream: false, - }, - { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "test prompt", + }), ) }) - it("handles API errors and captures telemetry", async () => { + it("should handle API errors and capture telemetry", async () => { const handler = new OpenRouterHandler(mockOptions) - const mockError = { - error: { - message: "API Error", - code: 500, - }, - } + mockGenerateText.mockRejectedValue(new Error("API Error")) - const mockCreate = vitest.fn().mockResolvedValue(mockError) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + await expect(handler.completePrompt("test prompt")).rejects.toThrow("API Error") - await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenRouter API Error 500: API Error") - - // Verify telemetry was captured expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "API Error", provider: "OpenRouter", modelId: mockOptions.openRouterModelId, operation: "completePrompt", - errorCode: 500, - status: 500, }), ) }) - it("handles unexpected errors and captures telemetry", async () => { + it("should handle unexpected errors and capture telemetry", async () => { const handler = new OpenRouterHandler(mockOptions) - const error = new Error("Unexpected error") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + mockGenerateText.mockRejectedValue(new Error("Unexpected error")) await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error") - // Verify telemetry was captured (filtering now happens inside PostHogTelemetryClient) expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "Unexpected error", @@ -603,18 +797,39 @@ describe("OpenRouterHandler", () => { ) }) - it("passes SDK exceptions with status 429 to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + it("should handle empty response", async () => { + const handler = new OpenRouterHandler(mockOptions) + mockGenerateText.mockResolvedValue({ text: "" }) + + const result = await handler.completePrompt("test prompt") + expect(result).toBe("") + }) + + it("should pass provider routing when specific provider is set", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterSpecificProvider: "Anthropic", + }) + mockGenerateText.mockResolvedValue({ text: "response" }) + + await handler.completePrompt("test prompt") + + const callArgs = mockGenerateText.mock.calls[0][0] + expect(callArgs.providerOptions?.openrouter?.provider).toEqual({ + order: ["Anthropic"], + only: ["Anthropic"], + allow_fallbacks: false, + }) + }) + + it("should handle rate limit errors (status 429)", async () => { const handler = new OpenRouterHandler(mockOptions) const error = new Error("Rate limit exceeded: free-models-per-day") as any error.status = 429 - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + mockGenerateText.mockRejectedValue(error) await expect(handler.completePrompt("test prompt")).rejects.toThrow("Rate limit exceeded") - // captureException is called, but PostHogTelemetryClient filters out 429 errors internally expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "Rate limit exceeded: free-models-per-day", @@ -625,17 +840,13 @@ describe("OpenRouterHandler", () => { ) }) - it("passes SDK exceptions with 429 in message to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + it("should handle rate limit errors with 429 in message", async () => { const handler = new OpenRouterHandler(mockOptions) const error = new Error("429 Rate limit exceeded: free-models-per-day") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + mockGenerateText.mockRejectedValue(error) await expect(handler.completePrompt("test prompt")).rejects.toThrow("429 Rate limit exceeded") - // captureException is called, but PostHogTelemetryClient filters out 429 errors internally expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "429 Rate limit exceeded: free-models-per-day", @@ -646,17 +857,13 @@ describe("OpenRouterHandler", () => { ) }) - it("passes SDK exceptions containing 'rate limit' to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + it("should handle errors containing 'rate limit' text", async () => { const handler = new OpenRouterHandler(mockOptions) const error = new Error("Request failed due to rate limit") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + mockGenerateText.mockRejectedValue(error) await expect(handler.completePrompt("test prompt")).rejects.toThrow("rate limit") - // captureException is called, but PostHogTelemetryClient filters out rate limit errors internally expect(mockCaptureException).toHaveBeenCalledWith( expect.objectContaining({ message: "Request failed due to rate limit", @@ -666,36 +873,156 @@ describe("OpenRouterHandler", () => { }), ) }) + }) - it("passes 429 rate limit errors from response to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + describe("getModel", () => { + it("should return model info for configured model", () => { const handler = new OpenRouterHandler(mockOptions) - const mockError = { - error: { - message: "Rate limit exceeded", - code: 429, - }, + const model = handler.getModel() + expect(model.id).toBe("anthropic/claude-sonnet-4") + expect(model.info).toBeDefined() + }) + + it("should use default model when no model ID provided", () => { + const handler = new OpenRouterHandler({}) + const model = handler.getModel() + expect(model.id).toBe("anthropic/claude-sonnet-4.5") + }) + + it("should include temperature 0 as default", () => { + const handler = new OpenRouterHandler(mockOptions) + const model = handler.getModel() + expect(model.temperature).toBe(0) + }) + + it("should include model parameters from getModelParams", () => { + const handler = new OpenRouterHandler(mockOptions) + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) + + it("should use DeepSeek default temperature for DeepSeek R1 models", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "deepseek/deepseek-r1", + }) + const result = await handler.fetchModel() + expect(result.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) + }) + + it("should set topP to 0.95 for deepseek-r1 models", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "deepseek/deepseek-r1", + }) + const result = await handler.fetchModel() + expect(result.topP).toBe(0.95) + }) + + it("should not set topP for non-deepseek models", async () => { + const handler = new OpenRouterHandler(mockOptions) + const result = await handler.fetchModel() + expect(result.topP).toBeUndefined() + }) + + it("should respect user-provided temperature", () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + modelTemperature: 0.7, + }) + const model = handler.getModel() + expect(model.temperature).toBe(0.7) + }) + }) + + describe("processUsageMetrics", () => { + // Expose protected method for testing + class TestOpenRouterHandler extends OpenRouterHandler { + public testProcessUsageMetrics(usage: any, providerMetadata?: any) { + return this.processUsageMetrics(usage, providerMetadata) } + } - const mockCreate = vitest.fn().mockResolvedValue(mockError) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + it("should correctly process basic usage metrics", () => { + const handler = new TestOpenRouterHandler(mockOptions) + const result = handler.testProcessUsageMetrics({ + inputTokens: 100, + outputTokens: 50, + }) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - "OpenRouter API Error 429: Rate limit exceeded", + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.totalCost).toBe(0) + }) + + it("should extract OpenRouter cost from provider metadata", () => { + const handler = new TestOpenRouterHandler(mockOptions) + const result = handler.testProcessUsageMetrics( + { inputTokens: 100, outputTokens: 50 }, + { + openrouter: { + usage: { + cost: 0.001, + costDetails: { upstreamInferenceCost: 0.0005 }, + }, + }, + }, ) - // captureException is called, but PostHogTelemetryClient filters out 429 errors internally - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Rate limit exceeded", - provider: "OpenRouter", - modelId: mockOptions.openRouterModelId, - operation: "completePrompt", - errorCode: 429, - status: 429, - }), + expect(result.totalCost).toBe(0.0015) + }) + + it("should extract cached tokens from provider metadata", () => { + const handler = new TestOpenRouterHandler(mockOptions) + const result = handler.testProcessUsageMetrics( + { inputTokens: 100, outputTokens: 50 }, + { + openrouter: { + usage: { + promptTokensDetails: { cachedTokens: 25 }, + }, + }, + }, + ) + + expect(result.cacheReadTokens).toBe(25) + }) + + it("should extract reasoning tokens from provider metadata", () => { + const handler = new TestOpenRouterHandler(mockOptions) + const result = handler.testProcessUsageMetrics( + { inputTokens: 100, outputTokens: 50 }, + { + openrouter: { + usage: { + completionTokensDetails: { reasoningTokens: 30 }, + }, + }, + }, ) + + expect(result.reasoningTokens).toBe(30) + }) + + it("should handle missing provider metadata gracefully", () => { + const handler = new TestOpenRouterHandler(mockOptions) + const result = handler.testProcessUsageMetrics({ inputTokens: 100, outputTokens: 50 }) + + expect(result.cacheReadTokens).toBeUndefined() + expect(result.reasoningTokens).toBeUndefined() + expect(result.totalCost).toBe(0) + }) + }) + + describe("generateImage", () => { + it("should return error when API key is not provided", async () => { + const handler = new OpenRouterHandler(mockOptions) + const result = await handler.generateImage("test prompt", "test-model", "") + + expect(result.success).toBe(false) + expect(result.error).toBe("OpenRouter API key is required for image generation") }) }) }) diff --git a/src/api/providers/__tests__/qwen-code-native-tools.spec.ts b/src/api/providers/__tests__/qwen-code-native-tools.spec.ts index 3b470ce461e..bf534ed5116 100644 --- a/src/api/providers/__tests__/qwen-code-native-tools.spec.ts +++ b/src/api/providers/__tests__/qwen-code-native-tools.spec.ts @@ -8,59 +8,50 @@ vi.mock("node:fs", () => ({ }, })) -const mockCreate = vi.fn() -vi.mock("openai", () => { +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: vi.fn().mockImplementation(() => ({ - apiKey: "test-key", - baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", - chat: { - completions: { - create: mockCreate, - }, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + return vi.fn(() => ({ + modelId: "qwen3-coder-plus", + provider: "qwen-code", + })) + }), +})) + import { promises as fs } from "node:fs" import { QwenCodeHandler } from "../qwen-code" -import { NativeToolCallParser } from "../../../core/assistant-message/NativeToolCallParser" import type { ApiHandlerOptions } from "../../../shared/api" -describe("QwenCodeHandler Native Tools", () => { +const mockCredentials = { + access_token: "test-access-token", + refresh_token: "test-refresh-token", + token_type: "Bearer", + expiry_date: Date.now() + 3600000, // 1 hour from now + resource_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", +} + +describe("QwenCodeHandler (AI SDK)", () => { let handler: QwenCodeHandler let mockOptions: ApiHandlerOptions & { qwenCodeOauthPath?: string } - const testTools = [ - { - type: "function" as const, - function: { - name: "test_tool", - description: "A test tool", - parameters: { - type: "object", - properties: { - arg1: { type: "string", description: "First argument" }, - }, - required: ["arg1"], - }, - }, - }, - ] - beforeEach(() => { vi.clearAllMocks() // Mock credentials file - const mockCredentials = { - access_token: "test-access-token", - refresh_token: "test-refresh-token", - token_type: "Bearer", - expiry_date: Date.now() + 3600000, // 1 hour from now - resource_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", - } ;(fs.readFile as any).mockResolvedValue(JSON.stringify(mockCredentials)) ;(fs.writeFile as any).mockResolvedValue(undefined) @@ -68,306 +59,356 @@ describe("QwenCodeHandler Native Tools", () => { apiModelId: "qwen3-coder-plus", } handler = new QwenCodeHandler(mockOptions) + }) + + describe("constructor", () => { + it("should initialize and extend OpenAICompatibleHandler", () => { + expect(handler).toBeInstanceOf(QwenCodeHandler) + expect(handler.getModel().id).toBe("qwen3-coder-plus") + }) - // Clear NativeToolCallParser state before each test - NativeToolCallParser.clearRawChunkState() + it("should use default model when no apiModelId provided", () => { + const h = new QwenCodeHandler({}) + expect(h.getModel().id).toBeDefined() + }) }) - describe("Native Tool Calling Support", () => { - it("should include tools in request when model supports native tools and tools are provided", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) - - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, + describe("OAuth lifecycle", () => { + it("should load credentials and authenticate before streaming", async () => { + async function* mockFullStream() { + yield { type: "text-delta" as const, text: "Test response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), }) - await stream.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - type: "function", - function: expect.objectContaining({ - name: "test_tool", - }), - }), - ]), - parallel_tool_calls: true, - }), - ) + + const stream = handler.createMessage("test prompt", []) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have read credentials file + expect(fs.readFile).toHaveBeenCalled() + // Should have called streamText + expect(mockStreamText).toHaveBeenCalled() }) - it("should include tool_choice when provided", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) - - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - tool_choice: "auto", + it("should refresh expired token before streaming", async () => { + // Return expired credentials + const expiredCredentials = { + ...mockCredentials, + expiry_date: Date.now() - 1000, // expired + } + ;(fs.readFile as any).mockResolvedValue(JSON.stringify(expiredCredentials)) + + // Mock the token refresh fetch + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-access-token", + token_type: "Bearer", + refresh_token: "new-refresh-token", + expires_in: 3600, + }), }) - await stream.next() + vi.stubGlobal("fetch", mockFetch) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tool_choice: "auto", - }), - ) - }) + async function* mockFullStream() { + yield { type: "text-delta" as const, text: "After refresh" } + } - it("should always include tools and tool_choice (tools are guaranteed to be present after ALWAYS_AVAILABLE_TOOLS)", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) - - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }), }) - await stream.next() - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] - expect(callArgs).toHaveProperty("tools") - expect(callArgs).toHaveProperty("tool_choice") - expect(callArgs).toHaveProperty("parallel_tool_calls", true) + const stream = handler.createMessage("test prompt", []) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have called the token refresh endpoint + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/oauth2/token"), + expect.objectContaining({ method: "POST" }), + ) + // Should have written new credentials to file + expect(fs.writeFile).toHaveBeenCalled() + + vi.unstubAllGlobals() }) + }) + + describe("401 retry", () => { + it("should retry on 401 during createMessage", async () => { + // First call throws 401, second succeeds + let callCount = 0 + + mockStreamText.mockImplementation(() => { + callCount++ + if (callCount === 1) { + // Simulate 401 error (handleAiSdkError preserves status) + const error = new Error("qwen-code: API Error (401): Unauthorized") + ;(error as any).status = 401 + throw error + } + + async function* mockFullStream() { + yield { type: "text-delta" as const, text: "Retry success" } + } + + return { + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + } + }) - it("should yield tool_call_partial chunks during streaming", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_qwen_123", - function: { - name: "test_tool", - arguments: '{"arg1":', - }, - }, - ], - }, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '"value"}', - }, - }, - ], - }, - }, - ], - } - }, - })) - - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, + // Mock the token refresh fetch for the retry + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + access_token: "refreshed-access-token", + token_type: "Bearer", + refresh_token: "refreshed-refresh-token", + expires_in: 3600, + }), }) + vi.stubGlobal("fetch", mockFetch) - const chunks = [] + const stream = handler.createMessage("test prompt", []) + const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } - expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, - id: "call_qwen_123", - name: "test_tool", - arguments: '{"arg1":', + // Should have retried: 2 calls to streamText + expect(mockStreamText).toHaveBeenCalledTimes(2) + // Should have gotten the successful response + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Retry success") + + vi.unstubAllGlobals() + }) + + it("should retry on 401 during completePrompt", async () => { + let callCount = 0 + + mockGenerateText.mockImplementation(() => { + callCount++ + if (callCount === 1) { + const error = new Error("qwen-code: API Error (401): Unauthorized") + ;(error as any).status = 401 + throw error + } + return Promise.resolve({ text: "Retry success" }) }) - expect(chunks).toContainEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"value"}', + // Mock the token refresh fetch + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + access_token: "refreshed-access-token", + token_type: "Bearer", + refresh_token: "refreshed-refresh-token", + expires_in: 3600, + }), }) + vi.stubGlobal("fetch", mockFetch) + + const result = await handler.completePrompt("test prompt") + + expect(mockGenerateText).toHaveBeenCalledTimes(2) + expect(result).toBe("Retry success") + + vi.unstubAllGlobals() }) - it("should set parallel_tool_calls based on metadata", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" } }], - } - }, - })) - - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - parallelToolCalls: true, + it("should throw non-401 errors without retrying", async () => { + const error = new Error("qwen-code: API Error (500): Internal Server Error") + ;(error as any).status = 500 + + mockStreamText.mockImplementation(() => { + throw error }) - await stream.next() - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - parallel_tool_calls: true, - }), - ) + const stream = handler.createMessage("test prompt", []) + await expect(async () => { + for await (const _chunk of stream) { + // consume + } + }).rejects.toThrow("500") + + // Should only have tried once + expect(mockStreamText).toHaveBeenCalledTimes(1) }) + }) + + describe("streaming via AI SDK", () => { + it("should yield text chunks from AI SDK stream", async () => { + async function* mockFullStream() { + yield { type: "text-delta" as const, text: "Hello " } + yield { type: "text-delta" as const, text: "world" } + } - it("should yield tool_call_end events when finish_reason is tool_calls", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_qwen_test", - function: { - name: "test_tool", - arguments: '{"arg1":"value"}', - }, - }, - ], - }, - }, - ], - } - yield { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, - })) - - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), }) - const chunks = [] + const stream = handler.createMessage("test prompt", []) + const chunks: any[] = [] for await (const chunk of stream) { - // Simulate what Task.ts does: when we receive tool_call_partial, - // process it through NativeToolCallParser to populate rawChunkTracker - if (chunk.type === "tool_call_partial") { - NativeToolCallParser.processRawChunk({ - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - }) - } chunks.push(chunk) } - // Should have tool_call_partial and tool_call_end - const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") - - expect(partialChunks).toHaveLength(1) - expect(endChunks).toHaveLength(1) - expect(endChunks[0].id).toBe("call_qwen_test") + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(2) + expect(textChunks[0].text).toBe("Hello ") + expect(textChunks[1].text).toBe("world") }) - it("should preserve thinking block handling alongside tool calls", async () => { - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - reasoning_content: "Thinking about this...", - }, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_after_think", - function: { - name: "test_tool", - arguments: '{"arg1":"result"}', - }, - }, - ], - }, - }, - ], - } - yield { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - }, - ], - } - }, - })) - - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, + it("should yield usage metrics", async () => { + async function* mockFullStream() { + yield { type: "text-delta" as const, text: "Response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 15, outputTokens: 8 }), }) - const chunks = [] + const stream = handler.createMessage("test prompt", []) + const chunks: any[] = [] for await (const chunk of stream) { - if (chunk.type === "tool_call_partial") { - NativeToolCallParser.processRawChunk({ - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - }) - } chunks.push(chunk) } - // Should have reasoning, tool_call_partial, and tool_call_end - const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") - const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(15) + expect(usageChunks[0].outputTokens).toBe(8) + }) + + it("should handle reasoning content from AI SDK", async () => { + async function* mockFullStream() { + yield { type: "reasoning" as const, text: "Thinking about this..." } + yield { type: "text-delta" as const, text: "Here is my answer" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + }) + const stream = handler.createMessage("test prompt", []) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") expect(reasoningChunks).toHaveLength(1) expect(reasoningChunks[0].text).toBe("Thinking about this...") - expect(partialChunks).toHaveLength(1) - expect(endChunks).toHaveLength(1) + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Here is my answer") + }) + }) + + describe("tool calls via AI SDK", () => { + it("should handle tool calls from AI SDK stream", async () => { + async function* mockFullStream() { + yield { + type: "tool-call" as const, + toolCallId: "call_qwen_123", + toolName: "test_tool", + args: { arg1: "value" }, + } + yield { + type: "tool-result" as const, + toolCallId: "call_qwen_123", + toolName: "test_tool", + result: "tool result", + } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + }) + + const stream = handler.createMessage("test prompt", []) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // AI SDK tool calls are processed by processAiSdkStreamPart + expect(chunks.length).toBeGreaterThan(0) + }) + }) + + describe("completePrompt", () => { + it("should delegate to AI SDK generateText", async () => { + mockGenerateText.mockResolvedValue({ text: "Completed response" }) + + const result = await handler.completePrompt("test prompt") + + expect(result).toBe("Completed response") + expect(mockGenerateText).toHaveBeenCalled() + }) + }) + + describe("refreshPromise guard", () => { + it("should not make concurrent refresh calls", async () => { + // Return expired credentials so refresh is triggered + const expiredCredentials = { + ...mockCredentials, + expiry_date: Date.now() - 1000, + } + ;(fs.readFile as any).mockResolvedValue(JSON.stringify(expiredCredentials)) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-token", + token_type: "Bearer", + refresh_token: "new-refresh", + expires_in: 3600, + }), + }) + vi.stubGlobal("fetch", mockFetch) + + async function* mockFullStream() { + yield { type: "text-delta" as const, text: "ok" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }), + }) + + // Make two concurrent calls - the refresh should only happen once + const stream1 = handler.createMessage("prompt1", []) + // Consume stream1 first to trigger auth + for await (const _chunk of stream1) { + // consume + } + + // The fetch for token refresh should have been called exactly once + expect(mockFetch).toHaveBeenCalledTimes(1) + + vi.unstubAllGlobals() }) }) }) diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index ea6a36b4b44..276d16d55f3 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -1,385 +1,386 @@ // npx vitest run api/providers/__tests__/requesty.spec.ts -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -import { RequestyHandler } from "../requesty" -import { ApiHandlerOptions } from "../../../shared/api" -import { Package } from "../../../shared/package" -import { ApiHandlerCreateMessageMetadata } from "../../index" - -const mockCreate = vitest.fn() +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -vitest.mock("openai", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - default: vitest.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) })) - -vitest.mock("../fetchers/modelCache", () => ({ - getModels: vitest.fn().mockImplementation(() => { - return Promise.resolve({ - "coding/claude-4-sonnet": { - maxTokens: 8192, - contextWindow: 200000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 3, - outputPrice: 15, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - description: "Claude 4 Sonnet", - }, - }) +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + return vi.fn(() => ({ + modelId: "coding/claude-4-sonnet", + provider: "requesty", + })) }), })) +vi.mock("delay", () => ({ default: vi.fn(() => Promise.resolve()) })) + +const mockGetModels = vi.fn() +const mockGetModelsFromCache = vi.fn() + +vi.mock("../fetchers/modelCache", () => ({ + getModels: (...args: unknown[]) => mockGetModels(...args), + getModelsFromCache: (...args: unknown[]) => mockGetModelsFromCache(...args), +})) + +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" +import { requestyDefaultModelId } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { RequestyHandler } from "../requesty" + +const testModelInfo = { + maxTokens: 8192, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3, + outputPrice: 15, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + description: "Claude 4 Sonnet", +} + describe("RequestyHandler", () => { const mockOptions: ApiHandlerOptions = { requestyApiKey: "test-key", requestyModelId: "coding/claude-4-sonnet", } - beforeEach(() => vitest.clearAllMocks()) + beforeEach(() => { + vi.clearAllMocks() + mockGetModelsFromCache.mockReturnValue(null) + mockGetModels.mockResolvedValue({ + "coding/claude-4-sonnet": testModelInfo, + }) + }) - it("initializes with correct options", () => { - const handler = new RequestyHandler(mockOptions) - expect(handler).toBeInstanceOf(RequestyHandler) + describe("constructor", () => { + it("should initialize with provided options", () => { + const handler = new RequestyHandler(mockOptions) + expect(handler).toBeInstanceOf(RequestyHandler) + }) - expect(OpenAI).toHaveBeenCalledWith({ - baseURL: "https://router.requesty.ai/v1", - apiKey: mockOptions.requestyApiKey, - defaultHeaders: { - "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", - "X-Title": "Roo Code", - "User-Agent": `RooCode/${Package.version}`, - }, + it("should use default model ID if not provided", () => { + const handler = new RequestyHandler({ requestyApiKey: "test-key" }) + const model = handler.getModel() + expect(model.id).toBe(requestyDefaultModelId) }) - }) - it("can use a base URL instead of the default", () => { - const handler = new RequestyHandler({ ...mockOptions, requestyBaseUrl: "https://custom.requesty.ai/v1" }) - expect(handler).toBeInstanceOf(RequestyHandler) - - expect(OpenAI).toHaveBeenCalledWith({ - baseURL: "https://custom.requesty.ai/v1", - apiKey: mockOptions.requestyApiKey, - defaultHeaders: { - "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", - "X-Title": "Roo Code", - "User-Agent": `RooCode/${Package.version}`, - }, + it("should use cache if available at construction time", () => { + mockGetModelsFromCache.mockReturnValue({ + "coding/claude-4-sonnet": testModelInfo, + }) + const handler = new RequestyHandler(mockOptions) + const model = handler.getModel() + expect(model.id).toBe("coding/claude-4-sonnet") + expect(model.info).toMatchObject(testModelInfo) }) }) describe("fetchModel", () => { - it("returns correct model info when options are provided", async () => { + it("returns correct model info after fetching", async () => { const handler = new RequestyHandler(mockOptions) const result = await handler.fetchModel() + expect(mockGetModels).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "requesty", + baseUrl: expect.stringContaining("requesty"), + }), + ) expect(result).toMatchObject({ - id: mockOptions.requestyModelId, - info: { - maxTokens: 8192, - contextWindow: 200000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 3, - outputPrice: 15, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - description: "Claude 4 Sonnet", - }, + id: "coding/claude-4-sonnet", + info: expect.objectContaining(testModelInfo), }) }) - it("returns default model info when options are not provided", async () => { - const handler = new RequestyHandler({}) + it("returns default model info when model not in fetched data", async () => { + mockGetModels.mockResolvedValue({}) + const handler = new RequestyHandler(mockOptions) const result = await handler.fetchModel() - expect(result).toMatchObject({ - id: mockOptions.requestyModelId, - info: { - maxTokens: 8192, - contextWindow: 200000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 3, - outputPrice: 15, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - description: "Claude 4 Sonnet", - }, + expect(result.id).toBe("coding/claude-4-sonnet") + // Falls back to requestyDefaultModelInfo + expect(result.info).toBeDefined() + }) + }) + + describe("getModel", () => { + it("should return model with anthropic format params", () => { + mockGetModelsFromCache.mockReturnValue({ + "coding/claude-4-sonnet": testModelInfo, }) + const handler = new RequestyHandler(mockOptions) + const model = handler.getModel() + + expect(model.id).toBe("coding/claude-4-sonnet") + expect(model.info).toBeDefined() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) + + it("should apply router tool preferences for openai models", () => { + mockGetModelsFromCache.mockReturnValue({ + "openai/gpt-4": { ...testModelInfo }, + }) + const handler = new RequestyHandler({ + ...mockOptions, + requestyModelId: "openai/gpt-4", + }) + const model = handler.getModel() + + expect(model.info.excludedTools).toContain("apply_diff") + expect(model.info.excludedTools).toContain("write_to_file") + expect(model.info.includedTools).toContain("apply_patch") }) }) describe("createMessage", () => { - it("generates correct stream chunks", async () => { + const systemPrompt = "You are a helpful assistant." + const messages: NeutralMessageParam[] = [{ role: "user", content: [{ type: "text" as const, text: "Hello!" }] }] + + it("should handle streaming responses", async () => { const handler = new RequestyHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: mockOptions.requestyModelId, - choices: [{ delta: { content: "test response" } }], - } - yield { - id: "test-id", - choices: [{ delta: {} }], - usage: { - prompt_tokens: 10, - completion_tokens: 20, - prompt_tokens_details: { - caching_tokens: 5, - cached_tokens: 2, - }, - }, - } - }, + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } } - mockCreate.mockResolvedValue(mockStream) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: { + prompt_tokens_details: { caching_tokens: 3, cached_tokens: 2 }, + }, + }), + }) + + const chunks: any[] = [] + for await (const chunk of handler.createMessage(systemPrompt, messages)) { + chunks.push(chunk) + } - const systemPrompt = "test system prompt" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "test message" }] + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response") + }) - const generator = handler.createMessage(systemPrompt, messages) - const chunks = [] + it("should pass requesty trace metadata as providerOptions", async () => { + const handler = new RequestyHandler(mockOptions) - for await (const chunk of generator) { - chunks.push(chunk) + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } } - // Verify stream chunks - expect(chunks).toHaveLength(2) // One text chunk and one usage chunk - expect(chunks[0]).toEqual({ type: "text", text: "test response" }) - expect(chunks[1]).toEqual({ - type: "usage", - inputTokens: 10, - outputTokens: 20, - cacheWriteTokens: 5, - cacheReadTokens: 2, - totalCost: expect.any(Number), + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0, details: {}, raw: {} }), }) - // Verify OpenAI client was called with correct parameters - expect(mockCreate).toHaveBeenCalledWith( + const metadata = { taskId: "task-123", mode: "code" } + const stream = handler.createMessage(systemPrompt, messages, metadata) + // Consume the stream + for await (const _chunk of stream) { + // no-op + } + + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - max_tokens: 8192, - messages: [ - { - role: "system", - content: "test system prompt", - }, - { - role: "user", - content: "test message", - }, - ], - model: "coding/claude-4-sonnet", - stream: true, - stream_options: { include_usage: true }, - temperature: 0, + providerOptions: expect.objectContaining({ + requesty: { trace_id: "task-123", extra: { mode: "code" } }, + }), }), ) }) - it("handles API errors", async () => { + it("should include tools and toolChoice when provided", async () => { const handler = new RequestyHandler(mockOptions) - const mockError = new Error("API Error") - mockCreate.mockRejectedValue(mockError) - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow("API Error") - }) + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } - describe("native tool support", () => { - const systemPrompt = "test system prompt" - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user" as const, content: "What's the weather?" }, - ] + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0, details: {}, raw: {} }), + }) - const mockTools: OpenAI.Chat.ChatCompletionTool[] = [ + const mockTools = [ { - type: "function", + type: "function" as const, function: { name: "get_weather", description: "Get the current weather", parameters: { type: "object", - properties: { - location: { type: "string" }, - }, + properties: { location: { type: "string" } }, required: ["location"], }, }, }, ] - beforeEach(() => { - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: "test-id", - choices: [{ delta: { content: "test response" } }], - } - }, - } - mockCreate.mockResolvedValue(mockStream) - }) + const metadata = { taskId: "test-task", tools: mockTools, tool_choice: "auto" as const } + for await (const _chunk of handler.createMessage(systemPrompt, messages, metadata)) { + // consume + } - it("should include tools in request when tools are provided", async () => { - const metadata: ApiHandlerCreateMessageMetadata = { - taskId: "test-task", - tools: mockTools, - tool_choice: "auto", - } + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.any(Object), + }), + ) + }) - const handler = new RequestyHandler(mockOptions) - const iterator = handler.createMessage(systemPrompt, messages, metadata) - await iterator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - type: "function", - function: expect.objectContaining({ - name: "get_weather", - description: "Get the current weather", - }), - }), - ]), - tool_choice: "auto", - }), - ) + it("should handle API errors", async () => { + const handler = new RequestyHandler(mockOptions) + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "" } + throw new Error("API Error") + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - it("should handle tool_call_partial chunks in streaming response", async () => { - const mockStreamWithToolCalls = { - async *[Symbol.asyncIterator]() { - yield { - id: "test-id", - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_123", - function: { - name: "get_weather", - arguments: '{"location":', - }, - }, - ], - }, - }, - ], - } - yield { - id: "test-id", - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '"New York"}', - }, - }, - ], - }, - }, - ], - } - yield { - id: "test-id", - choices: [{ delta: {} }], - usage: { prompt_tokens: 10, completion_tokens: 20 }, - } - }, - } - mockCreate.mockResolvedValue(mockStreamWithToolCalls) - - const metadata: ApiHandlerCreateMessageMetadata = { - taskId: "test-task", - tools: mockTools, - } - - const handler = new RequestyHandler(mockOptions) - const chunks = [] - for await (const chunk of handler.createMessage(systemPrompt, messages, metadata)) { - chunks.push(chunk) + const generator = handler.createMessage(systemPrompt, messages) + await expect(async () => { + for await (const _chunk of generator) { + // consume } - - // Expect two tool_call_partial chunks and one usage chunk - expect(chunks).toHaveLength(3) - expect(chunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", - name: "get_weather", - arguments: '{"location":', - }) - expect(chunks[1]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"New York"}', - }) - expect(chunks[2]).toMatchObject({ - type: "usage", - inputTokens: 10, - outputTokens: 20, - }) - }) + }).rejects.toThrow() }) }) describe("completePrompt", () => { - it("returns correct response", async () => { + it("should complete a prompt using generateText", async () => { const handler = new RequestyHandler(mockOptions) - const mockResponse = { choices: [{ message: { content: "test completion" } }] } + mockGenerateText.mockResolvedValue({ text: "Test completion" }) - mockCreate.mockResolvedValue(mockResponse) + const result = await handler.completePrompt("Test prompt") - const result = await handler.completePrompt("test prompt") + expect(result).toBe("Test completion") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) - expect(result).toBe("test completion") + it("should call fetchModel before completing", async () => { + const handler = new RequestyHandler(mockOptions) + mockGenerateText.mockResolvedValue({ text: "done" }) - expect(mockCreate).toHaveBeenCalledWith({ - model: mockOptions.requestyModelId, - max_tokens: 8192, - messages: [{ role: "system", content: "test prompt" }], - temperature: 0, - }) + await handler.completePrompt("test") + + expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "requesty" })) }) it("handles API errors", async () => { const handler = new RequestyHandler(mockOptions) - const mockError = new Error("API Error") - mockCreate.mockRejectedValue(mockError) + mockGenerateText.mockRejectedValue(new Error("API Error")) await expect(handler.completePrompt("test prompt")).rejects.toThrow("API Error") }) + }) - it("handles unexpected errors", async () => { - const handler = new RequestyHandler(mockOptions) - mockCreate.mockRejectedValue(new Error("Unexpected error")) + describe("processUsageMetrics", () => { + it("should correctly process usage metrics including cache and cost", async () => { + class TestRequestyHandler extends RequestyHandler { + public testProcessUsageMetrics(usage: any) { + return this.processUsageMetrics(usage) + } + } + + mockGetModelsFromCache.mockReturnValue({ + "coding/claude-4-sonnet": testModelInfo, + }) + const handler = new TestRequestyHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + details: {}, + raw: { + prompt_tokens_details: { caching_tokens: 5, cached_tokens: 2 }, + }, + } + + const result = handler.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBe(5) + expect(result.cacheReadTokens).toBe(2) + expect(result.totalCost).toEqual(expect.any(Number)) + expect(result.totalCost).toBeGreaterThan(0) + }) + + it("should handle missing cache metrics gracefully", async () => { + class TestRequestyHandler extends RequestyHandler { + public testProcessUsageMetrics(usage: any) { + return this.processUsageMetrics(usage) + } + } + + mockGetModelsFromCache.mockReturnValue({ + "coding/claude-4-sonnet": testModelInfo, + }) + const handler = new TestRequestyHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + details: {}, + raw: {}, + } + + const result = handler.testProcessUsageMetrics(usage) + + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBe(0) + expect(result.cacheReadTokens).toBe(0) + }) + + it("should fall back to details.cachedInputTokens when raw is missing", async () => { + class TestRequestyHandler extends RequestyHandler { + public testProcessUsageMetrics(usage: any) { + return this.processUsageMetrics(usage) + } + } + + mockGetModelsFromCache.mockReturnValue({ + "coding/claude-4-sonnet": testModelInfo, + }) + const handler = new TestRequestyHandler(mockOptions) + + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { cachedInputTokens: 15 }, + raw: undefined, + } + + const result = handler.testProcessUsageMetrics(usage) - await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error") + expect(result.cacheReadTokens).toBe(15) }) }) }) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index a6a76fe100d..0f0d7cbfa34 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -1,67 +1,51 @@ // npx vitest run api/providers/__tests__/roo.spec.ts -import { Anthropic } from "@anthropic-ai/sdk" +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import { rooDefaultModelId } from "@roo-code/types" import { ApiHandlerOptions } from "../../../shared/api" -// Mock OpenAI client -const mockCreate = vitest.fn() +// ── AI SDK mocks ────────────────────────────────────────────────── -vitest.mock("openai", () => { - return { - __esModule: true, - default: vitest.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate.mockImplementation(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - choices: [ - { - message: { role: "assistant", content: "Test response" }, - finish_reason: "stop", - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - } - - return { - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" }, index: 0 }], - usage: null, - } - yield { - choices: [{ delta: {}, index: 0 }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, - } - }), - }, +const { mockStreamText, mockGenerateText, mockLanguageModel, mockCreateOpenAICompatible } = vi.hoisted(() => { + const mockLanguageModel = vi.fn(() => ({ + modelId: "test-model", + provider: "roo", + })) + + const mockCreateOpenAICompatible = vi.fn(() => { + const providerFn = Object.assign( + vi.fn(() => ({ modelId: "test-model", provider: "roo" })), + { + languageModel: mockLanguageModel, }, - })), + ) + return providerFn + }) + + return { + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockLanguageModel, + mockCreateOpenAICompatible, } }) -// Mock CloudService - Define functions outside to avoid initialization issues -const mockGetSessionToken = vitest.fn() -const mockHasInstance = vitest.fn() +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, streamText: mockStreamText, generateText: mockGenerateText } +}) -// Create mock functions that we can control -const mockGetSessionTokenFn = vitest.fn() -const mockHasInstanceFn = vitest.fn() -const mockOnFn = vitest.fn() +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: mockCreateOpenAICompatible, +})) + +// ── CloudService mocks ──────────────────────────────────────────── -vitest.mock("@roo-code/cloud", () => ({ +const mockGetSessionTokenFn = vi.fn() +const mockHasInstanceFn = vi.fn() + +vi.mock("@roo-code/cloud", () => ({ CloudService: { hasInstance: () => mockHasInstanceFn(), get instance() { @@ -69,16 +53,17 @@ vitest.mock("@roo-code/cloud", () => ({ authService: { getSessionToken: () => mockGetSessionTokenFn(), }, - on: vitest.fn(), - off: vitest.fn(), + on: vi.fn(), + off: vi.fn(), } }, }, })) -// Mock i18n -vitest.mock("../../../i18n", () => ({ - t: vitest.fn((key: string) => { +// ── i18n mock ───────────────────────────────────────────────────── + +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => { if (key === "common:errors.roo.authenticationRequired") { return "Authentication required for Roo Code Cloud" } @@ -86,18 +71,19 @@ vitest.mock("../../../i18n", () => ({ }), })) -// Mock model cache -vitest.mock("../../providers/fetchers/modelCache", () => ({ - getModels: vitest.fn(), - flushModels: vitest.fn(), - getModelsFromCache: vitest.fn((provider: string) => { +// ── Model cache mock ────────────────────────────────────────────── + +vi.mock("../../providers/fetchers/modelCache", () => ({ + getModels: vi.fn(), + flushModels: vi.fn(), + getModelsFromCache: vi.fn((provider: string) => { if (provider === "roo") { return { "xai/grok-code-fast-1": { maxTokens: 16_384, contextWindow: 262_144, supportsImages: false, - supportsReasoningEffort: true, // Enable reasoning for tests + supportsReasoningEffort: true, supportsPromptCache: true, inputPrice: 0, outputPrice: 0, @@ -124,15 +110,39 @@ vitest.mock("../../providers/fetchers/modelCache", () => ({ }), })) -// Import after mocks are set up +// ── Import after mocks ──────────────────────────────────────────── + import { RooHandler } from "../roo" -import { CloudService } from "@roo-code/cloud" + +// ── Test helpers ────────────────────────────────────────────────── + +function createDefaultStreamMock( + textContent = "Test response", + rawUsage: Record = { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, +) { + async function* fullStream() { + if (textContent) { + yield { type: "text-delta" as const, text: textContent } + } + } + return { + fullStream: fullStream(), + usage: Promise.resolve({ + inputTokens: rawUsage.prompt_tokens ?? 10, + outputTokens: rawUsage.completion_tokens ?? 5, + details: {}, + raw: rawUsage, + }), + } +} + +// ── Tests ───────────────────────────────────────────────────────── describe("RooHandler", () => { let handler: RooHandler let mockOptions: ApiHandlerOptions const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: "Hello!", @@ -146,8 +156,10 @@ describe("RooHandler", () => { // Set up CloudService mocks for successful authentication mockHasInstanceFn.mockReturnValue(true) mockGetSessionTokenFn.mockReturnValue("test-session-token") - mockCreate.mockClear() - vitest.clearAllMocks() + vi.clearAllMocks() + // Restore default mock implementations after clearAllMocks + mockHasInstanceFn.mockReturnValue(true) + mockGetSessionTokenFn.mockReturnValue("test-session-token") }) describe("constructor", () => { @@ -162,7 +174,6 @@ describe("RooHandler", () => { expect(() => { new RooHandler(mockOptions) }).not.toThrow() - // Constructor should succeed even without CloudService const handler = new RooHandler(mockOptions) expect(handler).toBeInstanceOf(RooHandler) }) @@ -173,7 +184,6 @@ describe("RooHandler", () => { expect(() => { new RooHandler(mockOptions) }).not.toThrow() - // Constructor should succeed even without session token const handler = new RooHandler(mockOptions) expect(handler).toBeInstanceOf(RooHandler) }) @@ -187,29 +197,44 @@ describe("RooHandler", () => { it("should pass correct configuration to base class", () => { handler = new RooHandler(mockOptions) expect(handler).toBeInstanceOf(RooHandler) - // The handler should be initialized with correct base URL and API key - // We can't directly test the parent class constructor, but we can verify the handler works - expect(handler).toBeDefined() + // Verify createOpenAICompatible was called with correct config + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( + expect.objectContaining({ + name: "roo", + apiKey: "test-session-token", + baseURL: expect.stringContaining("/v1"), + }), + ) }) }) describe("createMessage", () => { beforeEach(() => { handler = new RooHandler(mockOptions) + // Clear mocks from constructor + mockCreateOpenAICompatible.mockClear() + mockLanguageModel.mockClear() + mockStreamText.mockReturnValue(createDefaultStreamMock()) }) - it("should update API key before making request", async () => { - // Set up a fresh token that will be returned when createMessage is called + it("should refresh auth before making request", async () => { const freshToken = "fresh-session-token" mockGetSessionTokenFn.mockReturnValue(freshToken) const stream = handler.createMessage(systemPrompt, messages) - // Consume the stream to trigger the API call for await (const _chunk of stream) { - // Just consume + // Consume stream } - // Verify getSessionToken was called to get the fresh token + // Verify provider was recreated with fresh token + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: freshToken, + headers: expect.objectContaining({ + "X-Roo-App-Version": expect.any(String), + }), + }), + ) expect(mockGetSessionTokenFn).toHaveBeenCalled() }) @@ -240,33 +265,27 @@ describe("RooHandler", () => { }) it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) + async function* failingStream(): AsyncGenerator { + yield* [] as never[] + throw new Error("API Error") + } + mockStreamText.mockReturnValue({ + fullStream: failingStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0, details: {}, raw: {} }), + }) + const stream = handler.createMessage(systemPrompt, messages) await expect(async () => { for await (const _chunk of stream) { // Should not reach here } - }).rejects.toThrow("API Error") + }).rejects.toThrow() }) it("should handle empty response content", async () => { - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: null }, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 0, - total_tokens: 10, - }, - } - }, - }) + mockStreamText.mockReturnValue( + createDefaultStreamMock("", { prompt_tokens: 10, completion_tokens: 0, total_tokens: 10 }), + ) const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -281,30 +300,46 @@ describe("RooHandler", () => { }) it("should handle multiple messages in conversation", async () => { - const multipleMessages: Anthropic.Messages.MessageParam[] = [ + const multipleMessages: NeutralMessageParam[] = [ { role: "user", content: "First message" }, { role: "assistant", content: "First response" }, { role: "user", content: "Second message" }, ] const stream = handler.createMessage(systemPrompt, multipleMessages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + for await (const _chunk of stream) { + // Consume stream } - expect(mockCreate).toHaveBeenCalledWith( + // Verify streamText was called with system prompt and messages + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - messages: expect.arrayContaining([ - expect.objectContaining({ role: "system", content: systemPrompt }), - expect.objectContaining({ role: "user", content: "First message" }), - expect.objectContaining({ role: "assistant", content: "First response" }), - expect.objectContaining({ role: "user", content: "Second message" }), - ]), + system: systemPrompt, + messages: expect.any(Array), }), + ) + + // Verify custom headers were set on the provider + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + "X-Roo-App-Version": expect.any(String), + }), + }), + ) + }) + + it("should include X-Roo-Task-ID header when taskId is present", async () => { + const stream = handler.createMessage(systemPrompt, messages, { taskId: "task-abc-123" }) + for await (const _chunk of stream) { + // Consume stream + } + + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ "X-Roo-App-Version": expect.any(String), + "X-Roo-Task-ID": "task-abc-123", }), }), ) @@ -314,55 +349,42 @@ describe("RooHandler", () => { describe("completePrompt", () => { beforeEach(() => { handler = new RooHandler(mockOptions) + mockCreateOpenAICompatible.mockClear() + mockLanguageModel.mockClear() + mockGenerateText.mockResolvedValue({ text: "Test response" }) }) it("should complete prompt successfully", async () => { const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") - expect(mockCreate).toHaveBeenCalledWith({ - model: mockOptions.apiModelId, - messages: [{ role: "user", content: "Test prompt" }], - }) + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) }) - it("should update API key before making request", async () => { - // Set up a fresh token that will be returned when completePrompt is called + it("should refresh auth before making request", async () => { const freshToken = "fresh-session-token" mockGetSessionTokenFn.mockReturnValue(freshToken) - // Access the client's apiKey property to verify it gets updated - const clientApiKeyGetter = vitest.fn() - Object.defineProperty(handler["client"], "apiKey", { - get: clientApiKeyGetter, - set: vitest.fn(), - configurable: true, - }) - await handler.completePrompt("Test prompt") - // Verify getSessionToken was called to get the fresh token + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: freshToken, + }), + ) expect(mockGetSessionTokenFn).toHaveBeenCalled() }) it("should handle API errors", async () => { - mockCreate.mockRejectedValueOnce(new Error("API Error")) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow( - "Roo Code Cloud completion error: API Error", - ) + mockGenerateText.mockRejectedValueOnce(new Error("API Error")) + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API Error") }) it("should handle empty response", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [{ message: { content: "" } }], - }) - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") - }) - - it("should handle missing response content", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [{ message: {} }], - }) + mockGenerateText.mockResolvedValueOnce({ text: "" }) const result = await handler.completePrompt("Test prompt") expect(result).toBe("") }) @@ -377,7 +399,6 @@ describe("RooHandler", () => { const modelInfo = handler.getModel() expect(modelInfo.id).toBe(mockOptions.apiModelId) expect(modelInfo.info).toBeDefined() - // Models are loaded dynamically, so we just verify the structure expect(modelInfo.info.maxTokens).toBeDefined() expect(modelInfo.info.contextWindow).toBeDefined() }) @@ -387,7 +408,6 @@ describe("RooHandler", () => { const modelInfo = handlerWithoutModel.getModel() expect(modelInfo.id).toBe(rooDefaultModelId) expect(modelInfo.info).toBeDefined() - // Models are loaded dynamically expect(modelInfo.info.maxTokens).toBeDefined() expect(modelInfo.info.contextWindow).toBeDefined() }) @@ -399,7 +419,6 @@ describe("RooHandler", () => { const modelInfo = handlerWithUnknownModel.getModel() expect(modelInfo.id).toBe("unknown-model-id") expect(modelInfo.info).toBeDefined() - // Should return fallback info for unknown models (dynamic models will be merged in real usage) expect(modelInfo.info.maxTokens).toBeDefined() expect(modelInfo.info.contextWindow).toBeDefined() expect(modelInfo.info.supportsImages).toBeDefined() @@ -409,7 +428,6 @@ describe("RooHandler", () => { }) it("should handle any model ID since models are loaded dynamically", () => { - // Test with various model IDs - they should all work since models are loaded dynamically const testModelIds = ["xai/grok-code-fast-1", "roo/sonic", "deepseek/deepseek-chat-v3.1"] for (const modelId of testModelIds) { @@ -417,7 +435,6 @@ describe("RooHandler", () => { const modelInfo = handlerWithModel.getModel() expect(modelInfo.id).toBe(modelId) expect(modelInfo.info).toBeDefined() - // Verify the structure has required fields expect(modelInfo.info.maxTokens).toBeDefined() expect(modelInfo.info.contextWindow).toBeDefined() } @@ -428,7 +445,6 @@ describe("RooHandler", () => { apiModelId: "minimax/minimax-m2:free", }) const modelInfo = handlerWithMinimax.getModel() - // The settings from API should already be applied in the cached model info expect(modelInfo.info.inputPrice).toBe(0.15) expect(modelInfo.info.outputPrice).toBe(0.6) }) @@ -437,20 +453,19 @@ describe("RooHandler", () => { describe("temperature and model configuration", () => { it("should use default temperature of 0", async () => { handler = new RooHandler(mockOptions) + mockCreateOpenAICompatible.mockClear() + mockLanguageModel.mockClear() + mockStreamText.mockReturnValue(createDefaultStreamMock()) + const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume stream } - expect(mockCreate).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ temperature: 0, }), - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Roo-App-Version": expect.any(String), - }), - }), ) }) @@ -459,29 +474,31 @@ describe("RooHandler", () => { ...mockOptions, modelTemperature: 0.9, }) + mockCreateOpenAICompatible.mockClear() + mockLanguageModel.mockClear() + mockStreamText.mockReturnValue(createDefaultStreamMock()) + const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume stream } - expect(mockCreate).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ temperature: 0.9, }), - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Roo-App-Version": expect.any(String), - }), - }), ) }) it("should use correct API endpoint", () => { - // The base URL should be set to Roo's API endpoint - // We can't directly test the OpenAI client configuration, but we can verify the handler initializes handler = new RooHandler(mockOptions) expect(handler).toBeInstanceOf(RooHandler) - // The handler should work with the Roo API endpoint + // Verify the provider was created with the expected base URL + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: expect.stringMatching(/\/v1$/), + }), + ) }) }) @@ -493,36 +510,27 @@ describe("RooHandler", () => { handler = new RooHandler(mockOptions) expect(handler).toBeInstanceOf(RooHandler) expect(mockGetSessionTokenFn).toHaveBeenCalled() + // Verify the provider was created with the session token + expect(mockCreateOpenAICompatible).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: testToken, + }), + ) }) it("should handle undefined auth service gracefully", () => { - mockHasInstanceFn.mockReturnValue(true) - // Mock CloudService with undefined authService - const originalGetSessionToken = mockGetSessionTokenFn.getMockImplementation() - - // Temporarily make authService return undefined + const originalImpl = mockGetSessionTokenFn.getMockImplementation() mockGetSessionTokenFn.mockImplementation(() => undefined) try { - Object.defineProperty(CloudService, "instance", { - get: () => ({ - authService: undefined, - on: vitest.fn(), - off: vitest.fn(), - }), - configurable: true, - }) - expect(() => { new RooHandler(mockOptions) }).not.toThrow() - // Constructor should succeed even with undefined auth service const handler = new RooHandler(mockOptions) expect(handler).toBeInstanceOf(RooHandler) } finally { - // Restore original mock implementation - if (originalGetSessionToken) { - mockGetSessionTokenFn.mockImplementation(originalGetSessionToken) + if (originalImpl) { + mockGetSessionTokenFn.mockImplementation(originalImpl) } else { mockGetSessionTokenFn.mockReturnValue("test-session-token") } @@ -535,34 +543,66 @@ describe("RooHandler", () => { expect(() => { new RooHandler(mockOptions) }).not.toThrow() - // Constructor should succeed even with empty session token const handler = new RooHandler(mockOptions) expect(handler).toBeInstanceOf(RooHandler) }) + + it("should recreate provider on each createMessage call", async () => { + handler = new RooHandler(mockOptions) + mockCreateOpenAICompatible.mockClear() + mockStreamText.mockReturnValue(createDefaultStreamMock()) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // Consume + } + + // refreshProvider should have called createOpenAICompatible + expect(mockCreateOpenAICompatible).toHaveBeenCalledTimes(1) + + // Call again + mockStreamText.mockReturnValue(createDefaultStreamMock()) + const stream2 = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream2) { + // Consume + } + + expect(mockCreateOpenAICompatible).toHaveBeenCalledTimes(2) + }) }) describe("reasoning effort support", () => { + /** + * Helper to extract the transformRequestBody function from the most recent + * `provider.languageModel()` call and invoke it with a test body. + */ + function getTransformedBody() { + const lastCall = mockLanguageModel.mock.calls[mockLanguageModel.mock.calls.length - 1] as unknown[] + const options = lastCall?.[1] as { + transformRequestBody?: (body: Record) => Record + } + const transformFn = options?.transformRequestBody + if (!transformFn) { + return { model: "test", messages: [] } as Record + } + return transformFn({ model: "test", messages: [] }) + } + + beforeEach(() => { + mockStreamText.mockReturnValue(createDefaultStreamMock()) + }) + it("should include reasoning with enabled: false when not enabled", async () => { handler = new RooHandler(mockOptions) + mockLanguageModel.mockClear() + const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume stream } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: mockOptions.apiModelId, - messages: expect.any(Array), - stream: true, - stream_options: { include_usage: true }, - reasoning: { enabled: false }, - }), - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Roo-App-Version": expect.any(String), - }), - }), - ) + const body = getTransformedBody() + expect(body.reasoning).toEqual({ enabled: false }) }) it("should include reasoning with enabled: false when explicitly disabled", async () => { @@ -570,21 +610,15 @@ describe("RooHandler", () => { ...mockOptions, enableReasoningEffort: false, }) + mockLanguageModel.mockClear() + const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume stream } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - reasoning: { enabled: false }, - }), - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Roo-App-Version": expect.any(String), - }), - }), - ) + const body = getTransformedBody() + expect(body.reasoning).toEqual({ enabled: false }) }) it("should include reasoning with enabled: true and effort: low", async () => { @@ -592,21 +626,15 @@ describe("RooHandler", () => { ...mockOptions, reasoningEffort: "low", }) + mockLanguageModel.mockClear() + const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume stream } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - reasoning: { enabled: true, effort: "low" }, - }), - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Roo-App-Version": expect.any(String), - }), - }), - ) + const body = getTransformedBody() + expect(body.reasoning).toEqual({ enabled: true, effort: "low" }) }) it("should include reasoning with enabled: true and effort: medium", async () => { @@ -614,21 +642,15 @@ describe("RooHandler", () => { ...mockOptions, reasoningEffort: "medium", }) + mockLanguageModel.mockClear() + const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume stream } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - reasoning: { enabled: true, effort: "medium" }, - }), - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Roo-App-Version": expect.any(String), - }), - }), - ) + const body = getTransformedBody() + expect(body.reasoning).toEqual({ enabled: true, effort: "medium" }) }) it("should include reasoning with enabled: true and effort: high", async () => { @@ -636,21 +658,15 @@ describe("RooHandler", () => { ...mockOptions, reasoningEffort: "high", }) + mockLanguageModel.mockClear() + const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume stream } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - reasoning: { enabled: true, effort: "high" }, - }), - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Roo-App-Version": expect.any(String), - }), - }), - ) + const body = getTransformedBody() + expect(body.reasoning).toEqual({ enabled: true, effort: "high" }) }) it("should not include reasoning for minimal (treated as none)", async () => { @@ -658,14 +674,16 @@ describe("RooHandler", () => { ...mockOptions, reasoningEffort: "minimal", }) + mockLanguageModel.mockClear() + const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume stream } // minimal should result in no reasoning parameter - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.reasoning).toBeUndefined() + const body = getTransformedBody() + expect(body.reasoning).toBeUndefined() }) it("should handle enableReasoningEffort: false overriding reasoningEffort setting", async () => { @@ -674,75 +692,41 @@ describe("RooHandler", () => { enableReasoningEffort: false, reasoningEffort: "high", }) + mockLanguageModel.mockClear() + const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { // Consume stream } // When explicitly disabled, should send enabled: false regardless of effort setting - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - reasoning: { enabled: false }, - }), - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Roo-App-Version": expect.any(String), - }), - }), - ) + const body = getTransformedBody() + expect(body.reasoning).toEqual({ enabled: false }) }) }) describe("tool calls handling", () => { beforeEach(() => { handler = new RooHandler(mockOptions) + mockCreateOpenAICompatible.mockClear() + mockLanguageModel.mockClear() }) - it("should yield raw tool call chunks when tool_calls present", async () => { - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_123", - function: { name: "read_file", arguments: '{"path":"' }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: 'test.ts"}' }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, + it("should yield tool call chunks from AI SDK stream", async () => { + async function* mockFullStream() { + yield { type: "tool-input-start" as const, id: "call_123", toolName: "read_file" } + yield { type: "tool-input-delta" as const, id: "call_123", delta: '{"path":"test.ts"}' } + yield { type: "tool-input-end" as const, id: "call_123" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: { prompt_tokens: 10, completion_tokens: 5 }, + }), }) const stream = handler.createMessage(systemPrompt, messages) @@ -751,59 +735,47 @@ describe("RooHandler", () => { chunks.push(chunk) } - // Verify we get raw tool call chunks - const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + const startChunks = chunks.filter((c) => c.type === "tool_call_start") + const deltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const endChunks = chunks.filter((c) => c.type === "tool_call_end") - expect(rawChunks).toHaveLength(2) - expect(rawChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, + expect(startChunks).toHaveLength(1) + expect(startChunks[0]).toEqual({ + type: "tool_call_start", id: "call_123", name: "read_file", - arguments: '{"path":"', }) - expect(rawChunks[1]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: 'test.ts"}', + expect(deltaChunks).toHaveLength(1) + expect(deltaChunks[0]).toEqual({ + type: "tool_call_delta", + id: "call_123", + delta: '{"path":"test.ts"}', + }) + expect(endChunks).toHaveLength(1) + expect(endChunks[0]).toEqual({ + type: "tool_call_end", + id: "call_123", }) }) - it("should yield raw tool call chunks even when finish_reason is not tool_calls", async () => { - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_456", - function: { - name: "write_to_file", - arguments: '{"path":"test.ts","content":"hello"}', - }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - choices: [ - { - delta: {}, - finish_reason: "stop", - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, + it("should handle multiple tool calls", async () => { + async function* mockFullStream() { + yield { type: "tool-input-start" as const, id: "call_1", toolName: "read_file" } + yield { type: "tool-input-delta" as const, id: "call_1", delta: '{"path":"file1.ts"}' } + yield { type: "tool-input-end" as const, id: "call_1" } + yield { type: "tool-input-start" as const, id: "call_2", toolName: "read_file" } + yield { type: "tool-input-delta" as const, id: "call_2", delta: '{"path":"file2.ts"}' } + yield { type: "tool-input-end" as const, id: "call_2" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: { prompt_tokens: 10, completion_tokens: 5 }, + }), }) const stream = handler.createMessage(systemPrompt, messages) @@ -812,64 +784,32 @@ describe("RooHandler", () => { chunks.push(chunk) } - const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + const startChunks = chunks.filter((c) => c.type === "tool_call_start") + const endChunks = chunks.filter((c) => c.type === "tool_call_end") - expect(rawChunks).toHaveLength(1) - expect(rawChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_456", - name: "write_to_file", - arguments: '{"path":"test.ts","content":"hello"}', - }) + expect(startChunks).toHaveLength(2) + expect(startChunks[0].id).toBe("call_1") + expect(startChunks[1].id).toBe("call_2") + expect(endChunks).toHaveLength(2) }) - it("should handle multiple tool calls with different indices", async () => { - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_1", - function: { name: "read_file", arguments: '{"path":"file1.ts"}' }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 1, - id: "call_2", - function: { name: "read_file", arguments: '{"path":"file2.ts"}' }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, + it("should handle streaming arguments across multiple deltas", async () => { + async function* mockFullStream() { + yield { type: "tool-input-start" as const, id: "call_789", toolName: "execute_command" } + yield { type: "tool-input-delta" as const, id: "call_789", delta: '{"command":"' } + yield { type: "tool-input-delta" as const, id: "call_789", delta: "npm install" } + yield { type: "tool-input-delta" as const, id: "call_789", delta: '"}' } + yield { type: "tool-input-end" as const, id: "call_789" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: { prompt_tokens: 10, completion_tokens: 5 }, + }), }) const stream = handler.createMessage(systemPrompt, messages) @@ -878,76 +818,15 @@ describe("RooHandler", () => { chunks.push(chunk) } - const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - - expect(rawChunks).toHaveLength(2) - expect(rawChunks[0].index).toBe(0) - expect(rawChunks[0].id).toBe("call_1") - expect(rawChunks[1].index).toBe(1) - expect(rawChunks[1].id).toBe("call_2") + const deltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + expect(deltaChunks).toHaveLength(3) + expect(deltaChunks[0].delta).toBe('{"command":"') + expect(deltaChunks[1].delta).toBe("npm install") + expect(deltaChunks[2].delta).toBe('"}') }) - it("should emit raw chunks for streaming arguments", async () => { - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_789", - function: { name: "execute_command", arguments: '{"command":"' }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: "npm install" }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: '"}' }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, - }) + it("should not yield tool call chunks when no tools present", async () => { + mockStreamText.mockReturnValue(createDefaultStreamMock()) const stream = handler.createMessage(systemPrompt, messages) const chunks: any[] = [] @@ -955,25 +834,35 @@ describe("RooHandler", () => { chunks.push(chunk) } - const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + const toolChunks = chunks.filter( + (c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end", + ) + expect(toolChunks).toHaveLength(0) + }) + }) - expect(rawChunks).toHaveLength(3) - expect(rawChunks[0].arguments).toBe('{"command":"') - expect(rawChunks[1].arguments).toBe("npm install") - expect(rawChunks[2].arguments).toBe('"}') + describe("reasoning streaming", () => { + beforeEach(() => { + handler = new RooHandler(mockOptions) + mockCreateOpenAICompatible.mockClear() + mockLanguageModel.mockClear() }) - it("should not yield tool call chunks when no tool calls present", async () => { - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Regular text response" }, index: 0 }], - } - yield { - choices: [{ delta: {}, finish_reason: "stop", index: 0 }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, + it("should yield reasoning chunks from AI SDK stream", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta" as const, text: "Let me think..." } + yield { type: "reasoning-delta" as const, text: " about this." } + yield { type: "text-delta" as const, text: "Here is my answer." } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: { prompt_tokens: 10, completion_tokens: 5 }, + }), }) const stream = handler.createMessage(systemPrompt, messages) @@ -982,71 +871,94 @@ describe("RooHandler", () => { chunks.push(chunk) } - const rawChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - expect(rawChunks).toHaveLength(0) + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") + const textChunks = chunks.filter((c) => c.type === "text") + + expect(reasoningChunks).toHaveLength(2) + expect(reasoningChunks[0].text).toBe("Let me think...") + expect(reasoningChunks[1].text).toBe(" about this.") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Here is my answer.") }) + }) - it("should yield tool_call_end events when finish_reason is tool_calls", async () => { - // Import NativeToolCallParser to set up state - const { NativeToolCallParser } = await import("../../../core/assistant-message/NativeToolCallParser") - - // Clear any previous state - NativeToolCallParser.clearRawChunkState() - - mockCreate.mockResolvedValueOnce({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_finish_test", - function: { name: "read_file", arguments: '{"path":"test.ts"}' }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, + describe("usage metrics", () => { + beforeEach(() => { + handler = new RooHandler(mockOptions) + mockCreateOpenAICompatible.mockClear() + mockLanguageModel.mockClear() + }) + + it("should return cost from raw usage", async () => { + mockStreamText.mockReturnValue( + createDefaultStreamMock("response", { + prompt_tokens: 100, + completion_tokens: 50, + cost: 0.005, + }), + ) + + const chunks: any[] = [] + for await (const chunk of handler.createMessage(systemPrompt, messages)) { + chunks.push(chunk) + } + + const usage = chunks.find((c) => c.type === "usage") + expect(usage).toBeDefined() + expect(usage.totalCost).toBe(0.005) + }) + + it("should set totalCost to 0 for free models", async () => { + const freeHandler = new RooHandler({ + apiModelId: "xai/grok-code-fast-1", + }) + // Override getModel to return isFree + const origGetModel = freeHandler.getModel.bind(freeHandler) + vi.spyOn(freeHandler, "getModel").mockImplementation(() => { + const model = origGetModel() + return { ...model, info: { ...model.info, isFree: true } } }) - const stream = handler.createMessage(systemPrompt, messages) + mockCreateOpenAICompatible.mockClear() + mockLanguageModel.mockClear() + mockStreamText.mockReturnValue( + createDefaultStreamMock("response", { + prompt_tokens: 100, + completion_tokens: 50, + cost: 0.005, + }), + ) + const chunks: any[] = [] - for await (const chunk of stream) { - // Simulate what Task.ts does: when we receive tool_call_partial, - // process it through NativeToolCallParser to populate rawChunkTracker - if (chunk.type === "tool_call_partial") { - NativeToolCallParser.processRawChunk({ - index: chunk.index, - id: chunk.id, - name: chunk.name, - arguments: chunk.arguments, - }) - } + for await (const chunk of freeHandler.createMessage(systemPrompt, messages)) { chunks.push(chunk) } - // Should have tool_call_partial and tool_call_end - const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + const usage = chunks.find((c) => c.type === "usage") + expect(usage).toBeDefined() + expect(usage.totalCost).toBe(0) + }) - expect(partialChunks).toHaveLength(1) - expect(endChunks).toHaveLength(1) - expect(endChunks[0].id).toBe("call_finish_test") + it("should handle cache token metrics", async () => { + mockStreamText.mockReturnValue( + createDefaultStreamMock("response", { + prompt_tokens: 100, + completion_tokens: 50, + cache_creation_input_tokens: 20, + prompt_tokens_details: { cached_tokens: 30 }, + cost: 0.003, + }), + ) + + const chunks: any[] = [] + for await (const chunk of handler.createMessage(systemPrompt, messages)) { + chunks.push(chunk) + } + + const usage = chunks.find((c) => c.type === "usage") + expect(usage).toBeDefined() + expect(usage.cacheWriteTokens).toBe(20) + expect(usage.cacheReadTokens).toBe(30) }) }) }) diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index 51bc256b769..82b1399bb7c 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -25,8 +25,7 @@ vi.mock("sambanova-ai-provider", () => ({ }), })) -import type { Anthropic } from "@anthropic-ai/sdk" - +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import { sambaNovaDefaultModelId, sambaNovaModels, type SambaNovaModelId } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" @@ -116,7 +115,7 @@ describe("SambaNovaHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [ @@ -454,7 +453,7 @@ describe("SambaNovaHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], @@ -569,7 +568,7 @@ describe("SambaNovaHandler", () => { describe("error handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 9ff804e0c42..0898f89bef6 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -1,272 +1,192 @@ -// npx vitest run src/api/providers/__tests__/vercel-ai-gateway.spec.ts - -// Mock vscode first to avoid import errors -vitest.mock("vscode", () => ({})) - -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -import { VercelAiGatewayHandler } from "../vercel-ai-gateway" -import { ApiHandlerOptions } from "../../../shared/api" -import { vercelAiGatewayDefaultModelId, VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-code/types" +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) -// Mock dependencies -vitest.mock("openai") -vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) })) -vitest.mock("../fetchers/modelCache", () => ({ - getModels: vitest.fn().mockImplementation(() => { - return Promise.resolve({ - "anthropic/claude-sonnet-4": { - maxTokens: 64000, - contextWindow: 200000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 3, - outputPrice: 15, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - description: "Claude Sonnet 4", - }, - "anthropic/claude-3.5-haiku": { - maxTokens: 32000, - contextWindow: 200000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 1, - outputPrice: 5, - cacheWritesPrice: 1.25, - cacheReadsPrice: 0.1, - description: "Claude 3.5 Haiku", - }, - "openai/gpt-4o": { - maxTokens: 16000, - contextWindow: 128000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 2.5, - outputPrice: 10, - cacheWritesPrice: 3.125, - cacheReadsPrice: 0.25, - description: "GPT-4o", - }, - }) +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + // Return a function that returns a mock language model + return vi.fn(() => ({ + modelId: "anthropic/claude-sonnet-4", + provider: "vercel-ai-gateway", + })) }), - getModelsFromCache: vitest.fn().mockReturnValue(undefined), })) -vitest.mock("../../transform/caching/vercel-ai-gateway", () => ({ - addCacheBreakpoints: vitest.fn(), +vi.mock("../fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + getModelsFromCache: vi.fn().mockReturnValue(undefined), })) -const mockCreate = vitest.fn() -const mockConstructor = vitest.fn() +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" +import { vercelAiGatewayDefaultModelId, VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-code/types" -;(OpenAI as any).mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, -})) -;(OpenAI as any).mockImplementation = mockConstructor.mockReturnValue({ - chat: { - completions: { - create: mockCreate, - }, - }, -}) +import type { ApiHandlerOptions } from "../../../shared/api" + +import { VercelAiGatewayHandler } from "../vercel-ai-gateway" describe("VercelAiGatewayHandler", () => { - const mockOptions: ApiHandlerOptions = { - vercelAiGatewayApiKey: "test-key", - vercelAiGatewayModelId: "anthropic/claude-sonnet-4", - } + let handler: VercelAiGatewayHandler + let mockOptions: ApiHandlerOptions beforeEach(() => { - vitest.clearAllMocks() - mockCreate.mockClear() - mockConstructor.mockClear() + mockOptions = { + vercelAiGatewayApiKey: "test-api-key", + vercelAiGatewayModelId: "anthropic/claude-sonnet-4", + } + handler = new VercelAiGatewayHandler(mockOptions) + vi.clearAllMocks() }) - it("initializes with correct options", () => { - const handler = new VercelAiGatewayHandler(mockOptions) - expect(handler).toBeInstanceOf(VercelAiGatewayHandler) - - expect(OpenAI).toHaveBeenCalledWith({ - baseURL: "https://ai-gateway.vercel.sh/v1", - apiKey: mockOptions.vercelAiGatewayApiKey, - defaultHeaders: expect.objectContaining({ - "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", - "X-Title": "Roo Code", - "User-Agent": expect.stringContaining("RooCode/"), - }), - }) - }) - - describe("fetchModel", () => { - it("returns correct model info when options are provided", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) - const result = await handler.fetchModel() - - expect(result.id).toBe(mockOptions.vercelAiGatewayModelId) - expect(result.info.maxTokens).toBe(64000) - expect(result.info.contextWindow).toBe(200000) - expect(result.info.supportsImages).toBe(true) - expect(result.info.supportsPromptCache).toBe(true) + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(VercelAiGatewayHandler) + expect(handler.getModel().id).toBe("anthropic/claude-sonnet-4") }) - it("returns default model info when options are not provided", async () => { - const handler = new VercelAiGatewayHandler({}) - const result = await handler.fetchModel() - expect(result.id).toBe(vercelAiGatewayDefaultModelId) - expect(result.info.supportsPromptCache).toBe(true) + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new VercelAiGatewayHandler({ + ...mockOptions, + vercelAiGatewayModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe(vercelAiGatewayDefaultModelId) }) - it("uses vercel ai gateway default model when no model specified", async () => { - const handler = new VercelAiGatewayHandler({ vercelAiGatewayApiKey: "test-key" }) - const result = await handler.fetchModel() - expect(result.id).toBe("anthropic/claude-sonnet-4") + it("should use default API key if not provided", () => { + const handlerWithoutKey = new VercelAiGatewayHandler({ + ...mockOptions, + vercelAiGatewayApiKey: undefined, + }) + expect(handlerWithoutKey).toBeInstanceOf(VercelAiGatewayHandler) }) }) - describe("createMessage", () => { - beforeEach(() => { - mockCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - cache_creation_input_tokens: 2, - prompt_tokens_details: { - cached_tokens: 3, - }, - cost: 0.005, - }, - } - }, - })) + describe("getModel", () => { + it("should return model info for the configured model", () => { + const model = handler.getModel() + expect(model.id).toBe("anthropic/claude-sonnet-4") + expect(model.info).toBeDefined() + // Falls back to default model info since cache is empty + expect(model.info.maxTokens).toBe(64000) + expect(model.info.contextWindow).toBe(200000) + expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsPromptCache).toBe(true) }) - it("streams text content correctly", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + it("should return default model when no model ID is provided", () => { + const handlerWithoutModel = new VercelAiGatewayHandler({ + ...mockOptions, + vercelAiGatewayModelId: undefined, + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(vercelAiGatewayDefaultModelId) + expect(model.info).toBeDefined() + }) - const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) - expect(chunks).toHaveLength(2) - expect(chunks[0]).toEqual({ - type: "text", - text: "Test response", - }) - expect(chunks[1]).toEqual({ - type: "usage", - inputTokens: 10, - outputTokens: 5, - cacheWriteTokens: 2, - cacheReadTokens: 3, - totalCost: 0.005, + it("should use default temperature when none is specified", () => { + const handlerNoTemp = new VercelAiGatewayHandler({ + ...mockOptions, + modelTemperature: undefined, }) + const model = handlerNoTemp.getModel() + expect(model.temperature).toBe(VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE) }) - it("uses correct temperature from options", async () => { - const customTemp = 0.5 - const handler = new VercelAiGatewayHandler({ + it("should use custom temperature when specified", () => { + const handlerCustomTemp = new VercelAiGatewayHandler({ ...mockOptions, - modelTemperature: customTemp, + modelTemperature: 0.5, }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] - - await handler.createMessage(systemPrompt, messages).next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: customTemp, - }), - ) + const model = handlerCustomTemp.getModel() + expect(model.temperature).toBe(0.5) }) + }) - it("uses default temperature when none provided", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] - - await handler.createMessage(systemPrompt, messages).next() + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: NeutralMessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE, - }), - ) - }) + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - it("adds cache breakpoints for supported models", async () => { - const { addCacheBreakpoints } = await import("../../transform/caching/vercel-ai-gateway") - const handler = new VercelAiGatewayHandler({ - ...mockOptions, - vercelAiGatewayModelId: "anthropic/claude-3.5-haiku", + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: { cachedInputTokens: 3 }, + raw: { cache_creation_input_tokens: 2, cost: 0.005 }, }) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) - await handler.createMessage(systemPrompt, messages).next() + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - expect(addCacheBreakpoints).toHaveBeenCalled() + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response") }) - it("sets correct max_completion_tokens", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] - - await handler.createMessage(systemPrompt, messages).next() + it("should include usage information with gateway-specific fields", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - max_completion_tokens: 64000, // max tokens for sonnet 4 - }), - ) - }) + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: { cachedInputTokens: 3 }, + raw: { cache_creation_input_tokens: 2, cost: 0.005 }, + }) - it("handles usage info correctly with all Vercel AI Gateway specific fields", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] + const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } - const usageChunk = chunks.find((chunk) => chunk.type === "usage") - expect(usageChunk).toEqual({ + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 5, @@ -275,313 +195,207 @@ describe("VercelAiGatewayHandler", () => { totalCost: 0.005, }) }) + }) - describe("native tool calling", () => { - const testTools = [ - { - type: "function" as const, - function: { - name: "test_tool", - description: "A test tool", - parameters: { - type: "object", - properties: { - arg1: { type: "string" }, - }, - required: ["arg1"], - }, - }, - }, - ] - - beforeEach(() => { - mockCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - } - }, - })) + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", }) - it("should include tools when provided", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) - - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - type: "function", - function: expect.objectContaining({ - name: "test_tool", - }), - }), - ]), - }), - ) - }) + const result = await handler.completePrompt("Test prompt") - it("should include tool_choice when provided", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) - - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - tool_choice: "auto", - }) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tool_choice: "auto", - }), - ) - }) + expect(result).toBe("Test completion") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + }) - it("should set parallel_tool_calls when parallelToolCalls is enabled", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) - - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - parallelToolCalls: true, - }) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - parallel_tool_calls: true, - }), - ) - }) + describe("processUsageMetrics", () => { + it("should correctly process usage metrics including cache and cost", () => { + class TestHandler extends VercelAiGatewayHandler { + public testProcessUsageMetrics(usage: any) { + return this.processUsageMetrics(usage) + } + } - it("should include parallel_tool_calls: true by default", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) - - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.any(Array), - parallel_tool_calls: true, - }), - ) - }) + const testHandler = new TestHandler(mockOptions) - it("should yield tool_call_partial chunks when streaming tool calls", async () => { - mockCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_123", - function: { - name: "test_tool", - arguments: '{"arg1":', - }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { - arguments: '"value"}', - }, - }, - ], - }, - index: 0, - }, - ], - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - }, - } - }, - })) + const usage = { + inputTokens: 100, + outputTokens: 50, + details: { cachedInputTokens: 20 }, + raw: { + cache_creation_input_tokens: 10, + cost: 0.01, + }, + } - const handler = new VercelAiGatewayHandler(mockOptions) + const result = testHandler.testProcessUsageMetrics(usage) - const stream = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - tools: testTools, - }) + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBe(10) + expect(result.cacheReadTokens).toBe(20) + expect(result.totalCost).toBe(0.01) + }) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) + it("should handle missing cache and cost metrics gracefully", () => { + class TestHandler extends VercelAiGatewayHandler { + public testProcessUsageMetrics(usage: any) { + return this.processUsageMetrics(usage) } + } - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - expect(toolCallChunks).toHaveLength(2) - expect(toolCallChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", - name: "test_tool", - arguments: '{"arg1":', - }) - expect(toolCallChunks[1]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"value"}', - }) - }) + const testHandler = new TestHandler(mockOptions) - it("should include stream_options with include_usage", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) + const usage = { + inputTokens: 100, + outputTokens: 50, + details: {}, + raw: {}, + } - const messageGenerator = handler.createMessage("test prompt", [], { - taskId: "test-task-id", - }) - await messageGenerator.next() + const result = testHandler.testProcessUsageMetrics(usage) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - stream_options: { include_usage: true }, - }), - ) - }) + expect(result.type).toBe("usage") + expect(result.inputTokens).toBe(100) + expect(result.outputTokens).toBe(50) + expect(result.cacheWriteTokens).toBeUndefined() + expect(result.cacheReadTokens).toBeUndefined() + expect(result.totalCost).toBe(0) }) }) - describe("completePrompt", () => { - beforeEach(() => { - mockCreate.mockImplementation(async () => ({ - choices: [ + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: NeutralMessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: {}, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ { - message: { role: "assistant", content: "Test completion response" }, - finish_reason: "stop", - index: 0, + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, }, ], - usage: { - prompt_tokens: 8, - completion_tokens: 4, - total_tokens: 12, - }, - })) - }) + }) - it("completes prompt correctly", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) - const prompt = "Complete this: Hello" + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - const result = await handler.completePrompt(prompt) + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") - expect(result).toBe("Test completion response") - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "anthropic/claude-sonnet-4", - messages: [{ role: "user", content: prompt }], - stream: false, - temperature: VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE, - max_completion_tokens: 64000, - }), - ) - }) - - it("uses custom temperature for completion", async () => { - const customTemp = 0.8 - const handler = new VercelAiGatewayHandler({ - ...mockOptions, - modelTemperature: customTemp, - }) + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") - await handler.completePrompt("Test prompt") + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: customTemp, - }), - ) + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") }) - it("handles completion errors correctly", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) - const errorMessage = "API error" + it("should ignore tool-call events to prevent duplicate tools in UI", async () => { + async function* mockFullStream() { + yield { + type: "tool-call", + toolCallId: "tool-call-1", + toolName: "read_file", + input: { path: "test.ts" }, + } + } - mockCreate.mockImplementation(() => { - throw new Error(errorMessage) + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: {}, }) - await expect(handler.completePrompt("Test")).rejects.toThrow( - `Vercel AI Gateway completion error: ${errorMessage}`, - ) - }) - - it("returns empty string when no content in response", async () => { - const handler = new VercelAiGatewayHandler(mockOptions) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) - mockCreate.mockImplementation(async () => ({ - choices: [ + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ { - message: { role: "assistant", content: null }, - finish_reason: "stop", - index: 0, + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, }, ], - })) - - const result = await handler.completePrompt("Test") - expect(result).toBe("") - }) - }) - - describe("temperature support", () => { - it("applies temperature for supported models", async () => { - const handler = new VercelAiGatewayHandler({ - ...mockOptions, - vercelAiGatewayModelId: "anthropic/claude-sonnet-4", - modelTemperature: 0.9, }) - await handler.completePrompt("Test") + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.9, - }), - ) + // tool-call events are ignored, so no tool_call chunks should be emitted + const toolCallChunks = chunks.filter((c) => c.type === "tool_call") + expect(toolCallChunks.length).toBe(0) }) }) }) diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index cc90c144b2f..89d4c78a8ce 100644 --- a/src/api/providers/__tests__/vertex.spec.ts +++ b/src/api/providers/__tests__/vertex.spec.ts @@ -29,8 +29,7 @@ vitest.mock("ai", async (importOriginal) => { } }) -import { Anthropic } from "@anthropic-ai/sdk" - +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import { ApiStreamChunk } from "../../transform/stream" import { t } from "i18next" @@ -140,7 +139,7 @@ describe("VertexHandler", () => { }) describe("createMessage", () => { - const mockMessages: Anthropic.Messages.MessageParam[] = [ + const mockMessages: NeutralMessageParam[] = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there!" }, ] diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 305305d2289..126b33baadc 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -1,3 +1,4 @@ +import type { RooMessageParam, RooContentBlock } from "../../../core/task-persistence/apiMessages" import type { Mock } from "vitest" // Mocks must come first, before imports @@ -16,6 +17,14 @@ vi.mock("vscode", () => { ) {} } + class MockLanguageModelToolResultPart { + type = "tool_result" + constructor( + public callId: string, + public content: any[], + ) {} + } + return { workspace: { onDidChangeConfiguration: vi.fn((_callback) => ({ @@ -48,6 +57,7 @@ vi.mock("vscode", () => { }, LanguageModelTextPart: MockLanguageModelTextPart, LanguageModelToolCallPart: MockLanguageModelToolCallPart, + LanguageModelToolResultPart: MockLanguageModelToolResultPart, lm: { selectChatModels: vi.fn(), }, @@ -57,7 +67,6 @@ vi.mock("vscode", () => { import * as vscode from "vscode" import { VsCodeLmHandler } from "../vscode-lm" import type { ApiHandlerOptions } from "../../../shared/api" -import type { Anthropic } from "@anthropic-ai/sdk" const mockLanguageModelChat = { id: "test-model", @@ -102,6 +111,12 @@ describe("VsCodeLmHandler", () => { }) }) + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) + }) + }) + describe("createClient", () => { it("should create client with selector", async () => { const mockModel = { ...mockLanguageModelChat } @@ -141,9 +156,9 @@ describe("VsCodeLmHandler", () => { handler["client"] = mockLanguageModelChat }) - it("should stream text responses", async () => { + it("should stream text responses via AI SDK", async () => { const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user" as const, content: "Hello", @@ -168,21 +183,20 @@ describe("VsCodeLmHandler", () => { chunks.push(chunk) } - expect(chunks).toHaveLength(2) // Text chunk + usage chunk - expect(chunks[0]).toEqual({ - type: "text", - text: responseText, - }) - expect(chunks[1]).toMatchObject({ - type: "usage", - inputTokens: expect.any(Number), - outputTokens: expect.any(Number), - }) + // Should have text chunk(s) and a usage chunk + const textChunks = chunks.filter((c) => c.type === "text") + const usageChunks = chunks.filter((c) => c.type === "usage") + + expect(textChunks.length).toBeGreaterThanOrEqual(1) + // Verify text content is present + const fullText = textChunks.map((c) => ("text" in c ? c.text : "")).join("") + expect(fullText).toBe(responseText) + expect(usageChunks).toHaveLength(1) }) - it("should emit tool_call chunks when tools are provided", async () => { + it("should emit streaming tool call events when tools are provided", async () => { const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user" as const, content: "Calculate 2+2", @@ -236,83 +250,36 @@ describe("VsCodeLmHandler", () => { chunks.push(chunk) } - expect(chunks).toHaveLength(2) // Tool call chunk + usage chunk - expect(chunks[0]).toEqual({ - type: "tool_call", + // AI SDK emits streaming tool call events + const toolStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolStartChunks).toHaveLength(1) + expect(toolStartChunks[0]).toMatchObject({ + type: "tool_call_start", id: toolCallData.callId, name: toolCallData.name, - arguments: JSON.stringify(toolCallData.arguments), }) - }) - it("should handle native tool calls when tools are provided", async () => { - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user" as const, - content: "Calculate 2+2", - }, - ] - - const toolCallData = { - name: "calculator", - arguments: { operation: "add", numbers: [2, 2] }, - callId: "call-1", - } - - const tools = [ - { - type: "function" as const, - function: { - name: "calculator", - description: "A simple calculator", - parameters: { - type: "object", - properties: { - operation: { type: "string" }, - numbers: { type: "array", items: { type: "number" } }, - }, - }, - }, - }, - ] - - mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ - stream: (async function* () { - yield new vscode.LanguageModelToolCallPart( - toolCallData.callId, - toolCallData.name, - toolCallData.arguments, - ) - return - })(), - text: (async function* () { - yield JSON.stringify({ type: "tool_call", ...toolCallData }) - return - })(), - }) - - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools, + expect(toolDeltaChunks).toHaveLength(1) + expect(toolDeltaChunks[0]).toMatchObject({ + type: "tool_call_delta", + id: toolCallData.callId, }) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + // Delta should contain the stringified arguments + expect((toolDeltaChunks[0] as { delta: string }).delta).toBe(JSON.stringify(toolCallData.arguments)) - expect(chunks).toHaveLength(2) // Tool call chunk + usage chunk - expect(chunks[0]).toEqual({ - type: "tool_call", + expect(toolEndChunks).toHaveLength(1) + expect(toolEndChunks[0]).toMatchObject({ + type: "tool_call_end", id: toolCallData.callId, - name: toolCallData.name, - arguments: JSON.stringify(toolCallData.arguments), }) }) - it("should pass tools to request options when tools are provided", async () => { + it("should handle mixed text and tool call responses", async () => { const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user" as const, content: "Calculate 2+2", @@ -337,11 +304,14 @@ describe("VsCodeLmHandler", () => { mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ stream: (async function* () { - yield new vscode.LanguageModelTextPart("Result: 4") + yield new vscode.LanguageModelTextPart("Let me calculate that. ") + yield new vscode.LanguageModelToolCallPart("call-1", "calculator", { + operation: "add", + }) return })(), text: (async function* () { - yield "Result: 4" + yield "Let me calculate that. " return })(), }) @@ -355,32 +325,17 @@ describe("VsCodeLmHandler", () => { chunks.push(chunk) } - // Verify sendRequest was called with tools in options - // Note: normalizeToolSchema adds additionalProperties: false for JSON Schema 2020-12 compliance - expect(mockLanguageModelChat.sendRequest).toHaveBeenCalledWith( - expect.any(Array), - expect.objectContaining({ - tools: [ - { - name: "calculator", - description: "A simple calculator", - inputSchema: { - type: "object", - properties: { - operation: { type: "string" }, - }, - additionalProperties: false, - }, - }, - ], - }), - expect.anything(), - ) + // Should have text, tool streaming events, and usage + const textChunks = chunks.filter((c) => c.type === "text") + const toolStartChunks = chunks.filter((c) => c.type === "tool_call_start") + + expect(textChunks.length).toBeGreaterThanOrEqual(1) + expect(toolStartChunks).toHaveLength(1) }) it("should handle errors", async () => { const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user" as const, content: "Hello", @@ -389,7 +344,14 @@ describe("VsCodeLmHandler", () => { mockLanguageModelChat.sendRequest.mockRejectedValueOnce(new Error("API Error")) - await expect(handler.createMessage(systemPrompt, messages).next()).rejects.toThrow("API Error") + // AI SDK wraps adapter errors and handleAiSdkError re-wraps them + const stream = handler.createMessage(systemPrompt, messages) + const consumeStream = async () => { + for await (const _chunk of stream) { + // consume + } + } + await expect(consumeStream()).rejects.toThrow("VS Code LM:") }) }) @@ -448,7 +410,7 @@ describe("VsCodeLmHandler", () => { mockLanguageModelChat.countTokens.mockResolvedValueOnce(42) - const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "Hello world" }] + const content: RooContentBlock[] = [{ type: "text", text: "Hello world" }] const result = await handler.countTokens(content) expect(result).toBe(42) @@ -466,7 +428,7 @@ describe("VsCodeLmHandler", () => { mockLanguageModelChat.countTokens.mockResolvedValueOnce(50) - const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "Test content" }] + const content: RooContentBlock[] = [{ type: "text", text: "Test content" }] const result = await handler.countTokens(content) expect(result).toBe(50) @@ -477,7 +439,7 @@ describe("VsCodeLmHandler", () => { handler["client"] = null handler["currentRequestCancellation"] = null - const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "Hello" }] + const content: RooContentBlock[] = [{ type: "text", text: "Hello" }] const result = await handler.countTokens(content) expect(result).toBe(0) @@ -487,9 +449,7 @@ describe("VsCodeLmHandler", () => { handler["currentRequestCancellation"] = null mockLanguageModelChat.countTokens.mockResolvedValueOnce(5) - const content: Anthropic.Messages.ContentBlockParam[] = [ - { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } }, - ] + const content: RooContentBlock[] = [{ type: "image", image: "abc", mediaType: "image/png" }] const result = await handler.countTokens(content) expect(result).toBe(5) @@ -498,7 +458,7 @@ describe("VsCodeLmHandler", () => { }) describe("completePrompt", () => { - it("should complete single prompt", async () => { + it("should complete single prompt via generateText", async () => { const mockModel = { ...mockLanguageModelChat } ;(vscode.lm.selectChatModels as Mock).mockResolvedValueOnce([mockModel]) @@ -533,7 +493,7 @@ describe("VsCodeLmHandler", () => { handler["client"] = mockLanguageModelChat const promise = handler.completePrompt("Test prompt") - await expect(promise).rejects.toThrow("VSCode LM completion error: Completion failed") + await expect(promise).rejects.toThrow("Completion failed") }) }) }) diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index 27e0a25f5cc..f763496f848 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -25,8 +25,7 @@ vi.mock("@ai-sdk/xai", () => ({ }), })) -import type { Anthropic } from "@anthropic-ai/sdk" - +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import { xaiDefaultModelId, xaiModels, type XAIModelId } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" @@ -141,7 +140,7 @@ describe("XAIHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [ @@ -538,7 +537,7 @@ describe("XAIHandler", () => { describe("tool handling", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index af3154e7783..ad8e071f72a 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -24,8 +24,7 @@ vi.mock("zhipu-ai-provider", () => ({ }), })) -import type { Anthropic } from "@anthropic-ai/sdk" - +import type { NeutralMessageParam } from "../../../core/task-persistence/apiMessages" import { type InternationalZAiModelId, type MainlandZAiModelId, @@ -262,7 +261,7 @@ describe("ZAiHandler", () => { describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: NeutralMessageParam[] = [ { role: "user", content: [{ type: "text" as const, text: "Hello!" }], diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 685c8628b04..2fea0cb8c4c 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -1,6 +1,5 @@ -import type { Anthropic } from "@anthropic-ai/sdk" -import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic" -import { streamText, generateText, ToolSet } from "ai" +import { createVertexAnthropic, type GoogleVertexAnthropicProvider } from "@ai-sdk/google-vertex/anthropic" +import { streamText, generateText, type ToolSet, type SystemModelMessage } from "ai" import { type ModelInfo, @@ -13,11 +12,9 @@ import { } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" -import { shouldUseReasoningBudget } from "../../shared/api" -import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" -import { getModelParams } from "../transform/model-params" import { convertToAiSdkMessages, convertToolsForAiSdk, @@ -25,294 +22,64 @@ import { mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" -import { calculateApiCostAnthropic } from "../../shared/cost" +import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" +import { buildCachedSystemMessage, applyCacheBreakpoints } from "../transform/caching" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { calculateApiCostAnthropic } from "../../shared/cost" -// https://docs.anthropic.com/en/api/claude-on-vertex-ai +/** + * Anthropic on Vertex AI provider using the AI SDK (@ai-sdk/google-vertex/anthropic). + * Supports extended thinking, prompt caching (4-block limit), 1M context beta, and cache cost metrics. + * + * @see https://docs.anthropic.com/en/api/claude-on-vertex-ai + */ export class AnthropicVertexHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private provider: ReturnType - private readonly providerName = "Vertex (Anthropic)" - private lastThoughtSignature: string | undefined - private lastRedactedThinkingBlocks: Array<{ type: "redacted_thinking"; data: string }> = [] + private readonly providerName = "AnthropicVertex" constructor(options: ApiHandlerOptions) { super() this.options = options + } + + override isAiSdkProvider(): boolean { + return true + } - // https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions + /** + * Create the AI SDK Vertex Anthropic provider with appropriate configuration. + * Handles three auth paths: JSON credentials, key file, or default ADC. + */ + protected createProvider(): GoogleVertexAnthropicProvider { const projectId = this.options.vertexProjectId ?? "not-provided" const region = this.options.vertexRegion ?? "us-east5" // Build googleAuthOptions based on provided credentials let googleAuthOptions: { credentials?: object; keyFile?: string } | undefined - if (options.vertexJsonCredentials) { + + if (this.options.vertexJsonCredentials) { try { - googleAuthOptions = { credentials: JSON.parse(options.vertexJsonCredentials) } + googleAuthOptions = { credentials: JSON.parse(this.options.vertexJsonCredentials) } } catch { - // If JSON parsing fails, ignore and try other auth methods + // If JSON parsing fails, fall through to other auth methods } - } else if (options.vertexKeyFile) { - googleAuthOptions = { keyFile: options.vertexKeyFile } + } else if (this.options.vertexKeyFile) { + googleAuthOptions = { keyFile: this.options.vertexKeyFile } } - // Build beta headers for 1M context support - const modelId = options.apiModelId - const betas: string[] = [] - - if (modelId) { - const supports1MContext = VERTEX_1M_CONTEXT_MODEL_IDS.includes( - modelId as (typeof VERTEX_1M_CONTEXT_MODEL_IDS)[number], - ) - if (supports1MContext && options.vertex1MContext) { - betas.push("context-1m-2025-08-07") - } - } - - this.provider = createVertexAnthropic({ + return createVertexAnthropic({ project: projectId, location: region, googleAuthOptions, - headers: { - ...DEFAULT_HEADERS, - ...(betas.length > 0 ? { "anthropic-beta": betas.join(",") } : {}), - }, + headers: { ...DEFAULT_HEADERS }, }) } - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - const modelConfig = this.getModel() - - // Reset thinking state for this request - this.lastThoughtSignature = undefined - this.lastRedactedThinkingBlocks = [] - - // Convert messages to AI SDK format - const aiSdkMessages = convertToAiSdkMessages(messages) - - // Convert tools to AI SDK format - const openAiTools = this.convertToolsForOpenAI(metadata?.tools) - const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined - - // Build Anthropic provider options - const anthropicProviderOptions: Record = {} - - // Configure thinking/reasoning if the model supports it - const isThinkingEnabled = - shouldUseReasoningBudget({ model: modelConfig.info, settings: this.options }) && - modelConfig.reasoning && - modelConfig.reasoningBudget - - if (isThinkingEnabled) { - anthropicProviderOptions.thinking = { - type: "enabled", - budgetTokens: modelConfig.reasoningBudget, - } - } - - // Forward parallelToolCalls setting - // When parallelToolCalls is explicitly false, disable parallel tool use - if (metadata?.parallelToolCalls === false) { - anthropicProviderOptions.disableParallelToolUse = true - } - - /** - * Vertex API has specific limitations for prompt caching: - * 1. Maximum of 4 blocks can have cache_control - * 2. Only text blocks can be cached (images and other content types cannot) - * 3. Cache control can only be applied to user messages, not assistant messages - * - * Our caching strategy: - * - Cache the system prompt (1 block) - * - Cache the last text block of the second-to-last user message (1 block) - * - Cache the last text block of the last user message (1 block) - * This ensures we stay under the 4-block limit while maintaining effective caching - * for the most relevant context. - */ - const cacheProviderOption = { anthropic: { cacheControl: { type: "ephemeral" as const } } } - - const userMsgIndices = messages.reduce( - (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), - [] as number[], - ) - - const targetIndices = new Set() - const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 - const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 - - if (lastUserMsgIndex >= 0) targetIndices.add(lastUserMsgIndex) - if (secondLastUserMsgIndex >= 0) targetIndices.add(secondLastUserMsgIndex) - - if (targetIndices.size > 0) { - this.applyCacheControlToAiSdkMessages(messages, aiSdkMessages, targetIndices, cacheProviderOption) - } - - // Build streamText request - // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values - const requestOptions: Parameters[0] = { - model: this.provider(modelConfig.id), - system: systemPrompt, - ...({ - systemProviderOptions: { anthropic: { cacheControl: { type: "ephemeral" } } }, - } as Record), - messages: aiSdkMessages, - temperature: modelConfig.temperature, - maxOutputTokens: modelConfig.maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, - tools: aiSdkTools, - toolChoice: mapToolChoice(metadata?.tool_choice), - ...(Object.keys(anthropicProviderOptions).length > 0 && { - providerOptions: { anthropic: anthropicProviderOptions } as any, - }), - } - - try { - const result = streamText(requestOptions) - - for await (const part of result.fullStream) { - // Capture thinking signature from stream events - // The AI SDK's @ai-sdk/anthropic emits the signature as a reasoning-delta - // event with providerMetadata.anthropic.signature - const partAny = part as any - if (partAny.providerMetadata?.anthropic?.signature) { - this.lastThoughtSignature = partAny.providerMetadata.anthropic.signature - } - - // Capture redacted thinking blocks from stream events - if (partAny.providerMetadata?.anthropic?.redactedData) { - this.lastRedactedThinkingBlocks.push({ - type: "redacted_thinking", - data: partAny.providerMetadata.anthropic.redactedData, - }) - } - - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - // Yield usage metrics at the end, including cache metrics from providerMetadata - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - TelemetryService.instance.captureException( - new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage"), - ) - throw handleAiSdkError(error, this.providerName) - } - } - - /** - * Process usage metrics from the AI SDK response, including Anthropic's cache metrics. - */ - private processUsageMetrics( - usage: { inputTokens?: number; outputTokens?: number }, - info: ModelInfo, - providerMetadata?: Record>, - ): ApiStreamUsageChunk { - const inputTokens = usage.inputTokens ?? 0 - const outputTokens = usage.outputTokens ?? 0 - - // Extract cache metrics from Anthropic's providerMetadata - const anthropicMeta = providerMetadata?.anthropic as - | { cacheCreationInputTokens?: number; cacheReadInputTokens?: number } - | undefined - const cacheWriteTokens = anthropicMeta?.cacheCreationInputTokens ?? 0 - const cacheReadTokens = anthropicMeta?.cacheReadInputTokens ?? 0 - - const { totalCost } = calculateApiCostAnthropic( - info, - inputTokens, - outputTokens, - cacheWriteTokens, - cacheReadTokens, - ) - - return { - type: "usage", - inputTokens, - outputTokens, - cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, - cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, - totalCost, - } - } - - /** - * Apply cacheControl providerOptions to the correct AI SDK messages by walking - * the original Anthropic messages and converted AI SDK messages in parallel. - * - * convertToAiSdkMessages() can split a single Anthropic user message (containing - * tool_results + text) into 2 AI SDK messages (tool role + user role). This method - * accounts for that split so cache control lands on the right message. - */ - private applyCacheControlToAiSdkMessages( - originalMessages: Anthropic.Messages.MessageParam[], - aiSdkMessages: { role: string; providerOptions?: Record> }[], - targetOriginalIndices: Set, - cacheProviderOption: Record>, - ): void { - let aiSdkIdx = 0 - for (let origIdx = 0; origIdx < originalMessages.length; origIdx++) { - const origMsg = originalMessages[origIdx] - - if (typeof origMsg.content === "string") { - if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { - aiSdkMessages[aiSdkIdx].providerOptions = { - ...aiSdkMessages[aiSdkIdx].providerOptions, - ...cacheProviderOption, - } - } - aiSdkIdx++ - } else if (origMsg.role === "user") { - const hasToolResults = origMsg.content.some((part) => (part as { type: string }).type === "tool_result") - const hasNonToolContent = origMsg.content.some( - (part) => (part as { type: string }).type === "text" || (part as { type: string }).type === "image", - ) - - if (hasToolResults && hasNonToolContent) { - const userMsgIdx = aiSdkIdx + 1 - if (targetOriginalIndices.has(origIdx) && userMsgIdx < aiSdkMessages.length) { - aiSdkMessages[userMsgIdx].providerOptions = { - ...aiSdkMessages[userMsgIdx].providerOptions, - ...cacheProviderOption, - } - } - aiSdkIdx += 2 - } else if (hasToolResults) { - if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { - aiSdkMessages[aiSdkIdx].providerOptions = { - ...aiSdkMessages[aiSdkIdx].providerOptions, - ...cacheProviderOption, - } - } - aiSdkIdx++ - } else { - if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { - aiSdkMessages[aiSdkIdx].providerOptions = { - ...aiSdkMessages[aiSdkIdx].providerOptions, - ...cacheProviderOption, - } - } - aiSdkIdx++ - } - } else { - aiSdkIdx++ - } - } - } - - getModel() { + override getModel() { const modelId = this.options.apiModelId let id = modelId && modelId in vertexModels ? (modelId as VertexModelId) : vertexDefaultModelId let info: ModelInfo = vertexModels[id] @@ -346,9 +113,10 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple defaultTemperature: 0, }) - // Build betas array for request headers (kept for backward compatibility / testing) + // Build betas array for request headers const betas: string[] = [] + // Add 1M context beta flag if enabled for supported models if (enable1MContext) { betas.push("context-1m-2025-08-07") } @@ -365,49 +133,154 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple } } + /** + * Build Anthropic provider options for thinking configuration. + * Converts from native Anthropic SDK format (budget_tokens) to AI SDK format (budgetTokens). + */ + private buildProviderOptions(reasoning: { type: string; budget_tokens?: number } | undefined) { + const anthropicOptions: Record = {} + + if (reasoning) { + if (reasoning.type === "enabled" && reasoning.budget_tokens) { + // Convert from native Anthropic SDK format to AI SDK format + anthropicOptions.thinking = { + type: "enabled", + budgetTokens: reasoning.budget_tokens, + } + } else { + anthropicOptions.thinking = reasoning + } + } + + return Object.keys(anthropicOptions).length > 0 ? { anthropic: anthropicOptions } : undefined + } + + override async *createMessage( + systemPrompt: string, + messages: NeutralMessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { id: modelId, info: modelInfo, maxTokens, temperature, reasoning, betas } = this.getModel() + + const provider = this.createProvider() + const model = provider.languageModel(modelId) + + // Convert messages and tools + const aiSdkMessages = convertToAiSdkMessages(messages) + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build system prompt with optional cache control for prompt caching models. + // Vertex has a 4-block limit for cache_control: + // - 1 block for system prompt + // - Up to 2 blocks for user messages (last 2 user messages) + let system: string | SystemModelMessage = systemPrompt + + if (modelInfo.supportsPromptCache) { + system = buildCachedSystemMessage(systemPrompt, "anthropic") + applyCacheBreakpoints(aiSdkMessages, "anthropic") + } + + // Build provider options for thinking + const providerOptions = this.buildProviderOptions(reasoning) + + // Build per-request headers with betas (e.g. 1M context) + const headers = betas?.length ? { "anthropic-beta": betas.join(",") } : undefined + + const result = streamText({ + model, + system, + messages: aiSdkMessages, + temperature, + maxOutputTokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + providerOptions: providerOptions as any, + headers, + }) + + try { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics( + usage, + providerMetadata as Record> | undefined, + modelInfo, + ) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") + TelemetryService.instance.captureException(apiError) + throw handleAiSdkError(error, this.providerName) + } + } + async completePrompt(prompt: string): Promise { - const { id, temperature } = this.getModel() + const { id: modelId, temperature } = this.getModel() + const provider = this.createProvider() + const model = provider.languageModel(modelId) try { const { text } = await generateText({ - model: this.provider(id), + model, prompt, - maxOutputTokens: ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, }) - return text } catch (error) { - TelemetryService.instance.captureException( - new ApiProviderError( - error instanceof Error ? error.message : String(error), - this.providerName, - id, - "completePrompt", - ), - ) + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") + TelemetryService.instance.captureException(apiError) throw handleAiSdkError(error, this.providerName) } } /** - * Returns the thinking signature captured from the last Anthropic response. - * Claude models with extended thinking return a cryptographic signature - * which must be round-tripped back for multi-turn conversations with tool use. - */ - getThoughtSignature(): string | undefined { - return this.lastThoughtSignature - } - - /** - * Returns any redacted thinking blocks captured from the last Anthropic response. - * Anthropic returns these when safety filters trigger on reasoning content. + * Process usage metrics from the AI SDK response, including Anthropic-specific + * cache metrics from providerMetadata.anthropic. */ - getRedactedThinkingBlocks(): Array<{ type: "redacted_thinking"; data: string }> | undefined { - return this.lastRedactedThinkingBlocks.length > 0 ? this.lastRedactedThinkingBlocks : undefined - } + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + }, + providerMetadata?: Record>, + modelInfo?: ModelInfo, + ): ApiStreamUsageChunk { + const anthropicMeta = providerMetadata?.anthropic as Record | undefined + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + const cacheWriteTokens = (anthropicMeta?.cacheCreationInputTokens as number) ?? 0 + const cacheReadTokens = (anthropicMeta?.cacheReadInputTokens as number) ?? 0 + + // Calculate cost using Anthropic-specific pricing (cache read/write tokens) + let totalCost: number | undefined + if (modelInfo && (inputTokens > 0 || outputTokens > 0 || cacheWriteTokens > 0 || cacheReadTokens > 0)) { + const { totalCost: cost } = calculateApiCostAnthropic( + modelInfo, + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + ) + totalCost = cost + } - override isAiSdkProvider(): boolean { - return true + return { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + totalCost, + } } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index f6ee47e130c..2879e53594d 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -1,6 +1,6 @@ -import type { Anthropic } from "@anthropic-ai/sdk" +import { Anthropic } from "@anthropic-ai/sdk" import { createAnthropic } from "@ai-sdk/anthropic" -import { streamText, generateText, ToolSet } from "ai" +import { streamText, generateText, ToolSet, type SystemModelMessage } from "ai" import { type ModelInfo, @@ -12,11 +12,9 @@ import { } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" -import { shouldUseReasoningBudget } from "../../shared/api" -import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" -import { getModelParams } from "../transform/model-params" import { convertToAiSdkMessages, convertToolsForAiSdk, @@ -24,355 +22,266 @@ import { mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" -import { calculateApiCostAnthropic } from "../../shared/cost" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" +import { buildCachedSystemMessage, applyCacheBreakpoints } from "../transform/caching" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { calculateApiCostAnthropic } from "../../shared/cost" +/** + * Anthropic provider using the AI SDK (@ai-sdk/anthropic). + * Supports extended thinking, prompt caching, 1M context beta, and cache cost metrics. + */ export class AnthropicHandler extends BaseProvider implements SingleCompletionHandler { - private options: ApiHandlerOptions - private provider: ReturnType + protected options: ApiHandlerOptions private readonly providerName = "Anthropic" - private lastThoughtSignature: string | undefined - private lastRedactedThinkingBlocks: Array<{ type: "redacted_thinking"; data: string }> = [] constructor(options: ApiHandlerOptions) { super() this.options = options + } - const useAuthToken = Boolean(options.anthropicBaseUrl && options.anthropicUseAuthToken) + override isAiSdkProvider(): boolean { + return true + } - // Build beta headers for model-specific features - const betas: string[] = [] - const modelId = options.apiModelId + /** + * Create the AI SDK Anthropic provider with appropriate configuration. + * Handles apiKey vs authToken based on anthropicBaseUrl and anthropicUseAuthToken settings. + */ + protected createProvider() { + const baseURL = this.options.anthropicBaseUrl || undefined + const useAuthToken = this.options.anthropicBaseUrl && this.options.anthropicUseAuthToken + + return createAnthropic({ + ...(useAuthToken ? { authToken: this.options.apiKey } : { apiKey: this.options.apiKey || undefined }), + baseURL, + headers: { ...DEFAULT_HEADERS }, + }) + } - if (modelId === "claude-3-7-sonnet-20250219:thinking") { - betas.push("output-128k-2025-02-19") + override getModel() { + const modelId = this.options.apiModelId + let id = modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId + let info: ModelInfo = anthropicModels[id] + + // If 1M context beta is enabled for supported models, update the model info + if ( + (id === "claude-sonnet-4-20250514" || id === "claude-sonnet-4-5" || id === "claude-opus-4-6") && + this.options.anthropicBeta1MContext + ) { + // Use the tier pricing for 1M context + const tier = info.tiers?.[0] + if (tier) { + info = { + ...info, + contextWindow: tier.contextWindow, + inputPrice: tier.inputPrice, + outputPrice: tier.outputPrice, + cacheWritesPrice: tier.cacheWritesPrice, + cacheReadsPrice: tier.cacheReadsPrice, + } + } } + const params = getModelParams({ + format: "anthropic", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) + + // The `:thinking` suffix indicates that the model is a "Hybrid" + // reasoning model and that reasoning is required to be enabled. + // The actual model ID honored by Anthropic's API does not have this + // suffix. + return { + id: id === "claude-3-7-sonnet-20250219:thinking" ? "claude-3-7-sonnet-20250219" : id, + info, + betas: id === "claude-3-7-sonnet-20250219:thinking" ? ["output-128k-2025-02-19"] : undefined, + ...params, + } + } + + /** + * Build Anthropic provider options for thinking configuration. + * Converts from native Anthropic SDK format (budget_tokens) to AI SDK format (budgetTokens). + */ + private buildProviderOptions(reasoning: { type: string; budget_tokens?: number } | undefined) { + const anthropicOptions: Record = {} + + if (reasoning) { + if (reasoning.type === "enabled" && reasoning.budget_tokens) { + // Convert from native Anthropic SDK format to AI SDK format + anthropicOptions.thinking = { + type: "enabled", + budgetTokens: reasoning.budget_tokens, + } + } else { + anthropicOptions.thinking = reasoning + } + } + + return Object.keys(anthropicOptions).length > 0 ? { anthropic: anthropicOptions } : undefined + } + + /** + * Build the anthropic-beta header string for the current model configuration. + * Combines base betas (e.g., output-128k for :thinking), fine-grained tool streaming, + * prompt caching, and 1M context beta. + */ + private buildBetasHeader( + modelId: string, + modelInfo: ModelInfo, + baseBetas?: string[], + ): Record | undefined { + const betas = [...(baseBetas || []), "fine-grained-tool-streaming-2025-05-14"] + + // Add prompt caching beta if model supports it + if (modelInfo.supportsPromptCache) { + betas.push("prompt-caching-2024-07-31") + } + + // Add 1M context beta flag if enabled for supported models if ( (modelId === "claude-sonnet-4-20250514" || modelId === "claude-sonnet-4-5" || modelId === "claude-opus-4-6") && - options.anthropicBeta1MContext + this.options.anthropicBeta1MContext ) { betas.push("context-1m-2025-08-07") } - this.provider = createAnthropic({ - baseURL: options.anthropicBaseUrl || undefined, - ...(useAuthToken ? { authToken: options.apiKey } : { apiKey: options.apiKey }), - headers: { - ...DEFAULT_HEADERS, - ...(betas.length > 0 ? { "anthropic-beta": betas.join(",") } : {}), - }, - }) + return betas.length > 0 ? { "anthropic-beta": betas.join(",") } : undefined } override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const modelConfig = this.getModel() + const { id: modelId, info: modelInfo, maxTokens, temperature, reasoning, betas } = this.getModel() - // Reset thinking state for this request - this.lastThoughtSignature = undefined - this.lastRedactedThinkingBlocks = [] + const provider = this.createProvider() + const model = provider.chat(modelId) - // Convert messages to AI SDK format + // Convert messages and tools const aiSdkMessages = convertToAiSdkMessages(messages) - - // Convert tools to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined - // Build Anthropic provider options - const anthropicProviderOptions: Record = {} + // Build system prompt with optional cache control for prompt caching models + let system: string | SystemModelMessage = systemPrompt - // Configure thinking/reasoning if the model supports it - const isThinkingEnabled = - shouldUseReasoningBudget({ model: modelConfig.info, settings: this.options }) && - modelConfig.reasoning && - modelConfig.reasoningBudget - - if (isThinkingEnabled) { - anthropicProviderOptions.thinking = { - type: "enabled", - budgetTokens: modelConfig.reasoningBudget, - } - } - - // Forward parallelToolCalls setting - // When parallelToolCalls is explicitly false, disable parallel tool use - if (metadata?.parallelToolCalls === false) { - anthropicProviderOptions.disableParallelToolUse = true + if (modelInfo.supportsPromptCache) { + system = buildCachedSystemMessage(systemPrompt, "anthropic") + applyCacheBreakpoints(aiSdkMessages, "anthropic") } - // Apply cache control to user messages - // Strategy: cache the last 2 user messages (write-to-cache + read-from-cache) - const cacheProviderOption = { anthropic: { cacheControl: { type: "ephemeral" as const } } } - - const userMsgIndices = messages.reduce( - (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), - [] as number[], - ) - - const targetIndices = new Set() - const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 - const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 + // Build provider options for thinking + const providerOptions = this.buildProviderOptions(reasoning) - if (lastUserMsgIndex >= 0) targetIndices.add(lastUserMsgIndex) - if (secondLastUserMsgIndex >= 0) targetIndices.add(secondLastUserMsgIndex) + // Build per-request headers with betas + const headers = this.buildBetasHeader(modelId, modelInfo, betas) - if (targetIndices.size > 0) { - this.applyCacheControlToAiSdkMessages(messages, aiSdkMessages, targetIndices, cacheProviderOption) - } - - // Build streamText request - // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values - const requestOptions: Parameters[0] = { - model: this.provider(modelConfig.id), - system: systemPrompt, - ...({ - systemProviderOptions: { anthropic: { cacheControl: { type: "ephemeral" } } }, - } as Record), + const result = streamText({ + model, + system, messages: aiSdkMessages, - temperature: modelConfig.temperature, - maxOutputTokens: modelConfig.maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, + temperature, + maxOutputTokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, tools: aiSdkTools, toolChoice: mapToolChoice(metadata?.tool_choice), - ...(Object.keys(anthropicProviderOptions).length > 0 && { - providerOptions: { anthropic: anthropicProviderOptions } as any, - }), - } + providerOptions: providerOptions as any, + headers, + }) try { - const result = streamText(requestOptions) - for await (const part of result.fullStream) { - // Capture thinking signature from stream events - // The AI SDK's @ai-sdk/anthropic emits the signature as a reasoning-delta - // event with providerMetadata.anthropic.signature - const partAny = part as any - if (partAny.providerMetadata?.anthropic?.signature) { - this.lastThoughtSignature = partAny.providerMetadata.anthropic.signature - } - - // Capture redacted thinking blocks from stream events - if (partAny.providerMetadata?.anthropic?.redactedData) { - this.lastRedactedThinkingBlocks.push({ - type: "redacted_thinking", - data: partAny.providerMetadata.anthropic.redactedData, - }) - } - for (const chunk of processAiSdkStreamPart(part)) { yield chunk } } - // Yield usage metrics at the end, including cache metrics from providerMetadata const usage = await result.usage const providerMetadata = await result.providerMetadata if (usage) { - yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + yield this.processUsageMetrics( + usage, + providerMetadata as Record> | undefined, + modelInfo, + ) } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - TelemetryService.instance.captureException( - new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage"), - ) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") + TelemetryService.instance.captureException(apiError) throw handleAiSdkError(error, this.providerName) } } - /** - * Process usage metrics from the AI SDK response, including Anthropic's cache metrics. - */ - private processUsageMetrics( - usage: { inputTokens?: number; outputTokens?: number }, - info: ModelInfo, - providerMetadata?: Record>, - ): ApiStreamUsageChunk { - const inputTokens = usage.inputTokens ?? 0 - const outputTokens = usage.outputTokens ?? 0 - - // Extract cache metrics from Anthropic's providerMetadata - const anthropicMeta = providerMetadata?.anthropic as - | { cacheCreationInputTokens?: number; cacheReadInputTokens?: number } - | undefined - const cacheWriteTokens = anthropicMeta?.cacheCreationInputTokens ?? 0 - const cacheReadTokens = anthropicMeta?.cacheReadInputTokens ?? 0 - - const { totalCost } = calculateApiCostAnthropic( - info, - inputTokens, - outputTokens, - cacheWriteTokens, - cacheReadTokens, - ) - - return { - type: "usage", - inputTokens, - outputTokens, - cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, - cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, - totalCost, - } - } - - /** - * Apply cacheControl providerOptions to the correct AI SDK messages by walking - * the original Anthropic messages and converted AI SDK messages in parallel. - * - * convertToAiSdkMessages() can split a single Anthropic user message (containing - * tool_results + text) into 2 AI SDK messages (tool role + user role). This method - * accounts for that split so cache control lands on the right message. - */ - private applyCacheControlToAiSdkMessages( - originalMessages: Anthropic.Messages.MessageParam[], - aiSdkMessages: { role: string; providerOptions?: Record> }[], - targetOriginalIndices: Set, - cacheProviderOption: Record>, - ): void { - let aiSdkIdx = 0 - for (let origIdx = 0; origIdx < originalMessages.length; origIdx++) { - const origMsg = originalMessages[origIdx] - - if (typeof origMsg.content === "string") { - if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { - aiSdkMessages[aiSdkIdx].providerOptions = { - ...aiSdkMessages[aiSdkIdx].providerOptions, - ...cacheProviderOption, - } - } - aiSdkIdx++ - } else if (origMsg.role === "user") { - const hasToolResults = origMsg.content.some((part) => (part as { type: string }).type === "tool_result") - const hasNonToolContent = origMsg.content.some( - (part) => (part as { type: string }).type === "text" || (part as { type: string }).type === "image", - ) - - if (hasToolResults && hasNonToolContent) { - const userMsgIdx = aiSdkIdx + 1 - if (targetOriginalIndices.has(origIdx) && userMsgIdx < aiSdkMessages.length) { - aiSdkMessages[userMsgIdx].providerOptions = { - ...aiSdkMessages[userMsgIdx].providerOptions, - ...cacheProviderOption, - } - } - aiSdkIdx += 2 - } else if (hasToolResults) { - if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { - aiSdkMessages[aiSdkIdx].providerOptions = { - ...aiSdkMessages[aiSdkIdx].providerOptions, - ...cacheProviderOption, - } - } - aiSdkIdx++ - } else { - if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { - aiSdkMessages[aiSdkIdx].providerOptions = { - ...aiSdkMessages[aiSdkIdx].providerOptions, - ...cacheProviderOption, - } - } - aiSdkIdx++ - } - } else { - aiSdkIdx++ - } - } - } - - getModel() { - const modelId = this.options.apiModelId - let id = modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId - let info: ModelInfo = anthropicModels[id] - - // If 1M context beta is enabled for supported models, update the model info - if ( - (id === "claude-sonnet-4-20250514" || id === "claude-sonnet-4-5" || id === "claude-opus-4-6") && - this.options.anthropicBeta1MContext - ) { - const tier = info.tiers?.[0] - if (tier) { - info = { - ...info, - contextWindow: tier.contextWindow, - inputPrice: tier.inputPrice, - outputPrice: tier.outputPrice, - cacheWritesPrice: tier.cacheWritesPrice, - cacheReadsPrice: tier.cacheReadsPrice, - } - } - } - - const params = getModelParams({ - format: "anthropic", - modelId: id, - model: info, - settings: this.options, - defaultTemperature: 0, - }) - - // The `:thinking` suffix indicates that the model is a "Hybrid" - // reasoning model and that reasoning is required to be enabled. - // The actual model ID honored by Anthropic's API does not have this - // suffix. - return { - id: id === "claude-3-7-sonnet-20250219:thinking" ? "claude-3-7-sonnet-20250219" : id, - info, - ...params, - } - } - async completePrompt(prompt: string): Promise { - const { id, temperature } = this.getModel() + const { id: modelId, temperature } = this.getModel() + const provider = this.createProvider() + const model = provider.chat(modelId) try { const { text } = await generateText({ - model: this.provider(id), + model, prompt, - maxOutputTokens: ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, }) - return text } catch (error) { - TelemetryService.instance.captureException( - new ApiProviderError( - error instanceof Error ? error.message : String(error), - this.providerName, - id, - "completePrompt", - ), - ) + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") + TelemetryService.instance.captureException(apiError) throw handleAiSdkError(error, this.providerName) } } /** - * Returns the thinking signature captured from the last Anthropic response. - * Claude models with extended thinking return a cryptographic signature - * which must be round-tripped back for multi-turn conversations with tool use. + * Process usage metrics from the AI SDK response, including Anthropic-specific + * cache metrics from providerMetadata.anthropic. */ - getThoughtSignature(): string | undefined { - return this.lastThoughtSignature - } - - /** - * Returns any redacted thinking blocks captured from the last Anthropic response. - * Anthropic returns these when safety filters trigger on reasoning content. - */ - getRedactedThinkingBlocks(): Array<{ type: "redacted_thinking"; data: string }> | undefined { - return this.lastRedactedThinkingBlocks.length > 0 ? this.lastRedactedThinkingBlocks : undefined - } + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + }, + providerMetadata?: Record>, + modelInfo?: ModelInfo, + ): ApiStreamUsageChunk { + const anthropicMeta = providerMetadata?.anthropic as Record | undefined + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + const cacheWriteTokens = (anthropicMeta?.cacheCreationInputTokens as number) ?? 0 + const cacheReadTokens = (anthropicMeta?.cacheReadInputTokens as number) ?? 0 + + // Calculate cost using Anthropic-specific pricing (cache read/write tokens) + let totalCost: number | undefined + if (modelInfo && (inputTokens > 0 || outputTokens > 0 || cacheWriteTokens > 0 || cacheReadTokens > 0)) { + const { totalCost: cost } = calculateApiCostAnthropic( + modelInfo, + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + ) + totalCost = cost + } - override isAiSdkProvider(): boolean { - return true + return { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + totalCost, + } } } diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts deleted file mode 100644 index fc3d769ae2a..00000000000 --- a/src/api/providers/base-openai-compatible-provider.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -import type { ModelInfo } from "@roo-code/types" - -import { type ApiHandlerOptions, getModelMaxOutputTokens } from "../../shared/api" -import { TagMatcher } from "../../utils/tag-matcher" -import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" -import { convertToOpenAiMessages } from "../transform/openai-format" - -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { DEFAULT_HEADERS } from "./constants" -import { BaseProvider } from "./base-provider" -import { handleOpenAIError } from "./utils/openai-error-handler" -import { calculateApiCostOpenAI } from "../../shared/cost" -import { getApiRequestTimeout } from "./utils/timeout-config" - -type BaseOpenAiCompatibleProviderOptions = ApiHandlerOptions & { - providerName: string - baseURL: string - defaultProviderModelId: ModelName - providerModels: Record - defaultTemperature?: number -} - -export abstract class BaseOpenAiCompatibleProvider - extends BaseProvider - implements SingleCompletionHandler -{ - protected readonly providerName: string - protected readonly baseURL: string - protected readonly defaultTemperature: number - protected readonly defaultProviderModelId: ModelName - protected readonly providerModels: Record - - protected readonly options: ApiHandlerOptions - - protected client: OpenAI - - constructor({ - providerName, - baseURL, - defaultProviderModelId, - providerModels, - defaultTemperature, - ...options - }: BaseOpenAiCompatibleProviderOptions) { - super() - - this.providerName = providerName - this.baseURL = baseURL - this.defaultProviderModelId = defaultProviderModelId - this.providerModels = providerModels - this.defaultTemperature = defaultTemperature ?? 0 - - this.options = options - - if (!this.options.apiKey) { - throw new Error("API key is required") - } - - this.client = new OpenAI({ - baseURL, - apiKey: this.options.apiKey, - defaultHeaders: DEFAULT_HEADERS, - timeout: getApiRequestTimeout(), - }) - } - - protected createStream( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - requestOptions?: OpenAI.RequestOptions, - ) { - const { id: model, info } = this.getModel() - - // Centralized cap: clamp to 20% of the context window (unless provider-specific exceptions apply) - const max_tokens = - getModelMaxOutputTokens({ - modelId: model, - model: info, - settings: this.options, - format: "openai", - }) ?? undefined - - const temperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature - - const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model, - max_tokens, - temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], - stream: true, - stream_options: { include_usage: true }, - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, - } - - // Add thinking parameter if reasoning is enabled and model supports it - if (this.options.enableReasoningEffort && info.supportsReasoningBinary) { - ;(params as any).thinking = { type: "enabled" } - } - - try { - return this.client.chat.completions.create(params, requestOptions) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - } - - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - const stream = await this.createStream(systemPrompt, messages, metadata) - - const matcher = new TagMatcher( - "think", - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) - - let lastUsage: OpenAI.CompletionUsage | undefined - const activeToolCallIds = new Set() - - for await (const chunk of stream) { - // Check for provider-specific error responses (e.g., MiniMax base_resp) - const chunkAny = chunk as any - if (chunkAny.base_resp?.status_code && chunkAny.base_resp.status_code !== 0) { - throw new Error( - `${this.providerName} API Error (${chunkAny.base_resp.status_code}): ${chunkAny.base_resp.status_msg || "Unknown error"}`, - ) - } - - const delta = chunk.choices?.[0]?.delta - const finishReason = chunk.choices?.[0]?.finish_reason - - if (delta?.content) { - for (const processedChunk of matcher.update(delta.content)) { - yield processedChunk - } - } - - if (delta) { - for (const key of ["reasoning_content", "reasoning"] as const) { - if (key in delta) { - const reasoning_content = ((delta as any)[key] as string | undefined) || "" - if (reasoning_content?.trim()) { - yield { type: "reasoning", text: reasoning_content } - } - break - } - } - } - - // Emit raw tool call chunks - NativeToolCallParser handles state management - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - if (toolCall.id) { - activeToolCallIds.add(toolCall.id) - } - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - // Emit tool_call_end events when finish_reason is "tool_calls" - // This ensures tool calls are finalized even if the stream doesn't properly close - if (finishReason === "tool_calls" && activeToolCallIds.size > 0) { - for (const id of activeToolCallIds) { - yield { type: "tool_call_end", id } - } - activeToolCallIds.clear() - } - - if (chunk.usage) { - lastUsage = chunk.usage - } - } - - if (lastUsage) { - yield this.processUsageMetrics(lastUsage, this.getModel().info) - } - - // Process any remaining content - for (const processedChunk of matcher.final()) { - yield processedChunk - } - } - - protected processUsageMetrics(usage: any, modelInfo?: any): ApiStreamUsageChunk { - const inputTokens = usage?.prompt_tokens || 0 - const outputTokens = usage?.completion_tokens || 0 - const cacheWriteTokens = usage?.prompt_tokens_details?.cache_write_tokens || 0 - const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || 0 - - const { totalCost } = modelInfo - ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) - : { totalCost: 0 } - - return { - type: "usage", - inputTokens, - outputTokens, - cacheWriteTokens: cacheWriteTokens || undefined, - cacheReadTokens: cacheReadTokens || undefined, - totalCost, - } - } - - async completePrompt(prompt: string): Promise { - const { id: modelId, info: modelInfo } = this.getModel() - - const params: OpenAI.Chat.Completions.ChatCompletionCreateParams = { - model: modelId, - messages: [{ role: "user", content: prompt }], - } - - // Add thinking parameter if reasoning is enabled and model supports it - if (this.options.enableReasoningEffort && modelInfo.supportsReasoningBinary) { - ;(params as any).thinking = { type: "enabled" } - } - - try { - const response = await this.client.chat.completions.create(params) - - // Check for provider-specific error responses (e.g., MiniMax base_resp) - const responseAny = response as any - if (responseAny.base_resp?.status_code && responseAny.base_resp.status_code !== 0) { - throw new Error( - `${this.providerName} API Error (${responseAny.base_resp.status_code}): ${responseAny.base_resp.status_msg || "Unknown error"}`, - ) - } - - return response.choices?.[0]?.message.content || "" - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - } - - override getModel() { - const id = - this.options.apiModelId && this.options.apiModelId in this.providerModels - ? (this.options.apiModelId as ModelName) - : this.defaultProviderModelId - - return { id, info: this.providerModels[id] } - } -} diff --git a/src/api/providers/base-provider.ts b/src/api/providers/base-provider.ts index 817af53a494..073d835ef0f 100644 --- a/src/api/providers/base-provider.ts +++ b/src/api/providers/base-provider.ts @@ -1,7 +1,6 @@ -import { Anthropic } from "@anthropic-ai/sdk" - import type { ModelInfo } from "@roo-code/types" +import type { NeutralMessageParam, NeutralContentBlock } from "../../core/task-persistence" import type { ApiHandler, ApiHandlerCreateMessageMetadata } from "../index" import { ApiStream } from "../transform/stream" import { countTokens } from "../../utils/countTokens" @@ -13,7 +12,7 @@ import { isMcpTool } from "../../utils/mcp-name" export abstract class BaseProvider implements ApiHandler { abstract createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream @@ -112,7 +111,7 @@ export abstract class BaseProvider implements ApiHandler { * @param content The content to count tokens for * @returns A promise resolving to the token count */ - async countTokens(content: Anthropic.Messages.ContentBlockParam[]): Promise { + async countTokens(content: NeutralContentBlock[]): Promise { if (content.length === 0) { return 0 } diff --git a/src/api/providers/baseten.ts b/src/api/providers/baseten.ts index 2e63f3d52c1..fd42cc313c4 100644 --- a/src/api/providers/baseten.ts +++ b/src/api/providers/baseten.ts @@ -1,9 +1,9 @@ -import { Anthropic } from "@anthropic-ai/sdk" import { createBaseten } from "@ai-sdk/baseten" import { streamText, generateText, ToolSet } from "ai" import { basetenModels, basetenDefaultModelId, type ModelInfo } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" import { @@ -94,7 +94,7 @@ export class BasetenHandler extends BaseProvider implements SingleCompletionHand */ override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { temperature } = this.getModel() diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 375dd2c0421..c211a986ed4 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -1,4 +1,3 @@ -import type { Anthropic } from "@anthropic-ai/sdk" import { createAmazonBedrock, type AmazonBedrockProvider } from "@ai-sdk/amazon-bedrock" import { streamText, generateText, ToolSet } from "ai" import { fromIni } from "@aws-sdk/credential-providers" @@ -32,6 +31,7 @@ import { handleAiSdkError, } from "../transform/ai-sdk" import { getModelParams } from "../transform/model-params" +import type { NeutralMessageParam } from "../../core/task-persistence" import { shouldUseReasoningBudget } from "../../shared/api" import { BaseProvider } from "./base-provider" import { DEFAULT_HEADERS } from "./constants" @@ -188,7 +188,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const modelConfig = this.getModel() @@ -200,7 +200,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH // 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): message is Anthropic.Messages.MessageParam => { + const filteredMessages = messages.filter((message): message is NeutralMessageParam => { const meta = message as ReasoningMetaLike if (meta.type === "reasoning") { return false @@ -735,7 +735,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH * accounts for that split so cache points land on the right message. */ private applyCachePointsToAiSdkMessages( - originalMessages: Anthropic.Messages.MessageParam[], + originalMessages: NeutralMessageParam[], aiSdkMessages: { role: string; providerOptions?: Record> }[], targetOriginalIndices: Set, cachePointOption: Record>, diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index aa1af804eaf..d6abaf20955 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -1,9 +1,9 @@ -import { Anthropic } from "@anthropic-ai/sdk" import { createDeepSeek } from "@ai-sdk/deepseek" import { streamText, generateText, ToolSet } from "ai" import { deepSeekModels, deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" import { @@ -109,7 +109,7 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan */ override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { temperature } = this.getModel() diff --git a/src/api/providers/fake-ai.ts b/src/api/providers/fake-ai.ts index b6bb9fd2c34..51a7b8dffb5 100644 --- a/src/api/providers/fake-ai.ts +++ b/src/api/providers/fake-ai.ts @@ -1,7 +1,6 @@ -import { Anthropic } from "@anthropic-ai/sdk" - import type { ModelInfo } from "@roo-code/types" +import type { NeutralMessageParam, NeutralContentBlock } from "../../core/task-persistence" import type { ApiHandler, SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" @@ -23,11 +22,11 @@ interface FakeAI { createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream getModel(): { id: string; info: ModelInfo } - countTokens(content: Array): Promise + countTokens(content: NeutralContentBlock[]): Promise completePrompt(prompt: string): Promise } @@ -61,7 +60,7 @@ export class FakeAIHandler implements ApiHandler, SingleCompletionHandler { async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { yield* this.ai.createMessage(systemPrompt, messages, metadata) @@ -71,7 +70,7 @@ export class FakeAIHandler implements ApiHandler, SingleCompletionHandler { return this.ai.getModel() } - countTokens(content: Array): Promise { + countTokens(content: NeutralContentBlock[]): Promise { return this.ai.countTokens(content) } diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index bc5560bfbbf..3c88e0a09e9 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -1,9 +1,9 @@ -import { Anthropic } from "@anthropic-ai/sdk" import { createFireworks } from "@ai-sdk/fireworks" import { streamText, generateText, ToolSet } from "ai" import { fireworksModels, fireworksDefaultModelId, type ModelInfo } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" import { @@ -109,7 +109,7 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa */ override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { temperature } = this.getModel() diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index f7ebfdeeb9e..983bce5cdfd 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -1,4 +1,3 @@ -import type { Anthropic } from "@anthropic-ai/sdk" import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google" import { streamText, generateText, NoOutputGeneratedError, ToolSet } from "ai" @@ -11,6 +10,7 @@ import { } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" import { @@ -50,7 +50,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl async *createMessage( systemInstruction: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { id: modelId, info, reasoning: thinkingConfig, maxTokens } = this.getModel() @@ -77,10 +77,10 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl // 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. + // NeutralMessageParam values and will cause failures. type ReasoningMetaLike = { type?: string } - const filteredMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => { + const filteredMessages = messages.filter((message): message is NeutralMessageParam => { const meta = message as ReasoningMetaLike if (meta.type === "reasoning") { return false diff --git a/src/api/providers/lite-llm.ts b/src/api/providers/lite-llm.ts index cf8d16a1129..93a91d145d9 100644 --- a/src/api/providers/lite-llm.ts +++ b/src/api/providers/lite-llm.ts @@ -1,38 +1,54 @@ -import OpenAI from "openai" -import { Anthropic } from "@anthropic-ai/sdk" // Keep for type usage only +import { streamText, generateText, ToolSet } from "ai" -import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types" +import { litellmDefaultModelId, litellmDefaultModelInfo, type ModelInfo, type ModelRecord } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" +import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" -import { ApiHandlerOptions } from "../../shared/api" - +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" -import { convertToOpenAiMessages } from "../transform/openai-format" +import { getModelParams } from "../transform/model-params" import { sanitizeOpenAiCallId } from "../../utils/tool-id" -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { RouterProvider } from "./router-provider" +import { OpenAICompatibleHandler, type OpenAICompatibleConfig } from "./openai-compatible" +import type { ApiHandlerCreateMessageMetadata } from "../index" +import { getModels, getModelsFromCache } from "./fetchers/modelCache" /** - * LiteLLM provider handler + * LiteLLM provider handler using AI SDK. * - * This handler uses the LiteLLM API to proxy requests to various LLM providers. - * It follows the OpenAI API format for compatibility. + * Uses @ai-sdk/openai-compatible with transformRequestBody to handle + * LiteLLM-specific wire-format requirements: prompt caching, Gemini + * thought signatures, GPT-5 max_completion_tokens, and tool ID normalization. */ -export class LiteLLMHandler extends RouterProvider implements SingleCompletionHandler { +export class LiteLLMHandler extends OpenAICompatibleHandler { + private models: ModelRecord = {} + constructor(options: ApiHandlerOptions) { - super({ - options, - name: "litellm", - baseURL: `${options.litellmBaseUrl || "http://localhost:4000"}`, + const modelId = options.litellmModelId ?? litellmDefaultModelId + const cached = getModelsFromCache("litellm") + const modelInfo = (cached && modelId && cached[modelId]) || litellmDefaultModelInfo + + const config: OpenAICompatibleConfig = { + providerName: "litellm", + baseURL: options.litellmBaseUrl || "http://localhost:4000", apiKey: options.litellmApiKey || "dummy-key", - modelId: options.litellmModelId, - defaultModelId: litellmDefaultModelId, - defaultModelInfo: litellmDefaultModelInfo, - }) + modelId, + modelInfo, + } + + super(options, config) } + // ── Helper methods ────────────────────────────────────────────── + private isGpt5(modelId: string): boolean { // Match gpt-5, gpt5, and variants like gpt-5o, gpt-5-turbo, gpt5-preview, gpt-5.1 // Avoid matching gpt-50, gpt-500, etc. @@ -44,64 +60,86 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa * Gemini 3 models validate thought signatures for tool/function calling steps. */ private isGeminiModel(modelId: string): boolean { - // Match various Gemini model patterns: - // - gemini-3-pro, gemini-3-flash, gemini-3-* - // - gemini 3 pro, Gemini 3 Pro (space-separated, case-insensitive) - // - gemini/gemini-3-*, google/gemini-3-* - // - vertex_ai/gemini-3-*, vertex/gemini-3-* - // Also match Gemini 2.5+ models which use similar validation const lowerModelId = modelId.toLowerCase() return ( - // Match hyphenated versions: gemini-3, gemini-2.5 lowerModelId.includes("gemini-3") || lowerModelId.includes("gemini-2.5") || - // Match space-separated versions: "gemini 3", "gemini 2.5" - // This handles model names like "Gemini 3 Pro" from LiteLLM model groups lowerModelId.includes("gemini 3") || lowerModelId.includes("gemini 2.5") || - // Also match provider-prefixed versions /\b(gemini|google|vertex_ai|vertex)\/gemini[-\s](3|2\.5)/i.test(modelId) ) } /** * Inject thought signatures for Gemini models via provider_specific_fields. - * This is required when switching from other models to Gemini to satisfy API validation - * for function calls that weren't generated by Gemini (and thus lack thought signatures). + * Operates on OpenAI-format messages in the wire request body. * * Per LiteLLM documentation: - * - Thought signatures are stored in provider_specific_fields.thought_signature of tool calls * - The dummy signature base64("skip_thought_signature_validator") bypasses validation - * - * We inject the dummy signature on EVERY tool call unconditionally to ensure Gemini - * doesn't complain about missing/corrupted signatures when conversation history - * contains tool calls from other models (like Claude). + * - Injected on EVERY tool call to ensure Gemini doesn't reject tool calls from other models */ - private injectThoughtSignatureForGemini( - openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[], - ): OpenAI.Chat.ChatCompletionMessageParam[] { - // Base64 encoded "skip_thought_signature_validator" as per LiteLLM docs + private injectThoughtSignatureForGemini(messages: Record[]): Record[] { const dummySignature = Buffer.from("skip_thought_signature_validator").toString("base64") - return openAiMessages.map((msg) => { + return messages.map((msg) => { if (msg.role === "assistant") { - const toolCalls = (msg as any).tool_calls as any[] | undefined - - // Only process if there are tool calls + const toolCalls = msg.tool_calls as Record[] | undefined if (toolCalls && toolCalls.length > 0) { - // Inject dummy signature into ALL tool calls' provider_specific_fields - // This ensures Gemini doesn't reject tool calls from other models const updatedToolCalls = toolCalls.map((tc) => ({ ...tc, provider_specific_fields: { - ...(tc.provider_specific_fields || {}), + ...((tc.provider_specific_fields as Record) || {}), thought_signature: dummySignature, }, })) + return { ...msg, tool_calls: updatedToolCalls } + } + } + return msg + }) + } + + /** + * Apply prompt caching to wire-format messages. + * Adds cache_control: { type: "ephemeral" } to system message and last 2 user messages. + */ + private applyPromptCaching(messages: Record[]): Record[] { + const result = messages.map((msg, index) => { + // Apply cache control to system message (always first) + if (index === 0 && msg.role === "system") { + const content = + typeof msg.content === "string" + ? [{ type: "text", text: msg.content, cache_control: { type: "ephemeral" } }] + : msg.content + return { ...msg, content } + } + return msg + }) + + // Find last 2 user messages and apply cache control + const userMsgIndices = result.reduce( + (acc: number[], msg, index) => (msg.role === "user" ? [...acc, index] : acc), + [], + ) + const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 + const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 + return result.map((msg, index) => { + if ((index === lastUserMsgIndex || index === secondLastUserMsgIndex) && msg.role === "user") { + if (typeof msg.content === "string") { return { ...msg, - tool_calls: updatedToolCalls, + content: [{ type: "text", text: msg.content, cache_control: { type: "ephemeral" } }], + } + } else if (Array.isArray(msg.content)) { + return { + ...msg, + content: (msg.content as Record[]).map( + (content: Record, contentIndex: number) => + contentIndex === (msg.content as unknown[]).length - 1 + ? { ...content, cache_control: { type: "ephemeral" } } + : content, + ), } } } @@ -109,223 +147,207 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa }) } - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - const { id: modelId, info } = await this.fetchModel() - - const openAiMessages = convertToOpenAiMessages(messages, { - normalizeToolCallId: sanitizeOpenAiCallId, + /** + * Sanitize tool call IDs in wire-format messages for Bedrock compatibility. + */ + private sanitizeToolCallIds(messages: Record[]): Record[] { + return messages.map((msg) => { + if (msg.role === "assistant" && msg.tool_calls) { + return { + ...msg, + tool_calls: (msg.tool_calls as Record[]).map((tc) => ({ + ...tc, + id: sanitizeOpenAiCallId(tc.id as string), + })), + } + } + if (msg.role === "tool" && msg.tool_call_id) { + return { + ...msg, + tool_call_id: sanitizeOpenAiCallId(msg.tool_call_id as string), + } + } + return msg }) + } - // Prepare messages with cache control if enabled and supported - let systemMessage: OpenAI.Chat.ChatCompletionMessageParam - let enhancedMessages: OpenAI.Chat.ChatCompletionMessageParam[] - - if (this.options.litellmUsePromptCache && info.supportsPromptCache) { - // Create system message with cache control in the proper format - systemMessage = { - role: "system", - content: [ - { - type: "text", - text: systemPrompt, - cache_control: { type: "ephemeral" }, - } as any, - ], - } + // ── Model resolution ──────────────────────────────────────────── - // Find the last two user messages to apply caching - const userMsgIndices = openAiMessages.reduce( - (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), - [] as number[], - ) - const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 - const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 - - // Apply cache_control to the last two user messages - enhancedMessages = openAiMessages.map((message, index) => { - if ((index === lastUserMsgIndex || index === secondLastUserMsgIndex) && message.role === "user") { - // Handle both string and array content types - if (typeof message.content === "string") { - return { - ...message, - content: [ - { - type: "text", - text: message.content, - cache_control: { type: "ephemeral" }, - } as any, - ], - } - } else if (Array.isArray(message.content)) { - // Apply cache control to the last content item in the array - return { - ...message, - content: message.content.map((content, contentIndex) => - contentIndex === message.content.length - 1 - ? ({ - ...content, - cache_control: { type: "ephemeral" }, - } as any) - : content, - ), - } - } - } - return message - }) - } else { - // No cache control - use simple format - systemMessage = { role: "system", content: systemPrompt } - enhancedMessages = openAiMessages - } + public async fetchModel() { + this.models = await getModels({ + provider: "litellm", + apiKey: this.config.apiKey, + baseUrl: this.config.baseURL, + }) + const model = this.getModel() + this.config.modelInfo = model.info + return model + } - // Required by some providers; others default to max tokens allowed - let maxTokens: number | undefined = info.maxTokens ?? undefined + override getModel() { + const id = this.options.litellmModelId ?? litellmDefaultModelId + const cached = getModelsFromCache("litellm") + const info: ModelInfo = (cached && id && cached[id]) || this.models[id] || litellmDefaultModelInfo + + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) + + return { id, info, ...params } + } - // Check if this is a GPT-5 model that requires max_completion_tokens instead of max_tokens - const isGPT5Model = this.isGpt5(modelId) + // ── Custom language model with transformRequestBody ───────────── - // For Gemini models with native protocol: inject fake reasoning.encrypted block for tool calls - // This is required when switching from other models to Gemini to satisfy API validation. - // Gemini 3 models validate thought signatures for function calls, and when conversation - // history contains tool calls from other models (like Claude), they lack the required - // signatures. The "skip_thought_signature_validator" value bypasses this validation. + /** + * Create a language model with transformRequestBody for LiteLLM-specific + * wire-format modifications (GPT-5 tokens, Gemini signatures, caching, tool IDs). + */ + private createLiteLLMModel(modelId: string) { const isGemini = this.isGeminiModel(modelId) - let processedMessages = enhancedMessages - if (isGemini) { - processedMessages = this.injectThoughtSignatureForGemini(enhancedMessages) - } + const isGPT5 = this.isGpt5(modelId) + const usePromptCache = !!(this.options.litellmUsePromptCache && this.config.modelInfo.supportsPromptCache) - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: modelId, - messages: [systemMessage, ...processedMessages], - stream: true, - stream_options: { - include_usage: true, - }, - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - } + return this.provider.languageModel(modelId, { + transformRequestBody: (body: Record) => { + const modified = { ...body } - // GPT-5 models require max_completion_tokens instead of the deprecated max_tokens parameter - if (isGPT5Model && maxTokens) { - requestOptions.max_completion_tokens = maxTokens - } else if (maxTokens) { - requestOptions.max_tokens = maxTokens - } + // GPT-5: use max_completion_tokens instead of max_tokens + if (isGPT5 && modified.max_tokens !== undefined) { + modified.max_completion_tokens = modified.max_tokens + delete modified.max_tokens + } - if (this.supportsTemperature(modelId)) { - requestOptions.temperature = this.options.modelTemperature ?? 0 - } + if (modified.messages && Array.isArray(modified.messages)) { + let messages = [...(modified.messages as Record[])] - try { - const { data: completion } = await this.client.chat.completions.create(requestOptions).withResponse() + // Sanitize tool call IDs for Bedrock compatibility + messages = this.sanitizeToolCallIds(messages) - let lastUsage + // Inject thought signatures for Gemini models + if (isGemini) { + messages = this.injectThoughtSignatureForGemini(messages) + } - for await (const chunk of completion) { - const delta = chunk.choices[0]?.delta - const usage = chunk.usage as LiteLLMUsage + // Apply prompt caching + if (usePromptCache) { + messages = this.applyPromptCaching(messages) + } - if (delta?.content) { - yield { type: "text", text: delta.content } + modified.messages = messages } - // Handle tool calls in stream - emit partial chunks for NativeToolCallParser - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } + return modified + }, + }) + } - if (usage) { - lastUsage = usage - } - } + // ── API methods ───────────────────────────────────────────────── - if (lastUsage) { - // Extract cache-related information if available - // LiteLLM may use different field names for cache tokens - const cacheWriteTokens = - lastUsage.cache_creation_input_tokens || (lastUsage as any).prompt_cache_miss_tokens || 0 - const cacheReadTokens = - lastUsage.prompt_tokens_details?.cached_tokens || - (lastUsage as any).cache_read_input_tokens || - (lastUsage as any).prompt_cache_hit_tokens || - 0 - - const { totalCost } = calculateApiCostOpenAI( - info, - lastUsage.prompt_tokens || 0, - lastUsage.completion_tokens || 0, - cacheWriteTokens, - cacheReadTokens, - ) - - const usageData: ApiStreamUsageChunk = { - type: "usage", - inputTokens: lastUsage.prompt_tokens || 0, - outputTokens: lastUsage.completion_tokens || 0, - cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, - cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, - totalCost, + override async *createMessage( + systemPrompt: string, + messages: NeutralMessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + await this.fetchModel() + const model = this.getModel() + const languageModel = this.createLiteLLMModel(model.id) + + const aiSdkMessages = convertToAiSdkMessages(messages) + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const result = streamText({ + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: model.temperature ?? 0, + maxOutputTokens: model.maxTokens, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + }) + + try { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk } + } - yield usageData + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) } } catch (error) { - if (error instanceof Error) { - throw new Error(`LiteLLM streaming error: ${error.message}`) - } - throw error + throw handleAiSdkError(error, this.config.providerName) } } - async completePrompt(prompt: string): Promise { - const { id: modelId, info } = await this.fetchModel() + override async completePrompt(prompt: string): Promise { + await this.fetchModel() + const model = this.getModel() + const languageModel = this.createLiteLLMModel(model.id) - // Check if this is a GPT-5 model that requires max_completion_tokens instead of max_tokens - const isGPT5Model = this.isGpt5(modelId) - - try { - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: modelId, - messages: [{ role: "user", content: prompt }], - } + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: model.maxTokens, + temperature: model.temperature ?? 0, + }) - if (this.supportsTemperature(modelId)) { - requestOptions.temperature = this.options.modelTemperature ?? 0 - } + return text + } - // GPT-5 models require max_completion_tokens instead of the deprecated max_tokens parameter - if (isGPT5Model && info.maxTokens) { - requestOptions.max_completion_tokens = info.maxTokens - } else if (info.maxTokens) { - requestOptions.max_tokens = info.maxTokens - } + // ── Usage metrics ─────────────────────────────────────────────── + + protected override processUsageMetrics(usage: { + inputTokens?: number + outputTokens?: number + details?: { cachedInputTokens?: number; reasoningTokens?: number } + raw?: Record + }): ApiStreamUsageChunk { + const rawUsage = usage.raw as LiteLLMRawUsage | undefined + + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + const cacheWriteTokens = rawUsage?.cache_creation_input_tokens || rawUsage?.prompt_cache_miss_tokens || 0 + const cacheReadTokens = + rawUsage?.prompt_tokens_details?.cached_tokens || + rawUsage?.cache_read_input_tokens || + rawUsage?.prompt_cache_hit_tokens || + usage.details?.cachedInputTokens || + 0 + + const modelInfo = this.getModel().info + const { totalCost } = calculateApiCostOpenAI( + modelInfo, + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + ) - const response = await this.client.chat.completions.create(requestOptions) - return response.choices[0]?.message.content || "" - } catch (error) { - if (error instanceof Error) { - throw new Error(`LiteLLM completion error: ${error.message}`) - } - throw error + return { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + totalCost, } } } -// LiteLLM usage may include an extra field for Anthropic use cases. -interface LiteLLMUsage extends OpenAI.CompletionUsage { +/** LiteLLM raw usage data with cache-related fields */ +interface LiteLLMRawUsage { + prompt_tokens?: number + completion_tokens?: number cache_creation_input_tokens?: number + cache_read_input_tokens?: number + prompt_cache_miss_tokens?: number + prompt_cache_hit_tokens?: number + prompt_tokens_details?: { cached_tokens?: number } } diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index a771394c535..cee4ec06616 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -1,217 +1,119 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { streamText, generateText, ToolSet } from "ai" import axios from "axios" import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATURE } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" -import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" -import { TagMatcher } from "../../utils/tag-matcher" - -import { convertToOpenAiMessages } from "../transform/openai-format" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" +import { getModelParams } from "../transform/model-params" -import { BaseProvider } from "./base-provider" -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { OpenAICompatibleHandler, type OpenAICompatibleConfig } from "./openai-compatible" +import type { ApiHandlerCreateMessageMetadata } from "../index" import { getModelsFromCache } from "./fetchers/modelCache" -import { getApiRequestTimeout } from "./utils/timeout-config" -import { handleOpenAIError } from "./utils/openai-error-handler" - -export class LmStudioHandler extends BaseProvider implements SingleCompletionHandler { - protected options: ApiHandlerOptions - private client: OpenAI - private readonly providerName = "LM Studio" +export class LmStudioHandler extends OpenAICompatibleHandler { constructor(options: ApiHandlerOptions) { - super() - this.options = options + const modelId = options.lmStudioModelId || "" + const models = getModelsFromCache("lmstudio") + const modelInfo = (models && modelId && models[modelId]) || openAiModelInfoSaneDefaults + + const config: OpenAICompatibleConfig = { + providerName: "lmstudio", + baseURL: (options.lmStudioBaseUrl || "http://localhost:1234") + "/v1", + apiKey: "noop", + modelId, + modelInfo, + } - // LM Studio uses "noop" as a placeholder API key - const apiKey = "noop" + super(options, config) + } - this.client = new OpenAI({ - baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1", - apiKey: apiKey, - timeout: getApiRequestTimeout(), + override getModel() { + const models = getModelsFromCache("lmstudio") + const id = this.options.lmStudioModelId || "" + const info = (models && id && models[id]) || openAiModelInfoSaneDefaults + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: LMSTUDIO_DEFAULT_TEMPERATURE, }) + return { id, info, ...params } + } + + private get speculativeDecodingProviderOptions() { + if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) { + return { lmstudio: { draft_model: this.options.lmStudioDraftModelId } } as Record + } + return undefined } override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), - ] - - // ------------------------- - // Track token usage - // ------------------------- - const toContentBlocks = ( - blocks: Anthropic.Messages.MessageParam[] | string, - ): Anthropic.Messages.ContentBlockParam[] => { - if (typeof blocks === "string") { - return [{ type: "text", text: blocks }] - } - - const result: Anthropic.Messages.ContentBlockParam[] = [] - for (const msg of blocks) { - if (typeof msg.content === "string") { - result.push({ type: "text", text: msg.content }) - } else if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === "text") { - result.push({ type: "text", text: part.text }) - } - } - } - } - return result - } - - let inputTokens = 0 - try { - inputTokens = await this.countTokens([{ type: "text", text: systemPrompt }, ...toContentBlocks(messages)]) - } catch (err) { - console.error("[LmStudio] Failed to count input tokens:", err) - inputTokens = 0 + const providerOptions = this.speculativeDecodingProviderOptions + if (!providerOptions) { + yield* super.createMessage(systemPrompt, messages, metadata) + return } - let assistantText = "" + const model = this.getModel() + const aiSdkMessages = convertToAiSdkMessages(messages) + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const result = streamText({ + model: this.getLanguageModel(), + system: systemPrompt, + messages: aiSdkMessages, + temperature: model.temperature ?? 0, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + providerOptions: providerOptions as any, + }) try { - const params: OpenAI.Chat.ChatCompletionCreateParamsStreaming & { draft_model?: string } = { - model: this.getModel().id, - messages: openAiMessages, - temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, - stream: true, - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, - } - - if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) { - params.draft_model = this.options.lmStudioDraftModelId - } - - let results - try { - results = await this.client.chat.completions.create(params) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - - const matcher = new TagMatcher( - "think", - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) - - for await (const chunk of results) { - const delta = chunk.choices[0]?.delta - const finishReason = chunk.choices[0]?.finish_reason - - if (delta?.content) { - assistantText += delta.content - for (const processedChunk of matcher.update(delta.content)) { - yield processedChunk - } - } - - // Handle tool calls in stream - emit partial chunks for NativeToolCallParser - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - // Process finish_reason to emit tool_call_end events - if (finishReason) { - const endEvents = NativeToolCallParser.processFinishReason(finishReason) - for (const event of endEvents) { - yield event - } + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk } } - - for (const processedChunk of matcher.final()) { - yield processedChunk + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) } - - let outputTokens = 0 - try { - outputTokens = await this.countTokens([{ type: "text", text: assistantText }]) - } catch (err) { - console.error("[LmStudio] Failed to count output tokens:", err) - outputTokens = 0 - } - - yield { - type: "usage", - inputTokens, - outputTokens, - } as const } catch (error) { - throw new Error( - "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Roo Code's prompts.", - ) + throw handleAiSdkError(error, this.config.providerName) } } - override getModel(): { id: string; info: ModelInfo } { - const models = getModelsFromCache("lmstudio") - if (models && this.options.lmStudioModelId && models[this.options.lmStudioModelId]) { - return { - id: this.options.lmStudioModelId, - info: models[this.options.lmStudioModelId], - } - } else { - return { - id: this.options.lmStudioModelId || "", - info: openAiModelInfoSaneDefaults, - } + override async completePrompt(prompt: string): Promise { + const providerOptions = this.speculativeDecodingProviderOptions + if (!providerOptions) { + return super.completePrompt(prompt) } - } - - async completePrompt(prompt: string): Promise { - try { - // Create params object with optional draft model - const params: any = { - model: this.getModel().id, - messages: [{ role: "user", content: prompt }], - temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, - stream: false, - } - // Add draft model if speculative decoding is enabled and a draft model is specified - if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) { - params.draft_model = this.options.lmStudioDraftModelId - } - - let response - try { - response = await this.client.chat.completions.create(params) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - return response.choices[0]?.message.content || "" - } catch (error) { - throw new Error( - "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Roo Code's prompts.", - ) - } + const { text } = await generateText({ + model: this.getLanguageModel(), + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.config.temperature ?? 0, + providerOptions: providerOptions as any, + }) + return text } } diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index bfcf4e3be40..ed02b58c1b7 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -1,306 +1,50 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming" -import { CacheControlEphemeral } from "@anthropic-ai/sdk/resources" -import OpenAI from "openai" - -import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types" +import { minimaxModels, minimaxDefaultModelId } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { mergeEnvironmentDetailsForMiniMax } from "../transform/minimax-format" - -import { BaseProvider } from "./base-provider" -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { calculateApiCostAnthropic } from "../../shared/cost" -import { convertOpenAIToolsToAnthropic } from "../../core/prompts/tools/native-tools/converters" -/** - * Converts OpenAI tool_choice to Anthropic ToolChoice format - */ -function convertOpenAIToolChoice( - toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"], -): Anthropic.Messages.MessageCreateParams["tool_choice"] | undefined { - if (!toolChoice) { - return undefined - } - - if (typeof toolChoice === "string") { - switch (toolChoice) { - case "none": - return undefined // Anthropic doesn't have "none", just omit tools - case "auto": - return { type: "auto" } - case "required": - return { type: "any" } - default: - return { type: "auto" } - } - } - - // Handle object form { type: "function", function: { name: string } } - if (typeof toolChoice === "object" && "function" in toolChoice) { - return { - type: "tool", - name: toolChoice.function.name, - } - } - - return { type: "auto" } -} - -export class MiniMaxHandler extends BaseProvider implements SingleCompletionHandler { - private options: ApiHandlerOptions - private client: Anthropic +import { OpenAICompatibleHandler, type OpenAICompatibleConfig } from "./openai-compatible" +export class MiniMaxHandler extends OpenAICompatibleHandler { constructor(options: ApiHandlerOptions) { - super() - this.options = options - - // Use Anthropic-compatible endpoint - // Default to international endpoint: https://api.minimax.io/anthropic - // China endpoint: https://api.minimaxi.com/anthropic - let baseURL = options.minimaxBaseUrl || "https://api.minimax.io/anthropic" - - // If user provided a /v1 endpoint, convert to /anthropic - if (baseURL.endsWith("/v1")) { - baseURL = baseURL.replace(/\/v1$/, "/anthropic") - } else if (!baseURL.endsWith("/anthropic")) { - baseURL = `${baseURL.replace(/\/$/, "")}/anthropic` - } - - this.client = new Anthropic({ + const modelId = options.apiModelId ?? minimaxDefaultModelId + const modelInfo = minimaxModels[modelId as keyof typeof minimaxModels] || minimaxModels[minimaxDefaultModelId] + + // MiniMax exposes an OpenAI-compatible API at /v1. + // International: https://api.minimax.io/v1 + // China: https://api.minimaxi.com/v1 + const rawBase = options.minimaxBaseUrl || "https://api.minimax.io" + const baseURL = rawBase + ? `${rawBase + .replace(/\/+$/, "") + .replace(/\/v1$/, "") + .replace(/\/anthropic$/, "")}/v1` + : "https://api.minimax.io/v1" + + const config: OpenAICompatibleConfig = { + providerName: "minimax", baseURL, - apiKey: options.minimaxApiKey, - }) - } - - async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - let stream: AnthropicStream - const cacheControl: CacheControlEphemeral = { type: "ephemeral" } - const { id: modelId, info, maxTokens, temperature } = this.getModel() - - // MiniMax M2 models support prompt caching - const supportsPromptCache = info.supportsPromptCache ?? false - - // Merge environment_details from messages that follow tool_result blocks - // into the tool_result content. This preserves reasoning continuity for - // thinking models by preventing user messages from interrupting the - // reasoning context after tool use (similar to r1-format's mergeToolResultText). - const processedMessages = mergeEnvironmentDetailsForMiniMax(messages) - - // Build the system blocks array - const systemBlocks: Anthropic.Messages.TextBlockParam[] = [ - supportsPromptCache - ? { text: systemPrompt, type: "text", cache_control: cacheControl } - : { text: systemPrompt, type: "text" }, - ] - - // Prepare request parameters - const requestParams: Anthropic.Messages.MessageCreateParams = { - model: modelId, - max_tokens: maxTokens ?? 16_384, - temperature: temperature ?? 1.0, - system: systemBlocks, - messages: supportsPromptCache ? this.addCacheControl(processedMessages, cacheControl) : processedMessages, - stream: true, - tools: convertOpenAIToolsToAnthropic(metadata?.tools ?? []), - tool_choice: convertOpenAIToolChoice(metadata?.tool_choice), + apiKey: options.minimaxApiKey || "not-provided", + modelId, + modelInfo, + modelMaxTokens: options.modelMaxTokens ?? undefined, + temperature: options.modelTemperature ?? undefined, } - stream = await this.client.messages.create(requestParams) - - let inputTokens = 0 - let outputTokens = 0 - let cacheWriteTokens = 0 - let cacheReadTokens = 0 - - for await (const chunk of stream) { - switch (chunk.type) { - case "message_start": { - // Tells us cache reads/writes/input/output. - const { - input_tokens = 0, - output_tokens = 0, - cache_creation_input_tokens, - cache_read_input_tokens, - } = chunk.message.usage - - yield { - type: "usage", - inputTokens: input_tokens, - outputTokens: output_tokens, - cacheWriteTokens: cache_creation_input_tokens || undefined, - cacheReadTokens: cache_read_input_tokens || undefined, - } - - inputTokens += input_tokens - outputTokens += output_tokens - cacheWriteTokens += cache_creation_input_tokens || 0 - cacheReadTokens += cache_read_input_tokens || 0 - - break - } - case "message_delta": - // Tells us stop_reason, stop_sequence, and output tokens - yield { - type: "usage", - inputTokens: 0, - outputTokens: chunk.usage.output_tokens || 0, - } - - break - case "message_stop": - // No usage data, just an indicator that the message is done. - break - case "content_block_start": - switch (chunk.content_block.type) { - case "thinking": - // Yield thinking/reasoning content - if (chunk.index > 0) { - yield { type: "reasoning", text: "\n" } - } - - yield { type: "reasoning", text: chunk.content_block.thinking } - break - case "text": - // We may receive multiple text blocks - if (chunk.index > 0) { - yield { type: "text", text: "\n" } - } - - yield { type: "text", text: chunk.content_block.text } - break - case "tool_use": { - // Emit initial tool call partial with id and name - yield { - type: "tool_call_partial", - index: chunk.index, - id: chunk.content_block.id, - name: chunk.content_block.name, - arguments: undefined, - } - break - } - } - break - case "content_block_delta": - switch (chunk.delta.type) { - case "thinking_delta": - yield { type: "reasoning", text: chunk.delta.thinking } - break - case "text_delta": - yield { type: "text", text: chunk.delta.text } - break - case "input_json_delta": { - // Emit tool call partial chunks as arguments stream in - yield { - type: "tool_call_partial", - index: chunk.index, - id: undefined, - name: undefined, - arguments: chunk.delta.partial_json, - } - break - } - } - - break - case "content_block_stop": - // Block is complete - no action needed, NativeToolCallParser handles completion - break - } - } - - // Calculate and yield final cost - if (inputTokens > 0 || outputTokens > 0 || cacheWriteTokens > 0 || cacheReadTokens > 0) { - const { totalCost } = calculateApiCostAnthropic( - this.getModel().info, - inputTokens, - outputTokens, - cacheWriteTokens, - cacheReadTokens, - ) - - yield { - type: "usage", - inputTokens: 0, - outputTokens: 0, - totalCost, - } - } - } - - /** - * Add cache control to the last two user messages for prompt caching - */ - private addCacheControl( - messages: Anthropic.Messages.MessageParam[], - cacheControl: CacheControlEphemeral, - ): Anthropic.Messages.MessageParam[] { - const userMsgIndices = messages.reduce( - (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), - [] as number[], - ) - - const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 - const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 - - return messages.map((message, index) => { - if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) { - return { - ...message, - content: - typeof message.content === "string" - ? [{ type: "text", text: message.content, cache_control: cacheControl }] - : message.content.map((content, contentIndex) => - contentIndex === message.content.length - 1 - ? { ...content, cache_control: cacheControl } - : content, - ), - } - } - return message - }) + super(options, config) } - getModel() { - const modelId = this.options.apiModelId - const id = modelId && modelId in minimaxModels ? (modelId as MinimaxModelId) : minimaxDefaultModelId - const info = minimaxModels[id] - + override getModel() { + const id = this.options.apiModelId ?? minimaxDefaultModelId + const info = minimaxModels[id as keyof typeof minimaxModels] || minimaxModels[minimaxDefaultModelId] const params = getModelParams({ - format: "anthropic", + format: "openai", modelId: id, model: info, settings: this.options, - defaultTemperature: 1.0, - }) - - return { - id, - info, - ...params, - } - } - - async completePrompt(prompt: string) { - const { id: model, temperature } = this.getModel() - - const message = await this.client.messages.create({ - model, - max_tokens: 16_384, - temperature: temperature ?? 1.0, - messages: [{ role: "user", content: prompt }], - stream: false, + defaultTemperature: 0, }) - - const content = message.content.find(({ type }) => type === "text") - return content?.type === "text" ? content.text : "" + return { id, info, ...params } } } diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index be6665e3244..e3247ab611a 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -1,4 +1,3 @@ -import { Anthropic } from "@anthropic-ai/sdk" import { createMistral } from "@ai-sdk/mistral" import { streamText, generateText, ToolSet, LanguageModel } from "ai" @@ -10,6 +9,7 @@ import { MISTRAL_DEFAULT_TEMPERATURE, } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" import { @@ -142,7 +142,7 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand */ override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const languageModel = this.getLanguageModel() diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 99c1dc03cfa..cc1bc7a6166 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -1,378 +1,118 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" -import { Message, Ollama, Tool as OllamaTool, type Config as OllamaOptions } from "ollama" -import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types" -import { ApiStream } from "../transform/stream" -import { BaseProvider } from "./base-provider" -import type { ApiHandlerOptions } from "../../shared/api" -import { getOllamaModels } from "./fetchers/ollama" -import { TagMatcher } from "../../utils/tag-matcher" -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" - -interface OllamaChatOptions { - temperature: number - num_ctx?: number -} - -function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] { - const ollamaMessages: Message[] = [] - - for (const anthropicMessage of anthropicMessages) { - if (typeof anthropicMessage.content === "string") { - ollamaMessages.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) - } - return acc - }, - { nonToolMessages: [], toolMessages: [] }, - ) - - // Process tool result messages FIRST since they must follow the tool use messages - const toolResultImages: string[] = [] - toolMessages.forEach((toolMessage) => { - // The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility. - let content: string - - if (typeof toolMessage.content === "string") { - content = toolMessage.content - } else { - content = - toolMessage.content - ?.map((part) => { - if (part.type === "image") { - // Handle base64 images only (Anthropic SDK uses base64) - // Ollama expects raw base64 strings, not data URLs - if ("source" in part && part.source.type === "base64") { - toolResultImages.push(part.source.data) - } - return "(see following user message for image)" - } - return part.text - }) - .join("\n") ?? "" - } - ollamaMessages.push({ - role: "user", - images: toolResultImages.length > 0 ? toolResultImages : undefined, - content: content, - }) - }) +import { streamText, generateText, ToolSet } from "ai" - // Process non-tool messages - if (nonToolMessages.length > 0) { - // Separate text and images for Ollama - const textContent = nonToolMessages - .filter((part) => part.type === "text") - .map((part) => part.text) - .join("\n") +import { ollamaDefaultModelInfo } from "@roo-code/types" - const imageData: string[] = [] - nonToolMessages.forEach((part) => { - if (part.type === "image" && "source" in part && part.source.type === "base64") { - // Ollama expects raw base64 strings, not data URLs - imageData.push(part.source.data) - } - }) - - ollamaMessages.push({ - role: "user", - content: textContent, - images: imageData.length > 0 ? imageData : undefined, - }) - } - } 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: [] }, - ) +import type { NeutralMessageParam } from "../../core/task-persistence" +import type { ApiHandlerOptions } from "../../shared/api" - // Process non-tool messages - let content: string = "" - 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") - } +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream } from "../transform/stream" +import { getModelParams } from "../transform/model-params" - // Convert tool_use blocks to Ollama tool_calls format - const toolCalls = - toolMessages.length > 0 - ? toolMessages.map((tool) => ({ - function: { - name: tool.name, - arguments: tool.input as Record, - }, - })) - : undefined +import { OpenAICompatibleHandler, type OpenAICompatibleConfig } from "./openai-compatible" +import type { ApiHandlerCreateMessageMetadata } from "../index" +import { getModelsFromCache } from "./fetchers/modelCache" - ollamaMessages.push({ - role: "assistant", - content, - tool_calls: toolCalls, - }) - } +export class NativeOllamaHandler extends OpenAICompatibleHandler { + constructor(options: ApiHandlerOptions) { + const baseUrl = options.ollamaBaseUrl || "http://localhost:11434" + const modelId = options.ollamaModelId || "" + const models = getModelsFromCache("ollama") + const modelInfo = (models && modelId && models[modelId]) || ollamaDefaultModelInfo + + const config: OpenAICompatibleConfig = { + providerName: "ollama", + baseURL: `${baseUrl.replace(/\/+$/, "")}/v1`, + apiKey: options.ollamaApiKey || "ollama", + modelId, + modelInfo, } - } - - return ollamaMessages -} - -export class NativeOllamaHandler extends BaseProvider implements SingleCompletionHandler { - protected options: ApiHandlerOptions - private client: Ollama | undefined - protected models: Record = {} - constructor(options: ApiHandlerOptions) { - super() - this.options = options + super(options, config) } - private ensureClient(): Ollama { - if (!this.client) { - try { - const clientOptions: OllamaOptions = { - host: this.options.ollamaBaseUrl || "http://localhost:11434", - // Note: The ollama npm package handles timeouts internally - } - - // Add API key if provided (for Ollama cloud or authenticated instances) - if (this.options.ollamaApiKey) { - clientOptions.headers = { - Authorization: `Bearer ${this.options.ollamaApiKey}`, - } - } - - this.client = new Ollama(clientOptions) - } catch (error: any) { - throw new Error(`Error creating Ollama client: ${error.message}`) - } - } - return this.client + override getModel() { + const models = getModelsFromCache("ollama") + const id = this.options.ollamaModelId || "" + const info = (models && id && models[id]) || ollamaDefaultModelInfo + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) + return { id, info, ...params } } - /** - * Converts OpenAI-format tools to Ollama's native tool format. - * This allows NativeOllamaHandler to use the same tool definitions - * that are passed to OpenAI-compatible providers. - */ - private convertToolsToOllama(tools: OpenAI.Chat.ChatCompletionTool[] | undefined): OllamaTool[] | undefined { - if (!tools || tools.length === 0) { - return undefined + private get numCtxProviderOptions(): Record | undefined { + if (this.options.ollamaNumCtx !== undefined) { + return { ollama: { num_ctx: this.options.ollamaNumCtx } } as Record } - - return tools - .filter((tool): tool is OpenAI.Chat.ChatCompletionTool & { type: "function" } => tool.type === "function") - .map((tool) => ({ - type: tool.type, - function: { - name: tool.function.name, - description: tool.function.description, - parameters: tool.function.parameters as OllamaTool["function"]["parameters"], - }, - })) + return undefined } override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const client = this.ensureClient() - const { id: modelId } = await this.fetchModel() - const useR1Format = modelId.toLowerCase().includes("deepseek-r1") - - const ollamaMessages: Message[] = [ - { role: "system", content: systemPrompt }, - ...convertToOllamaMessages(messages), - ] + const providerOptions = this.numCtxProviderOptions + if (!providerOptions) { + yield* super.createMessage(systemPrompt, messages, metadata) + return + } - const matcher = new TagMatcher( - "think", - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) + const model = this.getModel() + const aiSdkMessages = convertToAiSdkMessages(messages) + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const result = streamText({ + model: this.getLanguageModel(), + system: systemPrompt, + messages: aiSdkMessages, + temperature: model.temperature ?? 0, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + providerOptions: providerOptions as any, + }) try { - // Build options object conditionally - const chatOptions: OllamaChatOptions = { - temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), - } - - // Only include num_ctx if explicitly set via ollamaNumCtx - if (this.options.ollamaNumCtx !== undefined) { - chatOptions.num_ctx = this.options.ollamaNumCtx - } - - // Create the actual API request promise - const stream = await client.chat({ - model: modelId, - messages: ollamaMessages, - stream: true, - options: chatOptions, - tools: this.convertToolsToOllama(metadata?.tools), - }) - - let totalInputTokens = 0 - let totalOutputTokens = 0 - // Track tool calls across chunks (Ollama may send complete tool_calls in final chunk) - let toolCallIndex = 0 - // Track tool call IDs for emitting end events - const toolCallIds: string[] = [] - - try { - for await (const chunk of stream) { - if (typeof chunk.message.content === "string" && chunk.message.content.length > 0) { - // Process content through matcher for reasoning detection - for (const matcherChunk of matcher.update(chunk.message.content)) { - yield matcherChunk - } - } - - // Handle tool calls - emit partial chunks for NativeToolCallParser compatibility - if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) { - for (const toolCall of chunk.message.tool_calls) { - // Generate a unique ID for this tool call - const toolCallId = `ollama-tool-${toolCallIndex}` - toolCallIds.push(toolCallId) - yield { - type: "tool_call_partial", - index: toolCallIndex, - id: toolCallId, - name: toolCall.function.name, - arguments: JSON.stringify(toolCall.function.arguments), - } - toolCallIndex++ - } - } - - // Handle token usage if available - if (chunk.eval_count !== undefined || chunk.prompt_eval_count !== undefined) { - if (chunk.prompt_eval_count) { - totalInputTokens = chunk.prompt_eval_count - } - if (chunk.eval_count) { - totalOutputTokens = chunk.eval_count - } - } - } - - // Yield any remaining content from the matcher - for (const chunk of matcher.final()) { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { yield chunk } - - for (const toolCallId of toolCallIds) { - yield { - type: "tool_call_end", - id: toolCallId, - } - } - - // Yield usage information if available - if (totalInputTokens > 0 || totalOutputTokens > 0) { - yield { - type: "usage", - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - } - } - } catch (streamError: any) { - console.error("Error processing Ollama stream:", streamError) - throw new Error(`Ollama stream processing error: ${streamError.message || "Unknown error"}`) } - } catch (error: any) { - // Enhance error reporting - const statusCode = error.status || error.statusCode - const errorMessage = error.message || "Unknown error" - - if (error.code === "ECONNREFUSED") { - throw new Error( - `Ollama service is not running at ${this.options.ollamaBaseUrl || "http://localhost:11434"}. Please start Ollama first.`, - ) - } else if (statusCode === 404) { - throw new Error( - `Model ${this.getModel().id} not found in Ollama. Please pull the model first with: ollama pull ${this.getModel().id}`, - ) + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) } - - console.error(`Ollama API error (${statusCode || "unknown"}): ${errorMessage}`) - throw error + } catch (error) { + throw handleAiSdkError(error, this.config.providerName) } } - async fetchModel() { - this.models = await getOllamaModels(this.options.ollamaBaseUrl, this.options.ollamaApiKey) - return this.getModel() - } - - override getModel(): { id: string; info: ModelInfo } { - const modelId = this.options.ollamaModelId || "" - return { - id: modelId, - info: this.models[modelId] || openAiModelInfoSaneDefaults, + override async completePrompt(prompt: string): Promise { + const providerOptions = this.numCtxProviderOptions + if (!providerOptions) { + return super.completePrompt(prompt) } - } - - async completePrompt(prompt: string): Promise { - try { - const client = this.ensureClient() - const { id: modelId } = await this.fetchModel() - const useR1Format = modelId.toLowerCase().includes("deepseek-r1") - - // Build options object conditionally - const chatOptions: OllamaChatOptions = { - temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), - } - - // Only include num_ctx if explicitly set via ollamaNumCtx - if (this.options.ollamaNumCtx !== undefined) { - chatOptions.num_ctx = this.options.ollamaNumCtx - } - - const response = await client.chat({ - model: modelId, - messages: [{ role: "user", content: prompt }], - stream: false, - options: chatOptions, - }) - return response.message?.content || "" - } catch (error) { - if (error instanceof Error) { - throw new Error(`Ollama completion error: ${error.message}`) - } - throw error - } + const { text } = await generateText({ + model: this.getLanguageModel(), + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.config.temperature ?? 0, + providerOptions: providerOptions as any, + }) + return text } } diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index d64780c5557..ece435b2ac5 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -1,158 +1,100 @@ import * as os from "os" import { v7 as uuidv7 } from "uuid" -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { createOpenAI } from "@ai-sdk/openai" +import { streamText, generateText, ToolSet } from "ai" import { type ModelInfo, openAiCodexDefaultModelId, OpenAiCodexModelId, openAiCodexModels, - type ReasoningEffort, type ReasoningEffortExtended, ApiProviderError, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Package } from "../../shared/package" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { isMcpTool } from "../../utils/mcp-name" -import { sanitizeOpenAiCallId } from "../../utils/tool-id" import { openAiCodexOAuthManager } from "../../integrations/openai-codex/oauth" import { t } from "../../i18n" export type OpenAiCodexModel = ReturnType /** - * OpenAI Codex base URL for API requests + * OpenAI Codex base URL for API requests. * Per the implementation guide: requests are routed to chatgpt.com/backend-api/codex */ const CODEX_API_BASE_URL = "https://chatgpt.com/backend-api/codex" /** - * OpenAiCodexHandler - Uses OpenAI Responses API with OAuth authentication + * OpenAI Codex provider using the AI SDK (@ai-sdk/openai) with the Responses API. * * Key differences from OpenAiNativeHandler: * - Uses OAuth Bearer tokens instead of API keys * - Routes requests to Codex backend (chatgpt.com/backend-api/codex) - * - Subscription-based pricing (no per-token costs) - * - Limited model subset - * - Custom headers for Codex backend + * - Subscription-based pricing (no per-token costs, totalCost always 0) + * - No temperature, max_output_tokens, or promptCacheRetention support + * - Auth retry logic: attempt once, if 401/auth error → force token refresh → retry */ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private readonly providerName = "OpenAI Codex" - private client?: OpenAI - // Complete response output array - private lastResponseOutput: any[] | undefined - // Last top-level response id - private lastResponseId: string | undefined - // Abort controller for cancelling ongoing requests - private abortController?: AbortController - // Session ID for the Codex API (persists for the lifetime of the handler) + // Session ID for request tracking (persists for the lifetime of the handler) private readonly sessionId: string - /** - * Some Codex/Responses streams emit tool-call argument deltas without stable call id/name. - * Track the last observed tool identity from output_item events so we can still - * emit `tool_call_partial` chunks (tool-call-only streams). - */ - private pendingToolCallId: string | undefined - private pendingToolCallName: string | undefined - - // Event types handled by the shared event processor - private readonly coreHandledEventTypes = new Set([ - "response.text.delta", - "response.output_text.delta", - "response.reasoning.delta", - "response.reasoning_text.delta", - "response.reasoning_summary.delta", - "response.reasoning_summary_text.delta", - "response.refusal.delta", - "response.output_item.added", - "response.output_item.done", - "response.done", - "response.completed", - "response.tool_call_arguments.delta", - "response.function_call_arguments.delta", - "response.tool_call_arguments.done", - "response.function_call_arguments.done", - ]) + // Last response ID from Responses API + private lastResponseId: string | undefined + // Last encrypted reasoning content for stateless continuity + private lastEncryptedContent: { encrypted_content: string; id?: string } | undefined constructor(options: ApiHandlerOptions) { super() this.options = options - // Generate a new session ID for standalone handler usage (fallback) this.sessionId = uuidv7() } - private normalizeUsage(usage: any, model: OpenAiCodexModel): ApiStreamUsageChunk | undefined { - if (!usage) return undefined - - const inputDetails = usage.input_tokens_details ?? usage.prompt_tokens_details - - const hasCachedTokens = typeof inputDetails?.cached_tokens === "number" - const hasCacheMissTokens = typeof inputDetails?.cache_miss_tokens === "number" - const cachedFromDetails = hasCachedTokens ? inputDetails.cached_tokens : 0 - const missFromDetails = hasCacheMissTokens ? inputDetails.cache_miss_tokens : 0 + override getModel() { + const modelId = this.options.apiModelId - let totalInputTokens = usage.input_tokens ?? usage.prompt_tokens ?? 0 - if (totalInputTokens === 0 && inputDetails && (cachedFromDetails > 0 || missFromDetails > 0)) { - totalInputTokens = cachedFromDetails + missFromDetails - } + const id = modelId && modelId in openAiCodexModels ? (modelId as OpenAiCodexModelId) : openAiCodexDefaultModelId - const totalOutputTokens = usage.output_tokens ?? usage.completion_tokens ?? 0 - const cacheWriteTokens = usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? 0 - const cacheReadTokens = - usage.cache_read_input_tokens ?? usage.cache_read_tokens ?? usage.cached_tokens ?? cachedFromDetails ?? 0 + const info: ModelInfo = openAiCodexModels[id] - const reasoningTokens = - typeof usage.output_tokens_details?.reasoning_tokens === "number" - ? usage.output_tokens_details.reasoning_tokens - : undefined + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) - // Subscription-based: no per-token costs - const out: ApiStreamUsageChunk = { - type: "usage", - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - cacheWriteTokens, - cacheReadTokens, - ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), - totalCost: 0, // Subscription-based pricing - } - return out + return { id, info, ...params } } - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - const model = this.getModel() - yield* this.handleResponsesApiMessage(model, systemPrompt, messages, metadata) + override isAiSdkProvider(): boolean { + return true } - private async *handleResponsesApiMessage( - model: OpenAiCodexModel, - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - // Reset state for this request - this.lastResponseOutput = undefined - this.lastResponseId = undefined - this.pendingToolCallId = undefined - this.pendingToolCallName = undefined - - // Get access token from OAuth manager - let accessToken = await openAiCodexOAuthManager.getAccessToken() - if (!accessToken) { + /** + * Retrieve OAuth credentials from the Codex OAuth manager. + * Throws a localized error if not authenticated. + */ + private async getOAuthCredentials(): Promise<{ token: string; accountId: string | null }> { + const token = await openAiCodexOAuthManager.getAccessToken() + if (!token) { throw new Error( t("common:errors.openAiCodex.notAuthenticated", { defaultValue: @@ -160,939 +102,289 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion }), ) } + const accountId = await openAiCodexOAuthManager.getAccountId() + return { token, accountId } + } - // Resolve reasoning effort - const reasoningEffort = this.getReasoningEffort(model) - - // Format conversation - const formattedInput = this.formatFullConversation(systemPrompt, messages) - - // Build request body - // Per the implementation guide: Codex backend may reject some parameters - // Notably: max_output_tokens and prompt_cache_retention may be rejected - const requestBody = this.buildRequestBody(model, formattedInput, systemPrompt, reasoningEffort, metadata) - - // Make the request with retry on auth failure - for (let attempt = 0; attempt < 2; attempt++) { - try { - yield* this.executeRequest(requestBody, model, accessToken, metadata?.taskId) - return - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - const isAuthFailure = /unauthorized|invalid token|not authenticated|authentication|401/i.test(message) + /** + * Create the AI SDK OpenAI provider with per-request OAuth headers. + * The Bearer token is passed as apiKey; additional Codex-specific headers + * include originator, session tracking, User-Agent, and ChatGPT-Account-Id. + */ + private createProvider(token: string, accountId: string | null, metadata?: ApiHandlerCreateMessageMetadata) { + const taskId = metadata?.taskId + const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` + + return createOpenAI({ + apiKey: token, + baseURL: CODEX_API_BASE_URL, + headers: { + originator: "roo-code", + session_id: taskId || this.sessionId, + "User-Agent": userAgent, + ...(accountId ? { "ChatGPT-Account-Id": accountId } : {}), + }, + }) + } - if (attempt === 0 && isAuthFailure) { - // Force refresh the token for retry - const refreshed = await openAiCodexOAuthManager.forceRefreshAccessToken() - if (!refreshed) { - throw new Error( - t("common:errors.openAiCodex.notAuthenticated", { - defaultValue: - "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", - }), - ) - } - accessToken = refreshed - continue - } - throw error - } - } + /** + * Get the reasoning effort for models that support it. + */ + private getReasoningEffort(model: OpenAiCodexModel): ReasoningEffortExtended | undefined { + const selected = + (this.options.reasoningEffort as ReasoningEffortExtended | undefined) ?? + (model.info.reasoningEffort as ReasoningEffortExtended | undefined) + return selected && selected !== ("disable" as string) && selected !== ("none" as string) ? selected : undefined } - private buildRequestBody( + /** + * Build the providerOptions for the Responses API. + * Codex-specific: no max_output_tokens, no promptCacheRetention, no temperature. + */ + private buildProviderOptions( model: OpenAiCodexModel, - formattedInput: any, - systemPrompt: string, - reasoningEffort: ReasoningEffortExtended | undefined, metadata?: ApiHandlerCreateMessageMetadata, - ): any { - const ensureAllRequired = (schema: any): any => { - if (!schema || typeof schema !== "object" || schema.type !== "object") { - return schema - } - - const result = { ...schema } - if (result.additionalProperties !== false) { - result.additionalProperties = false - } - - if (result.properties) { - const allKeys = Object.keys(result.properties) - result.required = allKeys - - const newProps = { ...result.properties } - for (const key of allKeys) { - const prop = newProps[key] - if (prop.type === "object") { - newProps[key] = ensureAllRequired(prop) - } else if (prop.type === "array" && prop.items?.type === "object") { - newProps[key] = { - ...prop, - items: ensureAllRequired(prop.items), - } - } - } - result.properties = newProps - } - - return result - } - - const ensureAdditionalPropertiesFalse = (schema: any): any => { - if (!schema || typeof schema !== "object" || schema.type !== "object") { - return schema - } - - const result = { ...schema } - if (result.additionalProperties !== false) { - result.additionalProperties = false - } - - if (result.properties) { - const newProps = { ...result.properties } - for (const key of Object.keys(result.properties)) { - const prop = newProps[key] - if (prop && prop.type === "object") { - newProps[key] = ensureAdditionalPropertiesFalse(prop) - } else if (prop && prop.type === "array" && prop.items?.type === "object") { - newProps[key] = { - ...prop, - items: ensureAdditionalPropertiesFalse(prop.items), - } - } - } - result.properties = newProps - } - - return result - } - - interface ResponsesRequestBody { - model: string - input: Array<{ role: "user" | "assistant"; content: any[] } | { type: string; content: string }> - stream: boolean - reasoning?: { effort?: ReasoningEffortExtended; summary?: "auto" } - temperature?: number - store?: boolean - instructions?: string - include?: string[] - tools?: Array<{ - type: "function" - name: string - description?: string - parameters?: any - strict?: boolean - }> - tool_choice?: any - parallel_tool_calls?: boolean - } + ): Record { + const reasoningEffort = this.getReasoningEffort(model) - // Per the implementation guide: Codex backend may reject max_output_tokens - // and prompt_cache_retention, so we omit them - const body: ResponsesRequestBody = { - model: model.id, - input: formattedInput, - stream: true, + const opts: Record = { + // Always use stateless operation store: false, - instructions: systemPrompt, - // Only include encrypted reasoning content when reasoning effort is set - ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), + // Reasoning configuration ...(reasoningEffort ? { - reasoning: { - ...(reasoningEffort ? { effort: reasoningEffort } : {}), - summary: "auto" as const, - }, + reasoningEffort, + include: ["reasoning.encrypted_content"], + reasoningSummary: "auto", } : {}), - tools: (metadata?.tools ?? []) - .filter((tool) => tool.type === "function") - .map((tool) => { - const isMcp = isMcpTool(tool.function.name) - return { - type: "function", - name: tool.function.name, - description: tool.function.description, - parameters: isMcp - ? ensureAdditionalPropertiesFalse(tool.function.parameters) - : ensureAllRequired(tool.function.parameters), - strict: !isMcp, - } - }), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, + // Tool configuration + parallelToolCalls: metadata?.parallelToolCalls ?? true, + // NOTE: Codex backend rejects max_output_tokens and promptCacheRetention } - return body + return opts } - private async *executeRequest( - requestBody: any, - model: OpenAiCodexModel, - accessToken: string, - taskId?: string, - ): ApiStream { - // Create AbortController for cancellation - this.abortController = new AbortController() - - try { - // Prefer OpenAI SDK streaming (same approach as openai-native) so event handling - // is consistent across providers. - try { - // Get ChatGPT account ID for organization subscriptions - const accountId = await openAiCodexOAuthManager.getAccountId() - - // Build Codex-specific headers. Authorization is provided by the SDK apiKey. - const codexHeaders: Record = { - originator: "roo-code", - session_id: taskId || this.sessionId, - "User-Agent": `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`, - ...(accountId ? { "ChatGPT-Account-Id": accountId } : {}), - } - - // Allow tests to inject a client. If none is injected, create one for this request. - const client = - this.client ?? - new OpenAI({ - apiKey: accessToken, - baseURL: CODEX_API_BASE_URL, - defaultHeaders: codexHeaders, - }) - - const stream = (await (client as any).responses.create(requestBody, { - signal: this.abortController.signal, - // If the SDK supports per-request overrides, ensure headers are present. - headers: codexHeaders, - })) as AsyncIterable - - if (typeof (stream as any)?.[Symbol.asyncIterator] !== "function") { - throw new Error( - "OpenAI SDK did not return an AsyncIterable for Responses API streaming. Falling back to SSE.", - ) - } - - for await (const event of stream) { - if (this.abortController.signal.aborted) { - break - } - - for await (const outChunk of this.processEvent(event, model)) { - yield outChunk - } - } - } catch (_sdkErr) { - // Fallback to manual SSE via fetch (Codex backend). - yield* this.makeCodexRequest(requestBody, model, accessToken, taskId) - } - } finally { - this.abortController = undefined - } - } - - private formatFullConversation(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): any { - const formattedInput: any[] = [] - - for (const message of messages) { - // Check if this is a reasoning item - if ((message as any).type === "reasoning") { - formattedInput.push(message) - continue + /** + * Process usage metrics from the AI SDK response. + * Subscription-based pricing: totalCost is always 0. + */ + private processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number } + }, + providerMetadata: Record> | undefined, + ): ApiStreamUsageChunk { + const openaiMeta = providerMetadata?.openai as Record | undefined - if (message.role === "user") { - const content: any[] = [] - const toolResults: any[] = [] - - if (typeof message.content === "string") { - content.push({ type: "input_text", text: message.content }) - } else if (Array.isArray(message.content)) { - for (const block of message.content) { - if (block.type === "text") { - content.push({ type: "input_text", text: block.text }) - } else if (block.type === "image") { - const image = block as Anthropic.Messages.ImageBlockParam - const imageUrl = `data:${image.source.media_type};base64,${image.source.data}` - content.push({ type: "input_image", image_url: imageUrl }) - } else if (block.type === "tool_result") { - const result = - typeof block.content === "string" - ? block.content - : block.content?.map((c) => (c.type === "text" ? c.text : "")).join("") || "" - toolResults.push({ - type: "function_call_output", - // Sanitize and truncate call_id to fit OpenAI's 64-char limit - call_id: sanitizeOpenAiCallId(block.tool_use_id), - output: result, - }) - } - } - } - - if (content.length > 0) { - formattedInput.push({ role: "user", content }) - } - - if (toolResults.length > 0) { - formattedInput.push(...toolResults) - } - } else if (message.role === "assistant") { - const content: any[] = [] - const toolCalls: any[] = [] - - if (typeof message.content === "string") { - content.push({ type: "output_text", text: message.content }) - } else if (Array.isArray(message.content)) { - for (const block of message.content) { - if (block.type === "text") { - content.push({ type: "output_text", text: block.text }) - } else if (block.type === "tool_use") { - toolCalls.push({ - type: "function_call", - // Sanitize and truncate call_id to fit OpenAI's 64-char limit - call_id: sanitizeOpenAiCallId(block.id), - name: block.name, - arguments: JSON.stringify(block.input), - }) - } - } - } + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + const cacheReadTokens = usage.details?.cachedInputTokens ?? (openaiMeta?.cachedInputTokens as number) ?? 0 + const cacheWriteTokens = (openaiMeta?.cacheCreationInputTokens as number) ?? 0 + const reasoningTokens = usage.details?.reasoningTokens - if (content.length > 0) { - formattedInput.push({ role: "assistant", content }) - } - - if (toolCalls.length > 0) { - formattedInput.push(...toolCalls) - } - } + return { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens || undefined, + cacheReadTokens: cacheReadTokens || undefined, + ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), + totalCost: 0, // Subscription-based pricing } + } - return formattedInput + /** + * Check if an error is an authentication/authorization failure. + */ + private isAuthError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /unauthorized|invalid token|not authenticated|authentication|401/i.test(message) } - private async *makeCodexRequest( - requestBody: any, - model: OpenAiCodexModel, - accessToken: string, - taskId?: string, + /** + * Create a streaming message with auth retry logic. + * Attempts once; if auth error, forces token refresh and retries. + */ + override async *createMessage( + systemPrompt: string, + messages: NeutralMessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - // Per the implementation guide: route to Codex backend with Bearer token - const url = `${CODEX_API_BASE_URL}/responses` - - // Get ChatGPT account ID for organization subscriptions - const accountId = await openAiCodexOAuthManager.getAccountId() - - // Build headers with required Codex-specific fields - const headers: Record = { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - originator: "roo-code", - session_id: taskId || this.sessionId, - "User-Agent": `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`, - } - - // Add ChatGPT-Account-Id if available (required for organization subscriptions) - if (accountId) { - headers["ChatGPT-Account-Id"] = accountId - } - try { - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(requestBody), - signal: this.abortController?.signal, - }) - - if (!response.ok) { - const errorText = await response.text() - - let errorMessage = t("common:errors.api.apiRequestFailed", { status: response.status }) - let errorDetails = "" - - try { - const errorJson = JSON.parse(errorText) - if (errorJson.error?.message) { - errorDetails = errorJson.error.message - } else if (errorJson.message) { - errorDetails = errorJson.message - } else if (errorJson.detail) { - errorDetails = errorJson.detail - } else { - errorDetails = errorText - } - } catch { - errorDetails = errorText - } - - switch (response.status) { - case 400: - errorMessage = t("common:errors.openAiCodex.invalidRequest") - break - case 401: - errorMessage = t("common:errors.openAiCodex.authenticationFailed") - break - case 403: - errorMessage = t("common:errors.openAiCodex.accessDenied") - break - case 404: - errorMessage = t("common:errors.openAiCodex.endpointNotFound") - break - case 429: - errorMessage = t("common:errors.openAiCodex.rateLimitExceeded") - break - case 500: - case 502: - case 503: - errorMessage = t("common:errors.openAiCodex.serviceError") - break - default: - errorMessage = t("common:errors.openAiCodex.genericError", { status: response.status }) - } - - if (errorDetails) { - errorMessage += ` - ${errorDetails}` - } - - throw new Error(errorMessage) - } - - if (!response.body) { - throw new Error(t("common:errors.openAiCodex.noResponseBody")) - } - - yield* this.handleStreamResponse(response.body, model) + yield* this._createMessageImpl(systemPrompt, messages, metadata) } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") - TelemetryService.instance.captureException(apiError) - - if (error instanceof Error) { - if (error.message.includes("Codex API")) { - throw error + if (this.isAuthError(error)) { + const refreshed = await openAiCodexOAuthManager.forceRefreshAccessToken() + if (!refreshed) { + throw new Error( + t("common:errors.openAiCodex.notAuthenticated", { + defaultValue: + "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + }), + ) } - throw new Error(t("common:errors.openAiCodex.connectionFailed", { message: error.message })) + yield* this._createMessageImpl(systemPrompt, messages, metadata) + } else { + throw error } - throw new Error(t("common:errors.openAiCodex.unexpectedConnectionError")) } } - private async *handleStreamResponse(body: ReadableStream, model: OpenAiCodexModel): ApiStream { - const reader = body.getReader() - const decoder = new TextDecoder() - let buffer = "" - let hasContent = false + /** + * Internal streaming implementation using AI SDK streamText with Responses API. + */ + private async *_createMessageImpl( + systemPrompt: string, + messages: NeutralMessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const model = this.getModel() + + // Reset per-request state + this.lastResponseId = undefined + this.lastEncryptedContent = undefined + + const { token, accountId } = await this.getOAuthCredentials() + const provider = this.createProvider(token, accountId, metadata) + const languageModel = provider.responses(model.id) + + // Convert messages and tools to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build provider options for Responses API features + const openaiProviderOptions = this.buildProviderOptions(model, metadata) + + const result = streamText({ + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + // NOTE: No temperature — Codex backend does not support it + // NOTE: No maxOutputTokens — Codex backend rejects this parameter + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + providerOptions: { + openai: openaiProviderOptions as Record, + }, + }) try { - while (true) { - if (this.abortController?.signal.aborted) { - break + // Process the full stream + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk } + } - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") - buffer = lines.pop() || "" - - for (const line of lines) { - if (line.startsWith("data: ")) { - const data = line.slice(6).trim() - if (data === "[DONE]") { - continue - } - - try { - const parsed = JSON.parse(data) - - // Capture response metadata - if (parsed.response?.output && Array.isArray(parsed.response.output)) { - this.lastResponseOutput = parsed.response.output - } - if (parsed.response?.id) { - this.lastResponseId = parsed.response.id as string - } - - // Delegate standard event types - if (parsed?.type && this.coreHandledEventTypes.has(parsed.type)) { - // Capture tool call identity from output_item events so we can - // emit tool_call_partial for subsequent function_call_arguments.delta events - if ( - parsed.type === "response.output_item.added" || - parsed.type === "response.output_item.done" - ) { - const item = parsed.item - if (item && (item.type === "function_call" || item.type === "tool_call")) { - const callId = item.call_id || item.tool_call_id || item.id - const name = item.name || item.function?.name || item.function_name - if (typeof callId === "string" && callId.length > 0) { - this.pendingToolCallId = callId - this.pendingToolCallName = typeof name === "string" ? name : undefined - } - } - } - - // Some Codex streams only return tool calls (no text). Treat tool output as content. - if ( - parsed.type === "response.function_call_arguments.delta" || - parsed.type === "response.tool_call_arguments.delta" || - parsed.type === "response.output_item.added" || - parsed.type === "response.output_item.done" - ) { - hasContent = true - } + // Extract provider metadata after streaming + const usage = await result.usage + const providerMetadata = await result.providerMetadata + const openaiMeta = (providerMetadata as Record> | undefined)?.openai - for await (const outChunk of this.processEvent(parsed, model)) { - if (outChunk.type === "text" || outChunk.type === "reasoning") { - hasContent = true - } - yield outChunk - } - continue - } + // Store response ID for getResponseId() + if (openaiMeta?.responseId) { + this.lastResponseId = openaiMeta.responseId as string + } - // Handle complete response - if (parsed.response && parsed.response.output && Array.isArray(parsed.response.output)) { - for (const outputItem of parsed.response.output) { - if (outputItem.type === "text" && outputItem.content) { - for (const content of outputItem.content) { - if (content.type === "text" && content.text) { - hasContent = true - yield { type: "text", text: content.text } - } - } - } - if (outputItem.type === "reasoning" && Array.isArray(outputItem.summary)) { - for (const summary of outputItem.summary) { - if (summary?.type === "summary_text" && typeof summary.text === "string") { - hasContent = true - yield { type: "reasoning", text: summary.text } - } - } - } - } - if (parsed.response.usage) { - const usageData = this.normalizeUsage(parsed.response.usage, model) - if (usageData) { - yield usageData - } - } - } else if ( - parsed.type === "response.text.delta" || - parsed.type === "response.output_text.delta" - ) { - if (parsed.delta) { - hasContent = true - yield { type: "text", text: parsed.delta } - } - } else if ( - parsed.type === "response.reasoning.delta" || - parsed.type === "response.reasoning_text.delta" - ) { - if (parsed.delta) { - hasContent = true - yield { type: "reasoning", text: parsed.delta } - } - } else if ( - parsed.type === "response.reasoning_summary.delta" || - parsed.type === "response.reasoning_summary_text.delta" - ) { - if (parsed.delta) { - hasContent = true - yield { type: "reasoning", text: parsed.delta } - } - } else if (parsed.type === "response.refusal.delta") { - if (parsed.delta) { - hasContent = true - yield { type: "text", text: `[Refusal] ${parsed.delta}` } - } - } else if (parsed.type === "response.output_item.added") { - if (parsed.item) { - if (parsed.item.type === "text" && parsed.item.text) { - hasContent = true - yield { type: "text", text: parsed.item.text } - } else if (parsed.item.type === "reasoning" && parsed.item.text) { - hasContent = true - yield { type: "reasoning", text: parsed.item.text } - } else if (parsed.item.type === "message" && parsed.item.content) { - for (const content of parsed.item.content) { - if (content.type === "text" && content.text) { - hasContent = true - yield { type: "text", text: content.text } + // Extract encrypted reasoning content from response for stateless continuity + try { + const response = await result.response + if (response?.messages) { + for (const message of response.messages) { + if (!Array.isArray(message.content)) continue + for (const contentPart of message.content) { + if (contentPart.type === "reasoning") { + const reasoningMeta = ( + contentPart as { + providerMetadata?: { + openai?: { + itemId?: string + reasoningEncryptedContent?: string } } } - } - } else if (parsed.type === "response.error" || parsed.type === "error") { - if (parsed.error || parsed.message) { - throw new Error( - t("common:errors.openAiCodex.apiError", { - message: parsed.error?.message || parsed.message || "Unknown error", - }), - ) - } - } else if (parsed.type === "response.failed") { - if (parsed.error || parsed.message) { - throw new Error( - t("common:errors.openAiCodex.responseFailed", { - message: parsed.error?.message || parsed.message || "Unknown failure", - }), - ) - } - } else if (parsed.type === "response.completed" || parsed.type === "response.done") { - if (parsed.response?.output && Array.isArray(parsed.response.output)) { - this.lastResponseOutput = parsed.response.output - } - if (parsed.response?.id) { - this.lastResponseId = parsed.response.id as string - } - - if ( - !hasContent && - parsed.response && - parsed.response.output && - Array.isArray(parsed.response.output) - ) { - for (const outputItem of parsed.response.output) { - if (outputItem.type === "message" && outputItem.content) { - for (const content of outputItem.content) { - if (content.type === "output_text" && content.text) { - hasContent = true - yield { type: "text", text: content.text } - } - } - } - if (outputItem.type === "reasoning" && Array.isArray(outputItem.summary)) { - for (const summary of outputItem.summary) { - if ( - summary?.type === "summary_text" && - typeof summary.text === "string" - ) { - hasContent = true - yield { type: "reasoning", text: summary.text } - } - } - } + ).providerMetadata?.openai + if (reasoningMeta?.reasoningEncryptedContent) { + this.lastEncryptedContent = { + encrypted_content: reasoningMeta.reasoningEncryptedContent, + ...(reasoningMeta.itemId ? { id: reasoningMeta.itemId } : {}), } } - } else if (parsed.choices?.[0]?.delta?.content) { - hasContent = true - yield { type: "text", text: parsed.choices[0].delta.content } - } else if ( - parsed.item && - typeof parsed.item.text === "string" && - parsed.item.text.length > 0 - ) { - hasContent = true - yield { type: "text", text: parsed.item.text } - } else if (parsed.usage) { - const usageData = this.normalizeUsage(parsed.usage, model) - if (usageData) { - yield usageData - } - } - } catch (e) { - if (!(e instanceof SyntaxError)) { - throw e } } - } else if (line.trim() && !line.startsWith(":")) { - try { - const parsed = JSON.parse(line) - if (parsed.content || parsed.text || parsed.message) { - hasContent = true - yield { type: "text", text: parsed.content || parsed.text || parsed.message } - } - } catch { - // Not JSON, ignore - } } } + } catch { + // Encrypted content extraction is best-effort + } + + // Yield usage metrics (subscription: totalCost always 0) + if (usage) { + yield this.processUsageMetrics( + usage, + providerMetadata as Record> | undefined, + ) } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") TelemetryService.instance.captureException(apiError) - - if (error instanceof Error) { - throw new Error(t("common:errors.openAiCodex.streamProcessingError", { message: error.message })) - } - throw new Error(t("common:errors.openAiCodex.unexpectedStreamError")) - } finally { - reader.releaseLock() + throw handleAiSdkError(error, "OpenAI Codex") } } - private async *processEvent(event: any, model: OpenAiCodexModel): ApiStream { - if (event?.response?.output && Array.isArray(event.response.output)) { - this.lastResponseOutput = event.response.output - } - if (event?.response?.id) { - this.lastResponseId = event.response.id as string - } - - // Handle text deltas - if (event?.type === "response.text.delta" || event?.type === "response.output_text.delta") { - if (event?.delta) { - yield { type: "text", text: event.delta } - } - return - } - - // Handle reasoning deltas - if ( - event?.type === "response.reasoning.delta" || - event?.type === "response.reasoning_text.delta" || - event?.type === "response.reasoning_summary.delta" || - event?.type === "response.reasoning_summary_text.delta" - ) { - if (event?.delta) { - yield { type: "reasoning", text: event.delta } - } - return - } - - // Handle refusal deltas - if (event?.type === "response.refusal.delta") { - if (event?.delta) { - yield { type: "text", text: `[Refusal] ${event.delta}` } - } - return - } - - // Handle tool/function call deltas - if ( - event?.type === "response.tool_call_arguments.delta" || - event?.type === "response.function_call_arguments.delta" - ) { - const callId = event.call_id || event.tool_call_id || event.id || this.pendingToolCallId - const name = event.name || event.function_name || this.pendingToolCallName - const args = event.delta || event.arguments - - // Codex/Responses may stream tool-call arguments, but these delta events are not guaranteed - // to include a stable id/name. Avoid emitting incomplete tool_call_partial chunks because - // NativeToolCallParser requires a name to start a call. - if (typeof callId === "string" && callId.length > 0 && typeof name === "string" && name.length > 0) { - yield { - type: "tool_call_partial", - index: event.index ?? 0, - id: callId, - name, - arguments: typeof args === "string" ? args : "", - } - } - return - } - - // Handle tool/function call completion - if ( - event?.type === "response.tool_call_arguments.done" || - event?.type === "response.function_call_arguments.done" - ) { - return - } - - // Handle output item events - if (event?.type === "response.output_item.added" || event?.type === "response.output_item.done") { - const item = event?.item - if (item) { - // Capture tool identity so subsequent argument deltas can be attributed. - if (item.type === "function_call" || item.type === "tool_call") { - const callId = item.call_id || item.tool_call_id || item.id - const name = item.name || item.function?.name || item.function_name - if (typeof callId === "string" && callId.length > 0) { - this.pendingToolCallId = callId - this.pendingToolCallName = typeof name === "string" ? name : undefined - } - } - - // For "added" events, yield text/reasoning content (streaming path) - // For "done" events, do NOT yield text/reasoning - it's already been streamed via deltas - // and would cause double-emission (A, B, C, ABC). - if (event.type === "response.output_item.added") { - if (item.type === "text" && item.text) { - yield { type: "text", text: item.text } - } else if (item.type === "reasoning" && item.text) { - yield { type: "reasoning", text: item.text } - } else if (item.type === "message" && Array.isArray(item.content)) { - for (const content of item.content) { - if ((content?.type === "text" || content?.type === "output_text") && content?.text) { - yield { type: "text", text: content.text } - } - } - } + /** + * Complete a prompt with auth retry logic. + */ + async completePrompt(prompt: string): Promise { + try { + return await this._completePromptImpl(prompt) + } catch (error) { + if (this.isAuthError(error)) { + const refreshed = await openAiCodexOAuthManager.forceRefreshAccessToken() + if (!refreshed) { + throw new Error( + t("common:errors.openAiCodex.notAuthenticated", { + defaultValue: + "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", + }), + ) } - - // Note: We intentionally do NOT emit tool_call from response.output_item.done - // for function_call/tool_call items. The streaming path handles tool calls via: - // 1. tool_call_partial events during argument deltas - // 2. NativeToolCallParser.finalizeRawChunks() at stream end emitting tool_call_end - // 3. NativeToolCallParser.finalizeStreamingToolCall() creating the final ToolUse - // Emitting tool_call here would cause duplicate tool rendering. - } - return - } - - // Handle completion events - if (event?.type === "response.done" || event?.type === "response.completed") { - const usage = event?.response?.usage || event?.usage || undefined - const usageData = this.normalizeUsage(usage, model) - if (usageData) { - yield usageData + return this._completePromptImpl(prompt) } - return - } - - // Fallbacks - if (event?.choices?.[0]?.delta?.content) { - yield { type: "text", text: event.choices[0].delta.content } - return - } - - if (event?.usage) { - const usageData = this.normalizeUsage(event.usage, model) - if (usageData) { - yield usageData - } - } - } - - private getReasoningEffort(model: OpenAiCodexModel): ReasoningEffortExtended | undefined { - const selected = (this.options.reasoningEffort as any) ?? (model.info.reasoningEffort as any) - return selected && selected !== "disable" && selected !== "none" ? (selected as any) : undefined - } - - override getModel() { - const modelId = this.options.apiModelId - - let id = modelId && modelId in openAiCodexModels ? (modelId as OpenAiCodexModelId) : openAiCodexDefaultModelId - - const info: ModelInfo = openAiCodexModels[id] - - const params = getModelParams({ - format: "openai", - modelId: id, - model: info, - settings: this.options, - defaultTemperature: 0, - }) - - return { id, info, ...params } - } - - getEncryptedContent(): { encrypted_content: string; id?: string } | undefined { - if (!this.lastResponseOutput) return undefined - - const reasoningItem = this.lastResponseOutput.find( - (item) => item.type === "reasoning" && item.encrypted_content, - ) - - if (!reasoningItem?.encrypted_content) return undefined - - return { - encrypted_content: reasoningItem.encrypted_content, - ...(reasoningItem.id ? { id: reasoningItem.id } : {}), + throw error } } - getResponseId(): string | undefined { - return this.lastResponseId - } - - async completePrompt(prompt: string): Promise { - this.abortController = new AbortController() - + /** + * Internal prompt completion implementation using AI SDK generateText. + */ + private async _completePromptImpl(prompt: string): Promise { try { const model = this.getModel() - - // Get access token - const accessToken = await openAiCodexOAuthManager.getAccessToken() - if (!accessToken) { - throw new Error( - t("common:errors.openAiCodex.notAuthenticated", { - defaultValue: - "Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow.", - }), - ) - } - - const reasoningEffort = this.getReasoningEffort(model) - - const requestBody: any = { - model: model.id, - input: [ - { - role: "user", - content: [{ type: "input_text", text: prompt }], - }, - ], - stream: false, - store: false, - ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), - } - - if (reasoningEffort) { - requestBody.reasoning = { - effort: reasoningEffort, - summary: "auto" as const, - } - } - - const url = `${CODEX_API_BASE_URL}/responses` - - // Get ChatGPT account ID for organization subscriptions - const accountId = await openAiCodexOAuthManager.getAccountId() - - // Build headers with required Codex-specific fields - const headers: Record = { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - originator: "roo-code", - session_id: this.sessionId, - "User-Agent": `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`, - } - - // Add ChatGPT-Account-Id if available - if (accountId) { - headers["ChatGPT-Account-Id"] = accountId - } - - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(requestBody), - signal: this.abortController.signal, + const { token, accountId } = await this.getOAuthCredentials() + const provider = this.createProvider(token, accountId) + const languageModel = provider.responses(model.id) + const openaiProviderOptions = this.buildProviderOptions(model) + + const { text } = await generateText({ + model: languageModel, + prompt, + // NOTE: No temperature — Codex backend does not support it + providerOptions: { + openai: openaiProviderOptions as Record, + }, }) - if (!response.ok) { - const errorText = await response.text() - throw new Error( - t("common:errors.openAiCodex.genericError", { status: response.status }) + - (errorText ? `: ${errorText}` : ""), - ) - } - - const responseData = await response.json() - - if (responseData?.output && Array.isArray(responseData.output)) { - for (const outputItem of responseData.output) { - if (outputItem.type === "message" && outputItem.content) { - for (const content of outputItem.content) { - if (content.type === "output_text" && content.text) { - return content.text - } - } - } - } - } - - if (responseData?.text) { - return responseData.text - } - - return "" + return text } catch (error) { const errorModel = this.getModel() const errorMessage = error instanceof Error ? error.message : String(error) @@ -1103,8 +395,21 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion throw new Error(t("common:errors.openAiCodex.completionError", { message: error.message })) } throw error - } finally { - this.abortController = undefined } } + + /** + * Extracts encrypted_content from the last response's reasoning items. + * Used for stateless API continuity across requests. + */ + getEncryptedContent(): { encrypted_content: string; id?: string } | undefined { + return this.lastEncryptedContent + } + + /** + * Returns the last response ID from the Responses API. + */ + getResponseId(): string | undefined { + return this.lastResponseId + } } diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index 8f810349abe..78abcee2061 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -3,13 +3,13 @@ * This provides a parallel implementation to OpenAiHandler using @ai-sdk/openai-compatible. */ -import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" import { streamText, generateText, LanguageModel, ToolSet } from "ai" import type { ModelInfo } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" import { @@ -124,7 +124,7 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si */ override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const model = this.getModel() diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 4779db83409..3242c012df0 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -1,8 +1,7 @@ import * as os from "os" import { v7 as uuidv7 } from "uuid" -import { Anthropic } from "@anthropic-ai/sdk" import { createOpenAI } from "@ai-sdk/openai" -import { streamText, generateText, ToolSet, type ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { Package } from "../../shared/package" import { @@ -14,8 +13,12 @@ import { type VerbosityLevel, type ReasoningEffortExtended, type ServiceTier, + ApiProviderError, } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" +import type { NeutralMessageParam } from "../../core/task-persistence" +import type { ModelMessage } from "ai" import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" @@ -34,6 +37,10 @@ import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from ". export type OpenAiNativeModel = ReturnType +// --------------------------------------------------------------------------- +// Encrypted reasoning helpers (used by createMessage for OpenAI Responses API) +// --------------------------------------------------------------------------- + /** * An encrypted reasoning item extracted from the conversation history. * These are standalone items injected by `buildCleanConversationHistory` with @@ -57,10 +64,8 @@ export interface EncryptedReasoningItem { * This function removes them BEFORE conversion. If an assistant message's * content becomes empty after filtering, the message is removed entirely. */ -export function stripPlainTextReasoningBlocks( - messages: Anthropic.Messages.MessageParam[], -): Anthropic.Messages.MessageParam[] { - return messages.reduce((acc, msg) => { +export function stripPlainTextReasoningBlocks(messages: NeutralMessageParam[]): NeutralMessageParam[] { + return messages.reduce((acc, msg) => { if (msg.role !== "assistant" || typeof msg.content === "string") { acc.push(msg) return acc @@ -92,7 +97,7 @@ export function stripPlainTextReasoningBlocks( * injected by `buildCleanConversationHistory` for OpenAI Responses API * reasoning continuity. */ -export function collectEncryptedReasoningItems(messages: Anthropic.Messages.MessageParam[]): EncryptedReasoningItem[] { +export function collectEncryptedReasoningItems(messages: NeutralMessageParam[]): EncryptedReasoningItem[] { const items: EncryptedReasoningItem[] = [] messages.forEach((msg, index) => { const m = msg as unknown as Record @@ -124,7 +129,7 @@ export function collectEncryptedReasoningItems(messages: Anthropic.Messages.Mess export function injectEncryptedReasoning( aiSdkMessages: ModelMessage[], encryptedItems: EncryptedReasoningItem[], - originalMessages: Anthropic.Messages.MessageParam[], + originalMessages: NeutralMessageParam[], ): void { if (encryptedItems.length === 0) return @@ -194,43 +199,29 @@ export function injectEncryptedReasoning( } /** - * OpenAI Native provider using the dedicated @ai-sdk/openai package. - * Uses the OpenAI Responses API by default (AI SDK 5+). - * Supports reasoning models, service tiers, verbosity control, - * encrypted reasoning content, and prompt cache retention. + * OpenAI Native provider using the AI SDK (@ai-sdk/openai) with the Responses API. + * Supports GPT-4o/4.1, o-series reasoning models, GPT-5 family, and Codex models. */ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - protected provider: ReturnType private readonly providerName = "OpenAI Native" + // Session ID for request tracking (persists for the lifetime of the handler) private readonly sessionId: string - + // Resolved service tier from last response + private lastServiceTier: ServiceTier | undefined + // Last response ID from Responses API private lastResponseId: string | undefined + // Last encrypted reasoning content for stateless continuity private lastEncryptedContent: { encrypted_content: string; id?: string } | undefined - private lastServiceTier: ServiceTier | undefined constructor(options: ApiHandlerOptions) { super() this.options = options this.sessionId = uuidv7() - + // Default to including reasoning summaries unless explicitly disabled if (this.options.enableResponsesReasoningSummary === undefined) { this.options.enableResponsesReasoningSummary = true } - - const apiKey = this.options.openAiNativeApiKey ?? "not-provided" - const baseURL = this.options.openAiNativeBaseUrl || undefined - const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` - - this.provider = createOpenAI({ - apiKey, - baseURL, - headers: { - originator: "roo-code", - session_id: this.sessionId, - "User-Agent": userAgent, - }, - }) } override getModel() { @@ -249,25 +240,48 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio defaultTemperature: OPENAI_NATIVE_DEFAULT_TEMPERATURE, }) + // The o3-mini models are named like "o3-mini-[reasoning-effort]", + // which are not valid model ids, so strip the suffix. return { id: id.startsWith("o3-mini") ? "o3-mini" : id, info, ...params, verbosity: params.verbosity } } + override isAiSdkProvider(): boolean { + return true + } + /** - * Get the language model for the configured model ID. - * Uses the Responses API (default for @ai-sdk/openai since AI SDK 5). + * Create the AI SDK OpenAI provider with per-request headers. + * Headers include session tracking, originator, and User-Agent. */ - protected getLanguageModel() { - const { id } = this.getModel() - return this.provider.responses(id) + private createProvider(metadata?: ApiHandlerCreateMessageMetadata) { + const apiKey = this.options.openAiNativeApiKey ?? "not-provided" + const baseUrl = this.options.openAiNativeBaseUrl + const taskId = metadata?.taskId + const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` + + return createOpenAI({ + apiKey, + baseURL: baseUrl || undefined, + headers: { + originator: "roo-code", + session_id: taskId || this.sessionId, + "User-Agent": userAgent, + }, + }) } + /** + * Get the reasoning effort for models that support it. + */ private getReasoningEffort(model: OpenAiNativeModel): ReasoningEffortExtended | undefined { - const selected = (this.options.reasoningEffort as any) ?? (model.info.reasoningEffort as any) - return selected && selected !== "disable" ? (selected as any) : undefined + const selected = + (this.options.reasoningEffort as ReasoningEffortExtended | undefined) ?? + (model.info.reasoningEffort as ReasoningEffortExtended | undefined) + return selected && selected !== ("disable" as string) ? selected : undefined } /** - * Returns the appropriate prompt cache retention policy for the given model, if any. + * Returns the appropriate prompt cache retention policy for the given model. */ private getPromptCacheRetention(model: OpenAiNativeModel): "24h" | undefined { if (!model.info.supportsPromptCache) return undefined @@ -276,14 +290,12 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } /** - * Returns a shallow-cloned ModelInfo with pricing overridden for the given tier, if available. + * Returns a shallow-cloned ModelInfo with pricing overridden for the given tier. */ private applyServiceTierPricing(info: ModelInfo, tier?: ServiceTier): ModelInfo { if (!tier || tier === "default") return info - const tierInfo = info.tiers?.find((t) => t.name === tier) if (!tierInfo) return info - return { ...info, inputPrice: tierInfo.inputPrice ?? info.inputPrice, @@ -294,52 +306,53 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } /** - * Build OpenAI-specific provider options for the Responses API. + * Build the providerOptions for the Responses API. + * Maps all Roo-specific settings to AI SDK's OpenAIResponsesProviderOptions. */ private buildProviderOptions( model: OpenAiNativeModel, metadata?: ApiHandlerCreateMessageMetadata, - ): Record { + ): Record { + const { verbosity } = model const reasoningEffort = this.getReasoningEffort(model) const promptCacheRetention = this.getPromptCacheRetention(model) + // Validate service tier against model support const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined const allowedTierNames = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) - const openaiOptions: Record = { + const opts: Record = { + // Always use stateless operation store: false, + // Reasoning configuration + ...(reasoningEffort + ? { + reasoningEffort, + include: ["reasoning.encrypted_content"], + } + : {}), + ...(reasoningEffort && this.options.enableResponsesReasoningSummary ? { reasoningSummary: "auto" } : {}), + // Service tier + ...(requestedTier && (requestedTier === "default" || allowedTierNames.has(requestedTier)) + ? { serviceTier: requestedTier } + : {}), + // Verbosity for GPT-5 models + ...(model.info.supportsVerbosity === true + ? { textVerbosity: (verbosity || "medium") as VerbosityLevel } + : {}), + // Prompt cache retention + ...(promptCacheRetention ? { promptCacheRetention } : {}), + // Tool configuration parallelToolCalls: metadata?.parallelToolCalls ?? true, } - if (reasoningEffort) { - openaiOptions.reasoningEffort = reasoningEffort - openaiOptions.include = ["reasoning.encrypted_content"] - - if (this.options.enableResponsesReasoningSummary) { - openaiOptions.reasoningSummary = "auto" - } - } - - if (model.info.supportsVerbosity === true) { - openaiOptions.textVerbosity = (model.verbosity || "medium") as VerbosityLevel - } - - if (requestedTier && (requestedTier === "default" || allowedTierNames.has(requestedTier))) { - openaiOptions.serviceTier = requestedTier - } - - if (promptCacheRetention) { - openaiOptions.promptCacheRetention = promptCacheRetention - } - - return { openai: openaiOptions } + return opts } /** - * Process usage metrics from the AI SDK response, including OpenAI-specific - * cache metrics and service-tier-adjusted pricing. + * Process usage metrics from the AI SDK response with cost calculation. */ - protected processUsageMetrics( + private processUsageMetrics( usage: { inputTokens?: number outputTokens?: number @@ -348,22 +361,21 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio reasoningTokens?: number } }, + providerMetadata: Record> | undefined, model: OpenAiNativeModel, - providerMetadata?: Record, ): ApiStreamUsageChunk { + const openaiMeta = providerMetadata?.openai as Record | undefined + const inputTokens = usage.inputTokens || 0 const outputTokens = usage.outputTokens || 0 - - const cacheReadTokens = usage.details?.cachedInputTokens ?? 0 - // The OpenAI Responses API does not report cache write tokens separately; - // only cached (read) tokens are available via usage.details.cachedInputTokens. - const cacheWriteTokens = 0 + const cacheReadTokens = usage.details?.cachedInputTokens ?? (openaiMeta?.cachedInputTokens as number) ?? 0 + const cacheWriteTokens = (openaiMeta?.cacheCreationInputTokens as number) ?? 0 const reasoningTokens = usage.details?.reasoningTokens + // Calculate cost with service tier pricing const effectiveTier = this.lastServiceTier || (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined const effectiveInfo = this.applyServiceTierPricing(model.info, effectiveTier) - const { totalCost } = calculateApiCostOpenAI( effectiveInfo, inputTokens, @@ -383,169 +395,165 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } } - /** - * Get the max output tokens parameter. - */ - protected getMaxOutputTokens(): number | undefined { - const model = this.getModel() - return model.maxTokens ?? undefined - } - - /** - * Create a message stream using the AI SDK. - */ override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const model = this.getModel() - const languageModel = this.getLanguageModel() + // Reset per-request state + this.lastServiceTier = undefined this.lastResponseId = undefined 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: [...] } - 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 unknown as Record).type !== "reasoning" || - !(msg as unknown as Record).encrypted_content, - ) - // 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 = convertToAiSdkMessages(cleanedMessages) - - // Step 5: 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) - } + const provider = this.createProvider(metadata) + const languageModel = provider.responses(model.id) + // Convert messages and tools to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined - const taskId = metadata?.taskId - const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` - const requestHeaders: Record = { - originator: "roo-code", - session_id: taskId || this.sessionId, - "User-Agent": userAgent, - } + // Build provider options for Responses API features + const openaiProviderOptions = this.buildProviderOptions(model, metadata) - const providerOptions = this.buildProviderOptions(model, metadata) + // Determine temperature (some models like GPT-5 don't support it) + const temperature = + model.info.supportsTemperature !== false + ? (this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE) + : undefined - const requestOptions: Parameters[0] = { + const result = streamText({ model: languageModel, system: systemPrompt, messages: aiSdkMessages, + temperature, + maxOutputTokens: model.maxTokens || undefined, tools: aiSdkTools, toolChoice: mapToolChoice(metadata?.tool_choice), - headers: requestHeaders, - providerOptions, - ...(model.info.supportsTemperature !== false && { - temperature: this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE, - }), - ...(model.maxTokens ? { maxOutputTokens: model.maxTokens } : {}), - } - - const result = streamText(requestOptions) + providerOptions: { + openai: openaiProviderOptions as Record, + }, + }) try { + // Process the full stream for await (const part of result.fullStream) { for (const chunk of processAiSdkStreamPart(part)) { yield chunk } } - const providerMeta = await result.providerMetadata - const openaiMeta = (providerMeta as any)?.openai + // Extract provider metadata after streaming + const usage = await result.usage + const providerMetadata = await result.providerMetadata + const openaiMeta = (providerMetadata as Record> | undefined)?.openai + // Store response ID and service tier for getResponseId() / cost calculation if (openaiMeta?.responseId) { - this.lastResponseId = openaiMeta.responseId + this.lastResponseId = openaiMeta.responseId as string } if (openaiMeta?.serviceTier) { this.lastServiceTier = openaiMeta.serviceTier as ServiceTier } - // Capture encrypted content from reasoning parts in the response + // Extract encrypted reasoning content from response for stateless continuity try { - const content = await (result as any).content - if (Array.isArray(content)) { - for (const part of content) { - if (part.type === "reasoning" && part.providerMetadata) { - const partMeta = (part.providerMetadata as any)?.openai - if (partMeta?.reasoningEncryptedContent) { - this.lastEncryptedContent = { - encrypted_content: partMeta.reasoningEncryptedContent, - ...(partMeta.itemId ? { id: partMeta.itemId } : {}), + const response = await result.response + if (response?.messages) { + for (const message of response.messages) { + if (!Array.isArray(message.content)) continue + for (const contentPart of message.content) { + if (contentPart.type === "reasoning") { + const reasoningMeta = ( + contentPart as { + providerMetadata?: { + openai?: { + itemId?: string + reasoningEncryptedContent?: string + } + } + } + ).providerMetadata?.openai + if (reasoningMeta?.reasoningEncryptedContent) { + this.lastEncryptedContent = { + encrypted_content: reasoningMeta.reasoningEncryptedContent, + ...(reasoningMeta.itemId ? { id: reasoningMeta.itemId } : {}), + } } - break } } } } } catch { - // Content parts with encrypted reasoning may not always be available + // Encrypted content extraction is best-effort } - const usage = await result.usage + // Yield usage metrics with cost calculation if (usage) { - yield this.processUsageMetrics(usage, model, providerMeta as any) + yield this.processUsageMetrics( + usage, + providerMetadata as Record> | undefined, + model, + ) } } catch (error) { - throw handleAiSdkError(error, this.providerName) + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") + TelemetryService.instance.captureException(apiError) + throw handleAiSdkError(error, "OpenAI Native") } } - /** - * Extracts encrypted_content and id from the last response's reasoning output. - */ - getEncryptedContent(): { encrypted_content: string; id?: string } | undefined { - return this.lastEncryptedContent - } - - getResponseId(): string | undefined { - return this.lastResponseId - } - - /** - * Complete a prompt using the AI SDK generateText. - */ async completePrompt(prompt: string): Promise { - const model = this.getModel() - const languageModel = this.getLanguageModel() - const providerOptions = this.buildProviderOptions(model) - try { + const model = this.getModel() + const provider = this.createProvider() + const languageModel = provider.responses(model.id) + const openaiProviderOptions = this.buildProviderOptions(model) + + const temperature = + model.info.supportsTemperature !== false + ? (this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE) + : undefined + const { text } = await generateText({ model: languageModel, prompt, - providerOptions, - ...(model.info.supportsTemperature !== false && { - temperature: this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE, - }), - ...(model.maxTokens ? { maxOutputTokens: model.maxTokens } : {}), + temperature, + maxOutputTokens: model.maxTokens || undefined, + providerOptions: { + openai: openaiProviderOptions as Record, + }, }) return text } catch (error) { - throw handleAiSdkError(error, this.providerName) + const errorModel = this.getModel() + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, errorModel.id, "completePrompt") + TelemetryService.instance.captureException(apiError) + + if (error instanceof Error) { + throw new Error(`OpenAI Native completion error: ${error.message}`) + } + throw error } } - override isAiSdkProvider(): boolean { - return true + /** + * Extracts encrypted_content from the last response's reasoning items. + * Used for stateless API continuity across requests. + */ + getEncryptedContent(): { encrypted_content: string; id?: string } | undefined { + return this.lastEncryptedContent + } + + /** + * Returns the last response ID from the Responses API. + */ + getResponseId(): string | undefined { + return this.lastResponseId } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 33b29abcafe..290f4cc2965 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -1,498 +1,211 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI, { AzureOpenAI } from "openai" +import { createOpenAI } from "@ai-sdk/openai" +import { streamText, generateText, ToolSet } from "ai" import axios from "axios" -import { - type ModelInfo, - azureOpenAiDefaultApiVersion, - openAiModelInfoSaneDefaults, - DEEP_SEEK_DEFAULT_TEMPERATURE, - OPENAI_AZURE_AI_INFERENCE_PATH, -} from "@roo-code/types" +import { type ModelInfo, openAiModelInfoSaneDefaults, azureOpenAiDefaultApiVersion } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" -import { TagMatcher } from "../../utils/tag-matcher" - -import { convertToOpenAiMessages } from "../transform/openai-format" -import { convertToR1Format } from "../transform/r1-format" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { getApiRequestTimeout } from "./utils/timeout-config" -import { handleOpenAIError } from "./utils/openai-error-handler" -// TODO: Rename this to OpenAICompatibleHandler. Also, I think the -// `OpenAINativeHandler` can subclass from this, since it's obviously -// compatible with the OpenAI API. We can also rename it to `OpenAIHandler`. +/** + * OpenAI-compatible provider using the AI SDK (@ai-sdk/openai). + * Supports regular OpenAI, Azure OpenAI, Azure AI Inference, and Grok xAI. + */ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - protected client: OpenAI - private readonly providerName = "OpenAI" constructor(options: ApiHandlerOptions) { super() this.options = options + } + + override getModel() { + const id = this.options.openAiModelId ?? "" + const info: ModelInfo = this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) + return { id, info, ...params } + } - const baseURL = this.options.openAiBaseUrl || "https://api.openai.com/v1" + override isAiSdkProvider(): boolean { + return true + } + + /** + * Create the AI SDK OpenAI provider with appropriate configuration. + * Handles regular OpenAI, Azure OpenAI, and Azure AI Inference. + */ + protected createProvider() { + const baseUrl = this.options.openAiBaseUrl || "https://api.openai.com/v1" const apiKey = this.options.openAiApiKey ?? "not-provided" - const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) - const urlHost = this._getUrlHost(this.options.openAiBaseUrl) - const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure + const isAzureAiInference = this._isAzureAiInference(baseUrl) + const urlHost = this._getUrlHost(baseUrl) + const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || this.options.openAiUseAzure - const headers = { + const customHeaders: Record = { ...DEFAULT_HEADERS, ...(this.options.openAiHeaders || {}), } - const timeout = getApiRequestTimeout() - if (isAzureAiInference) { - // Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure - this.client = new OpenAI({ - baseURL, - apiKey, - defaultHeaders: headers, - defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" }, - timeout, - }) - } else if (isAzureOpenAi) { - // Azure API shape slightly differs from the core API shape: - // https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai - this.client = new AzureOpenAI({ - baseURL, + // Azure AI Inference Service: adjust baseURL so AI SDK appends /chat/completions correctly + const apiVersion = this.options.azureApiVersion || "2024-05-01-preview" + return createOpenAI({ apiKey, - apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion, - defaultHeaders: headers, - timeout, + baseURL: `${baseUrl}/models`, + headers: customHeaders, + fetch: async (url, init) => { + const urlObj = new URL(url as string) + urlObj.searchParams.set("api-version", apiVersion) + return globalThis.fetch(urlObj.toString(), init) + }, }) - } else { - this.client = new OpenAI({ - baseURL, + } + + if (isAzureOpenAi) { + // Azure OpenAI uses api-key header and Azure-specific API versioning + return createOpenAI({ apiKey, - defaultHeaders: headers, - timeout, + baseURL: baseUrl || undefined, + headers: { + "api-key": apiKey, + ...customHeaders, + }, }) } + + return createOpenAI({ + apiKey, + baseURL: baseUrl || undefined, + headers: customHeaders, + }) } override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const { info: modelInfo, reasoning } = this.getModel() - const modelUrl = this.options.openAiBaseUrl ?? "" - const modelId = this.options.openAiModelId ?? "" - const enabledR1Format = this.options.openAiR1FormatEnabled ?? false - const isAzureAiInference = this._isAzureAiInference(modelUrl) - const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format - - if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) { - yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages, metadata) - return - } - - let systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = { - role: "system", - content: systemPrompt, - } + const { id: modelId, info: modelInfo, temperature, reasoning } = this.getModel() + const isO3Family = modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4") - if (this.options.openAiStreamingEnabled ?? true) { - let convertedMessages - - if (deepseekReasoner) { - convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) - } else { - if (modelInfo.supportsPromptCache) { - systemMessage = { - role: "system", - content: [ - { - type: "text", - text: systemPrompt, - // @ts-ignore-next-line - cache_control: { type: "ephemeral" }, - }, - ], - } - } + const provider = this.createProvider() + const model = provider.chat(modelId) - convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)] + // Convert messages and tools + const aiSdkMessages = convertToAiSdkMessages(messages) + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined - if (modelInfo.supportsPromptCache) { - // Note: the following logic is copied from openrouter: - // Add cache_control to the last two user messages - // (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message) - const lastTwoUserMessages = convertedMessages.filter((msg) => msg.role === "user").slice(-2) + // O3/O4 family uses developer role with modified prompt + const system = isO3Family ? `Formatting re-enabled\n${systemPrompt}` : systemPrompt - lastTwoUserMessages.forEach((msg) => { - if (typeof msg.content === "string") { - msg.content = [{ type: "text", text: msg.content }] - } + // Build provider options for OpenAI-specific features + const openaiProviderOptions: Record = {} - if (Array.isArray(msg.content)) { - // NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end. - let lastTextPart = msg.content.filter((part) => part.type === "text").pop() - - if (!lastTextPart) { - lastTextPart = { type: "text", text: "..." } - msg.content.push(lastTextPart) - } - - // @ts-ignore-next-line - lastTextPart["cache_control"] = { type: "ephemeral" } - } - }) - } - } - - const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl) - - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: modelId, - temperature: this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), - messages: convertedMessages, - stream: true as const, - ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), - ...(reasoning && reasoning), - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, - } - - // Add max_tokens if needed - this.addMaxTokensIfNeeded(requestOptions, modelInfo) - - let stream - try { - stream = await this.client.chat.completions.create( - requestOptions, - isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, - ) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - - const matcher = new TagMatcher( - "think", - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) - - let lastUsage - const activeToolCallIds = new Set() - - for await (const chunk of stream) { - const delta = chunk.choices?.[0]?.delta ?? {} - const finishReason = chunk.choices?.[0]?.finish_reason - - if (delta.content) { - for (const chunk of matcher.update(delta.content)) { - yield chunk - } - } - - if ("reasoning_content" in delta && delta.reasoning_content) { - yield { - type: "reasoning", - text: (delta.reasoning_content as string | undefined) || "", - } - } - - yield* this.processToolCalls(delta, finishReason, activeToolCallIds) - - if (chunk.usage) { - lastUsage = chunk.usage - } - } - - for (const chunk of matcher.final()) { - yield chunk - } - - if (lastUsage) { - yield this.processUsageMetrics(lastUsage, modelInfo) - } - } else { - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: modelId, - messages: deepseekReasoner - ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) - : [systemMessage, ...convertToOpenAiMessages(messages)], - // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS) - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, - } - - // Add max_tokens if needed - this.addMaxTokensIfNeeded(requestOptions, modelInfo) - - let response - try { - response = await this.client.chat.completions.create( - requestOptions, - this._isAzureAiInference(modelUrl) ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, - ) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - - const message = response.choices?.[0]?.message - - if (message?.tool_calls) { - for (const toolCall of message.tool_calls) { - if (toolCall.type === "function") { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.function.name, - arguments: toolCall.function.arguments, - } - } - } - } - - yield { - type: "text", - text: message?.content || "", - } - - yield this.processUsageMetrics(response.usage, modelInfo) + if (isO3Family) { + openaiProviderOptions.systemMessageMode = "developer" } - } - protected processUsageMetrics(usage: any, _modelInfo?: ModelInfo): ApiStreamUsageChunk { - return { - type: "usage", - inputTokens: usage?.prompt_tokens || 0, - outputTokens: usage?.completion_tokens || 0, - cacheWriteTokens: usage?.cache_creation_input_tokens || undefined, - cacheReadTokens: usage?.cache_read_input_tokens || undefined, + if (reasoning?.reasoning_effort) { + openaiProviderOptions.reasoningEffort = reasoning.reasoning_effort } - } - override getModel() { - const id = this.options.openAiModelId ?? "" - const info: ModelInfo = this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults - const params = getModelParams({ - format: "openai", - modelId: id, - model: info, - settings: this.options, - defaultTemperature: 0, + // maxOutputTokens: only include when includeMaxTokens is true + const maxOutputTokens = + this.options.includeMaxTokens === true + ? this.options.modelMaxTokens || modelInfo.maxTokens || undefined + : undefined + + const result = streamText({ + model, + system, + messages: aiSdkMessages, + temperature: isO3Family ? undefined : (this.options.modelTemperature ?? temperature ?? 0), + maxOutputTokens, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + providerOptions: + Object.keys(openaiProviderOptions).length > 0 ? { openai: openaiProviderOptions } : undefined, }) - return { id, info, ...params } - } - async completePrompt(prompt: string): Promise { try { - const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) - const model = this.getModel() - const modelInfo = model.info - - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: model.id, - messages: [{ role: "user", content: prompt }], - } - - // Add max_tokens if needed - this.addMaxTokensIfNeeded(requestOptions, modelInfo) - - let response - try { - response = await this.client.chat.completions.create( - requestOptions, - isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, - ) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - - return response.choices?.[0]?.message.content || "" - } catch (error) { - if (error instanceof Error) { - throw new Error(`${this.providerName} completion error: ${error.message}`) - } - - throw error - } - } - - private async *handleO3FamilyMessage( - modelId: string, - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - const modelInfo = this.getModel().info - const methodIsAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) - - if (this.options.openAiStreamingEnabled ?? true) { - const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl) - - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: modelId, - messages: [ - { - role: "developer", - content: `Formatting re-enabled\n${systemPrompt}`, - }, - ...convertToOpenAiMessages(messages), - ], - stream: true, - ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), - reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined, - temperature: undefined, - // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS) - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, - } - - // O3 family models do not support the deprecated max_tokens parameter - // but they do support max_completion_tokens (the modern OpenAI parameter) - // This allows O3 models to limit response length when includeMaxTokens is enabled - this.addMaxTokensIfNeeded(requestOptions, modelInfo) - - let stream - try { - stream = await this.client.chat.completions.create( - requestOptions, - methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, - ) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - - yield* this.handleStreamResponse(stream) - } else { - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: modelId, - messages: [ - { - role: "developer", - content: `Formatting re-enabled\n${systemPrompt}`, - }, - ...convertToOpenAiMessages(messages), - ], - reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined, - temperature: undefined, - // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS) - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, - } - - // O3 family models do not support the deprecated max_tokens parameter - // but they do support max_completion_tokens (the modern OpenAI parameter) - // This allows O3 models to limit response length when includeMaxTokens is enabled - this.addMaxTokensIfNeeded(requestOptions, modelInfo) - - let response - try { - response = await this.client.chat.completions.create( - requestOptions, - methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, - ) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - - const message = response.choices?.[0]?.message - if (message?.tool_calls) { - for (const toolCall of message.tool_calls) { - if (toolCall.type === "function") { - yield { - type: "tool_call", - id: toolCall.id, - name: toolCall.function.name, - arguments: toolCall.function.arguments, - } - } + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk } } - yield { - type: "text", - text: message?.content || "", + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, providerMetadata) } - yield this.processUsageMetrics(response.usage) + } catch (error) { + throw handleAiSdkError(error, "OpenAI") } } - private async *handleStreamResponse(stream: AsyncIterable): ApiStream { - const activeToolCallIds = new Set() - - for await (const chunk of stream) { - const delta = chunk.choices?.[0]?.delta - const finishReason = chunk.choices?.[0]?.finish_reason - - if (delta) { - if (delta.content) { - yield { - type: "text", - text: delta.content, - } - } - - yield* this.processToolCalls(delta, finishReason, activeToolCallIds) - } + async completePrompt(prompt: string): Promise { + const { id: modelId, temperature } = this.getModel() + const provider = this.createProvider() + const model = provider.chat(modelId) - if (chunk.usage) { - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - } + try { + const { text } = await generateText({ + model, + prompt, + temperature: this.options.modelTemperature ?? temperature ?? 0, + }) + return text + } catch (error) { + if (error instanceof Error) { + throw new Error(`OpenAI completion error: ${error.message}`) } + throw error } } /** - * Helper generator to process tool calls from a stream chunk. - * Tracks active tool call IDs and yields tool_call_partial and tool_call_end events. - * @param delta - The delta object from the stream chunk - * @param finishReason - The finish_reason from the stream chunk - * @param activeToolCallIds - Set to track active tool call IDs (mutated in place) + * Process usage metrics from the AI SDK response, including OpenAI's cache metrics. */ - private *processToolCalls( - delta: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta | undefined, - finishReason: string | null | undefined, - activeToolCallIds: Set, - ): Generator< - | { type: "tool_call_partial"; index: number; id?: string; name?: string; arguments?: string } - | { type: "tool_call_end"; id: string } - > { - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - if (toolCall.id) { - activeToolCallIds.add(toolCall.id) - } - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - // Emit tool_call_end events when finish_reason is "tool_calls" - // This ensures tool calls are finalized even if the stream doesn't properly close - if (finishReason === "tool_calls" && activeToolCallIds.size > 0) { - for (const id of activeToolCallIds) { - yield { type: "tool_call_end", id } + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number } - activeToolCallIds.clear() + }, + providerMetadata?: Record>, + ): ApiStreamUsageChunk { + const openaiMeta = providerMetadata?.openai as Record | undefined + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheWriteTokens: (openaiMeta?.cacheCreationInputTokens as number) ?? undefined, + cacheReadTokens: usage.details?.cachedInputTokens ?? (openaiMeta?.cachedInputTokens as number) ?? undefined, } } @@ -504,34 +217,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } - private _isGrokXAI(baseUrl?: string): boolean { - const urlHost = this._getUrlHost(baseUrl) - return urlHost.includes("x.ai") - } - protected _isAzureAiInference(baseUrl?: string): boolean { const urlHost = this._getUrlHost(baseUrl) return urlHost.endsWith(".services.ai.azure.com") } - - /** - * Adds max_completion_tokens to the request body if needed based on provider configuration - * Note: max_tokens is deprecated in favor of max_completion_tokens as per OpenAI documentation - * O3 family models handle max_tokens separately in handleO3FamilyMessage - */ - protected addMaxTokensIfNeeded( - requestOptions: - | OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming - | OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming, - modelInfo: ModelInfo, - ): void { - // Only add max_completion_tokens if includeMaxTokens is true - if (this.options.includeMaxTokens === true) { - // Use user-configured modelMaxTokens if available, otherwise fall back to model's default maxTokens - // Using max_completion_tokens as max_tokens is deprecated - requestOptions.max_completion_tokens = this.options.modelMaxTokens || modelInfo.maxTokens - } - } } export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiHeaders?: Record) { diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 7fcc24b15f6..5065db3027a 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -1,9 +1,9 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" -import { z } from "zod" +import { createOpenRouter } from "@openrouter/ai-sdk-provider" +import { streamText, generateText, ToolSet, type SystemModelMessage } from "ai" import { type ModelRecord, + type ModelInfo, ApiProviderError, openRouterDefaultModelId, openRouterDefaultModelInfo, @@ -13,22 +13,20 @@ import { } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" - +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" import { - convertToOpenAiMessages, - sanitizeGeminiMessages, - consolidateReasoningDetails, -} from "../transform/openai-format" -import { normalizeMistralToolCallId } from "../transform/mistral-format" -import { ApiStreamChunk } from "../transform/stream" -import { convertToR1Format } from "../transform/r1-format" -import { addCacheBreakpoints as addAnthropicCacheBreakpoints } from "../transform/caching/anthropic" -import { addCacheBreakpoints as addGeminiCacheBreakpoints } from "../transform/caching/gemini" + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import type { OpenRouterReasoningParams } from "../transform/reasoning" import { getModelParams } from "../transform/model-params" +import { buildCachedSystemMessage, applyCacheBreakpoints } from "../transform/caching" import { getModels } from "./fetchers/modelCache" import { getModelEndpoints } from "./fetchers/modelEndpointCache" @@ -36,125 +34,23 @@ import { getModelEndpoints } from "./fetchers/modelEndpointCache" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index" -import { handleOpenAIError } from "./utils/openai-error-handler" import { generateImageWithProvider, ImageGenerationResult } from "./utils/image-generation" import { applyRouterToolPreferences } from "./utils/router-tool-preferences" -// Add custom interface for OpenRouter params. -type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { - transforms?: string[] - include_reasoning?: boolean - // https://openrouter.ai/docs/use-cases/reasoning-tokens - reasoning?: OpenRouterReasoningParams -} - -// Zod schema for OpenRouter error response structure (for caught exceptions) -const OpenRouterErrorResponseSchema = z.object({ - error: z - .object({ - message: z.string().optional(), - code: z.number().optional(), - metadata: z - .object({ - raw: z.string().optional(), - }) - .optional(), - }) - .optional(), -}) - -// OpenRouter error structure that may include error.metadata.raw with actual upstream error -// This is for caught exceptions which have the error wrapped in an "error" property -interface OpenRouterErrorResponse { - error?: { - message?: string - code?: number - metadata?: { raw?: string } - } -} - -// Direct error object structure (for streaming errors passed directly) -interface OpenRouterError { - message?: string - code?: number - metadata?: { raw?: string } -} - /** - * Helper function to parse and extract error message from metadata.raw - * metadata.raw is often a JSON encoded string that may contain .message or .error fields - * Example structures: - * - {"message": "Error text"} - * - {"error": "Error text"} - * - {"error": {"message": "Error text"}} - * - {"type":"error","error":{"type":"invalid_request_error","message":"tools: Tool names must be unique."}} + * OpenRouter provider using the AI SDK (@openrouter/ai-sdk-provider). + * Supports routing across multiple upstream providers with provider-specific features. */ -function extractErrorFromMetadataRaw(raw: string | undefined): string | undefined { - if (!raw) { - return undefined - } - - try { - const parsed = JSON.parse(raw) - // Check for common error message fields - if (typeof parsed === "object" && parsed !== null) { - // Check for direct message field - if (typeof parsed.message === "string") { - return parsed.message - } - // Check for nested error.message field (e.g., Anthropic error format) - if (typeof parsed.error === "object" && parsed.error !== null && typeof parsed.error.message === "string") { - return parsed.error.message - } - // Check for error as a string - if (typeof parsed.error === "string") { - return parsed.error - } - } - // If we can't extract a specific field, return the raw string - return raw - } catch { - // If it's not valid JSON, return as-is - return raw - } -} - -// See `OpenAI.Chat.Completions.ChatCompletionChunk["usage"]` -// `CompletionsAPI.CompletionUsage` -// See also: https://openrouter.ai/docs/use-cases/usage-accounting -interface CompletionUsage { - completion_tokens?: number - completion_tokens_details?: { - reasoning_tokens?: number - } - prompt_tokens?: number - prompt_tokens_details?: { - cached_tokens?: number - } - total_tokens?: number - cost?: number - cost_details?: { - upstream_inference_cost?: number - } -} - export class OpenRouterHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: OpenAI protected models: ModelRecord = {} protected endpoints: ModelRecord = {} private readonly providerName = "OpenRouter" - private currentReasoningDetails: any[] = [] constructor(options: ApiHandlerOptions) { super() this.options = options - const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1" - const apiKey = this.options.openRouterApiKey ?? "not-provided" - - this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: DEFAULT_HEADERS }) - // Load models asynchronously to populate cache before getModel() is called this.loadDynamicModels().catch((error) => { console.error("[OpenRouterHandler] Failed to load dynamic models:", error) @@ -182,355 +78,48 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } - getReasoningDetails(): any[] | undefined { - return this.currentReasoningDetails.length > 0 ? this.currentReasoningDetails : undefined + override isAiSdkProvider(): boolean { + return true } /** - * Handle OpenRouter streaming error response and report to telemetry. - * OpenRouter may include metadata.raw with the actual upstream provider error. - * @param error The error object (not wrapped - receives the error directly) + * Create the AI SDK OpenRouter provider with appropriate configuration. */ - private handleStreamingError(error: OpenRouterError, modelId: string, operation: string): never { - const rawString = error?.metadata?.raw - const parsedError = extractErrorFromMetadataRaw(rawString) - const rawErrorMessage = parsedError || error?.message || "Unknown error" - - const apiError = Object.assign( - new ApiProviderError(rawErrorMessage, this.providerName, modelId, operation, error?.code), - { status: error?.code, error }, - ) - - TelemetryService.instance.captureException(apiError) + protected createProvider() { + const baseURL = this.options.openRouterBaseUrl || undefined + const apiKey = this.options.openRouterApiKey ?? "not-provided" - throw new Error(`OpenRouter API Error ${error?.code}: ${rawErrorMessage}`) + return createOpenRouter({ + apiKey, + baseURL: baseURL || undefined, + headers: { ...DEFAULT_HEADERS }, + compatibility: "strict", + }) } - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): AsyncGenerator { - const model = await this.fetchModel() - - let { id: modelId, maxTokens, temperature, topP, reasoning } = model - - // Reset reasoning_details accumulator for this request - this.currentReasoningDetails = [] - - // OpenRouter sends reasoning tokens by default for Gemini 2.5 Pro models - // even if you don't request them. This is not the default for - // other providers (including Gemini), so we need to explicitly disable - // them unless the user has explicitly configured reasoning. - // Note: Gemini 3 models use reasoning_details format with thought signatures, - // but we handle this via skip_thought_signature_validator injection below. - if ( - (modelId === "google/gemini-2.5-pro-preview" || modelId === "google/gemini-2.5-pro") && - typeof reasoning === "undefined" - ) { - reasoning = { exclude: true } - } - - // Convert Anthropic messages to OpenAI format. - // Pass normalization function for Mistral compatibility (requires 9-char alphanumeric IDs) - const isMistral = modelId.toLowerCase().includes("mistral") - let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages( - messages, - isMistral ? { normalizeToolCallId: normalizeMistralToolCallId } : undefined, - ), - ] - - // DeepSeek highly recommends using user instead of system role. - if (modelId.startsWith("deepseek/deepseek-r1") || modelId === "perplexity/sonar-reasoning") { - openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) - } - - // Process reasoning_details when switching models to Gemini. - const isGemini = modelId.startsWith("google/gemini") - - // For Gemini models with native protocol: - // 1. Sanitize messages to handle thought signature validation issues. - // This must happen BEFORE fake encrypted block injection to avoid injecting for - // tool calls that will be dropped due to missing/mismatched reasoning_details. - // 2. Inject fake reasoning.encrypted block for tool calls without existing encrypted reasoning. - // This is required when switching from other models to Gemini to satisfy API validation. - // Per OpenRouter documentation (conversation with Toven, Nov 2025): - // - Create ONE reasoning_details entry per assistant message with tool calls - // - Set `id` to the FIRST tool call's ID from the tool_calls array - // - Set `data` to "skip_thought_signature_validator" to bypass signature validation - // - Set `index` to 0 - // See: https://github.com/cline/cline/issues/8214 - if (isGemini) { - // Step 1: Sanitize messages - filter out tool calls with missing/mismatched reasoning_details - openAiMessages = sanitizeGeminiMessages(openAiMessages, modelId) - - // Step 2: Inject fake reasoning.encrypted block for tool calls that survived sanitization - openAiMessages = openAiMessages.map((msg) => { - if (msg.role === "assistant") { - const toolCalls = (msg as any).tool_calls as any[] | undefined - const existingDetails = (msg as any).reasoning_details as any[] | undefined - - // Only inject if there are tool calls and no existing encrypted reasoning - if (toolCalls && toolCalls.length > 0) { - const hasEncrypted = existingDetails?.some((d) => d.type === "reasoning.encrypted") ?? false - - if (!hasEncrypted) { - // Create ONE fake encrypted block with the FIRST tool call's ID - // This is the documented format from OpenRouter for skipping thought signature validation - const fakeEncrypted = { - type: "reasoning.encrypted", - data: "skip_thought_signature_validator", - id: toolCalls[0].id, - format: "google-gemini-v1", - index: 0, - } - - return { - ...msg, - reasoning_details: [...(existingDetails ?? []), fakeEncrypted], - } - } - } - } - return msg - }) - } - - // https://openrouter.ai/docs/features/prompt-caching - // TODO: Add a `promptCacheStratey` field to `ModelInfo`. - if (OPEN_ROUTER_PROMPT_CACHING_MODELS.has(modelId)) { - if (modelId.startsWith("google")) { - addGeminiCacheBreakpoints(systemPrompt, openAiMessages) - } else { - addAnthropicCacheBreakpoints(systemPrompt, openAiMessages) - } - } - - // https://openrouter.ai/docs/transforms - const completionParams: OpenRouterChatCompletionParams = { - model: modelId, - ...(maxTokens && maxTokens > 0 && { max_tokens: maxTokens }), - temperature, - top_p: topP, - messages: openAiMessages, - stream: true, - stream_options: { include_usage: true }, - // Only include provider if openRouterSpecificProvider is not "[default]". - ...(this.options.openRouterSpecificProvider && - this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME && { - provider: { - order: [this.options.openRouterSpecificProvider], - only: [this.options.openRouterSpecificProvider], - allow_fallbacks: false, - }, - }), - ...(reasoning && { reasoning }), - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - } - - // Add Anthropic beta header for fine-grained tool streaming when using Anthropic models - const requestOptions = modelId.startsWith("anthropic/") - ? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } } - : undefined - - let stream - try { - stream = await this.client.chat.completions.create(completionParams, requestOptions) - } catch (error) { - // Try to parse as OpenRouter error structure using Zod - const parseResult = OpenRouterErrorResponseSchema.safeParse(error) - - if (parseResult.success && parseResult.data.error) { - const openRouterError = parseResult.data - const rawString = openRouterError.error?.metadata?.raw - const parsedError = extractErrorFromMetadataRaw(rawString) - const rawErrorMessage = parsedError || openRouterError.error?.message || "Unknown error" - - const apiError = Object.assign( - new ApiProviderError( - rawErrorMessage, - this.providerName, - modelId, - "createMessage", - openRouterError.error?.code, - ), - { - status: openRouterError.error?.code, - error: openRouterError.error, - }, - ) + override getModel() { + const id = this.options.openRouterModelId ?? openRouterDefaultModelId + let info: ModelInfo = this.models[id] ?? openRouterDefaultModelInfo - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) - } else { - // Fallback for non-OpenRouter errors - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) - } + // If a specific provider is requested, use the endpoint for that provider. + if (this.options.openRouterSpecificProvider && this.endpoints[this.options.openRouterSpecificProvider]) { + info = this.endpoints[this.options.openRouterSpecificProvider] } - let lastUsage: CompletionUsage | undefined = undefined - // Accumulator for reasoning_details FROM the API. - // We preserve the original shape of reasoning_details to prevent malformed responses. - const reasoningDetailsAccumulator = new Map< - string, - { - type: string - text?: string - summary?: string - data?: string - id?: string | null - format?: string - signature?: string - index: number - } - >() - - // Track whether we've yielded displayable text from reasoning_details. - // When reasoning_details has displayable content (reasoning.text or reasoning.summary), - // we skip yielding the top-level reasoning field to avoid duplicate display. - let hasYieldedReasoningFromDetails = false - - for await (const chunk of stream) { - // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. - if ("error" in chunk) { - this.handleStreamingError(chunk.error as OpenRouterError, modelId, "createMessage") - } - - const delta = chunk.choices[0]?.delta - const finishReason = chunk.choices[0]?.finish_reason - - if (delta) { - // Handle reasoning_details array format (used by Gemini 3, Claude, OpenAI o-series, etc.) - // See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks - // Priority: Check for reasoning_details first, as it's the newer format - const deltaWithReasoning = delta as typeof delta & { - reasoning_details?: Array<{ - type: string - text?: string - summary?: string - data?: string - id?: string | null - format?: string - signature?: string - index?: number - }> - } - - if (deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details)) { - for (const detail of deltaWithReasoning.reasoning_details) { - const index = detail.index ?? 0 - const key = `${detail.type}-${index}` - const existing = reasoningDetailsAccumulator.get(key) - - if (existing) { - // Accumulate text/summary/data for existing reasoning detail - if (detail.text !== undefined) { - existing.text = (existing.text || "") + detail.text - } - if (detail.summary !== undefined) { - existing.summary = (existing.summary || "") + detail.summary - } - if (detail.data !== undefined) { - existing.data = (existing.data || "") + detail.data - } - // Update other fields if provided - if (detail.id !== undefined) existing.id = detail.id - if (detail.format !== undefined) existing.format = detail.format - if (detail.signature !== undefined) existing.signature = detail.signature - } else { - // Start new reasoning detail accumulation - reasoningDetailsAccumulator.set(key, { - type: detail.type, - text: detail.text, - summary: detail.summary, - data: detail.data, - id: detail.id, - format: detail.format, - signature: detail.signature, - index, - }) - } - - // Yield text for display (still fragmented for live streaming) - // Only reasoning.text and reasoning.summary have displayable content - // reasoning.encrypted is intentionally skipped as it contains redacted content - let reasoningText: string | undefined - if (detail.type === "reasoning.text" && typeof detail.text === "string") { - reasoningText = detail.text - } else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") { - reasoningText = detail.summary - } - - if (reasoningText) { - hasYieldedReasoningFromDetails = true - yield { type: "reasoning", text: reasoningText } - } - } - } - - // Handle top-level reasoning field for UI display. - // Skip if we've already yielded from reasoning_details to avoid duplicate display. - if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { - if (!hasYieldedReasoningFromDetails) { - yield { type: "reasoning", text: delta.reasoning } - } - } - - // Emit raw tool call chunks - NativeToolCallParser handles state management - if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - if (delta.content) { - yield { type: "text", text: delta.content } - } - } - - // Process finish_reason to emit tool_call_end events - // This ensures tool calls are finalized even if the stream doesn't properly close - if (finishReason) { - const endEvents = NativeToolCallParser.processFinishReason(finishReason) - for (const event of endEvents) { - yield event - } - } + // Apply tool preferences for models accessed through routers (OpenAI, Gemini) + info = applyRouterToolPreferences(id, info) - if (chunk.usage) { - lastUsage = chunk.usage - } - } + const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || id === "perplexity/sonar-reasoning" - // After streaming completes, consolidate and store reasoning_details from the API. - // This filters out corrupted encrypted blocks (missing `data`) and consolidates by index. - if (reasoningDetailsAccumulator.size > 0) { - const rawDetails = Array.from(reasoningDetailsAccumulator.values()) - this.currentReasoningDetails = consolidateReasoningDetails(rawDetails) - } + const params = getModelParams({ + format: "openrouter", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0, + }) - if (lastUsage) { - yield { - type: "usage", - inputTokens: lastUsage.prompt_tokens || 0, - outputTokens: lastUsage.completion_tokens || 0, - cacheReadTokens: lastUsage.prompt_tokens_details?.cached_tokens, - reasoningTokens: lastUsage.completion_tokens_details?.reasoning_tokens, - totalCost: (lastUsage.cost_details?.upstream_inference_cost || 0) + (lastUsage.cost || 0), - } - } + return { id, info, topP: isDeepSeekR1 ? 0.95 : undefined, ...params } } public async fetchModel() { @@ -549,107 +138,180 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH return this.getModel() } - override getModel() { - const id = this.options.openRouterModelId ?? openRouterDefaultModelId - let info = this.models[id] ?? openRouterDefaultModelInfo + /** + * Build OpenRouter provider options for routing, reasoning, and usage accounting. + */ + private buildProviderOptions(modelId: string, reasoning: OpenRouterReasoningParams | undefined) { + const openrouterOptions: Record = {} - // If a specific provider is requested, use the endpoint for that provider. - if (this.options.openRouterSpecificProvider && this.endpoints[this.options.openRouterSpecificProvider]) { - info = this.endpoints[this.options.openRouterSpecificProvider] + // Provider routing + if ( + this.options.openRouterSpecificProvider && + this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME + ) { + openrouterOptions.provider = { + order: [this.options.openRouterSpecificProvider], + only: [this.options.openRouterSpecificProvider], + allow_fallbacks: false, + } } - // Apply tool preferences for models accessed through routers (OpenAI, Gemini) - info = applyRouterToolPreferences(id, info) - - const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || id === "perplexity/sonar-reasoning" + // Reasoning configuration + if (reasoning) { + openrouterOptions.reasoning = reasoning + } - const params = getModelParams({ - format: "openrouter", - modelId: id, - model: info, - settings: this.options, - defaultTemperature: isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0, - }) + // Usage accounting + openrouterOptions.usage = { include: true } - return { id, info, topP: isDeepSeekR1 ? 0.95 : undefined, ...params } + return { openrouter: openrouterOptions } } - async completePrompt(prompt: string) { - let { id: modelId, maxTokens, temperature, reasoning } = await this.fetchModel() + override async *createMessage( + systemPrompt: string, + messages: NeutralMessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const model = await this.fetchModel() + let { id: modelId, info: modelInfo, maxTokens, temperature, topP, reasoning } = model - const completionParams: OpenRouterChatCompletionParams = { - model: modelId, - max_tokens: maxTokens, - temperature, - messages: [{ role: "user", content: prompt }], - stream: false, - // Only include provider if openRouterSpecificProvider is not "[default]". - ...(this.options.openRouterSpecificProvider && - this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME && { - provider: { - order: [this.options.openRouterSpecificProvider], - only: [this.options.openRouterSpecificProvider], - allow_fallbacks: false, - }, - }), - ...(reasoning && { reasoning }), + // OpenRouter sends reasoning tokens by default for Gemini 2.5 Pro models + // even if you don't request them. Explicitly disable them unless the user + // has explicitly configured reasoning. + if ( + (modelId === "google/gemini-2.5-pro-preview" || modelId === "google/gemini-2.5-pro") && + typeof reasoning === "undefined" + ) { + reasoning = { exclude: true } + } + + const provider = this.createProvider() + const languageModel = provider.chat(modelId) + + // Convert messages and tools + const aiSdkMessages = convertToAiSdkMessages(messages) + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + // Build system prompt with optional cache control for prompt caching models + let system: string | SystemModelMessage = systemPrompt + + if (OPEN_ROUTER_PROMPT_CACHING_MODELS.has(modelId)) { + system = buildCachedSystemMessage(systemPrompt, "openrouter") + + if (modelId.startsWith("google")) { + applyCacheBreakpoints(aiSdkMessages, "openrouter", { style: "every-nth", frequency: 10 }) + } else { + applyCacheBreakpoints(aiSdkMessages, "openrouter") + } } + // Build provider options for routing, reasoning, and usage + const providerOptions = this.buildProviderOptions(modelId, reasoning) + // Add Anthropic beta header for fine-grained tool streaming when using Anthropic models - const requestOptions = modelId.startsWith("anthropic/") - ? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } } + const headers = modelId.startsWith("anthropic/") + ? { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } : undefined - let response + const result = streamText({ + model: languageModel, + system, + messages: aiSdkMessages, + temperature, + topP, + maxOutputTokens: maxTokens && maxTokens > 0 ? maxTokens : undefined, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + providerOptions: providerOptions as any, + headers, + }) try { - response = await this.client.chat.completions.create(completionParams, requestOptions) - } catch (error) { - // Try to parse as OpenRouter error structure using Zod - const parseResult = OpenRouterErrorResponseSchema.safeParse(error) - - if (parseResult.success && parseResult.data.error) { - const openRouterError = parseResult.data - const rawString = openRouterError.error?.metadata?.raw - const parsedError = extractErrorFromMetadataRaw(rawString) - const rawErrorMessage = parsedError || openRouterError.error?.message || "Unknown error" - - const apiError = Object.assign( - new ApiProviderError( - rawErrorMessage, - this.providerName, - modelId, - "completePrompt", - openRouterError.error?.code, - ), - { - status: openRouterError.error?.code, - error: openRouterError.error, - }, - ) + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) - } else { - // Fallback for non-OpenRouter errors - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics( + usage, + providerMetadata as Record> | undefined, + ) } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") + TelemetryService.instance.captureException(apiError) + throw handleAiSdkError(error, this.providerName) } + } - if ("error" in response) { - this.handleStreamingError(response.error as OpenRouterError, modelId, "completePrompt") + async completePrompt(prompt: string): Promise { + const model = await this.fetchModel() + const { id: modelId, maxTokens, temperature, reasoning } = model + + const provider = this.createProvider() + const languageModel = provider.chat(modelId) + + // Build provider options for routing, reasoning, and usage + const providerOptions = this.buildProviderOptions(modelId, reasoning) + + try { + const { text } = await generateText({ + model: languageModel, + prompt, + temperature, + maxOutputTokens: maxTokens || undefined, + providerOptions: providerOptions as any, + }) + return text + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") + TelemetryService.instance.captureException(apiError) + throw handleAiSdkError(error, this.providerName) } + } - const completion = response as OpenAI.Chat.ChatCompletion - return completion.choices[0]?.message?.content || "" + /** + * Process usage metrics from the AI SDK response, including OpenRouter cost information. + * @see https://openrouter.ai/docs/use-cases/usage-accounting + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + }, + providerMetadata?: Record>, + ): ApiStreamUsageChunk { + const openrouterMeta = providerMetadata?.openrouter as Record | undefined + const usageAccounting = openrouterMeta?.usage as Record | undefined + + // Extract detailed token info from OpenRouter usage accounting + const promptTokensDetails = usageAccounting?.promptTokensDetails as { cachedTokens?: number } | undefined + const completionTokensDetails = usageAccounting?.completionTokensDetails as + | { reasoningTokens?: number } + | undefined + const cost = usageAccounting?.cost as number | undefined + const costDetails = usageAccounting?.costDetails as { upstreamInferenceCost?: number } | undefined + + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens: promptTokensDetails?.cachedTokens, + reasoningTokens: completionTokensDetails?.reasoningTokens, + totalCost: (costDetails?.upstreamInferenceCost || 0) + (cost || 0), + } } /** - * Generate an image using OpenRouter's image generation API (chat completions with modalities) - * Note: OpenRouter only supports the chat completions approach, not the /images/generations endpoint + * Generate an image using OpenRouter's image generation API (chat completions with modalities). + * Note: OpenRouter only supports the chat completions approach, not the /images/generations endpoint. * @param prompt The text prompt for image generation * @param model The model to use for generation * @param apiKey The OpenRouter API key (must be explicitly provided) diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts index 18d09a59f3b..a9b197fba6f 100644 --- a/src/api/providers/qwen-code.ts +++ b/src/api/providers/qwen-code.ts @@ -1,20 +1,19 @@ import { promises as fs } from "node:fs" -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" import * as os from "os" import * as path from "path" -import { type ModelInfo, type QwenCodeModelId, qwenCodeModels, qwenCodeDefaultModelId } from "@roo-code/types" +import { qwenCodeModels, qwenCodeDefaultModelId } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" -import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" - -import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" +import { getModelParams } from "../transform/model-params" -import { BaseProvider } from "./base-provider" -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { OpenAICompatibleHandler, type OpenAICompatibleConfig } from "./openai-compatible" +import { DEFAULT_HEADERS } from "./constants" +import type { ApiHandlerCreateMessageMetadata } from "../index" const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai" const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token` @@ -36,7 +35,6 @@ interface QwenCodeHandlerOptions extends ApiHandlerOptions { function getQwenCachedCredentialPath(customPath?: string): string { if (customPath) { - // Support custom path that starts with ~/ or is absolute if (customPath.startsWith("~/")) { return path.join(os.homedir(), customPath.slice(2)) } @@ -51,29 +49,55 @@ function objectToUrlEncoded(data: Record): string { .join("&") } -export class QwenCodeHandler extends BaseProvider implements SingleCompletionHandler { - protected options: QwenCodeHandlerOptions +export class QwenCodeHandler extends OpenAICompatibleHandler { + protected override options: QwenCodeHandlerOptions private credentials: QwenOAuthCredentials | null = null - private client: OpenAI | undefined private refreshPromise: Promise | null = null constructor(options: QwenCodeHandlerOptions) { - super() + const modelId = options.apiModelId || "" + const modelInfo = + qwenCodeModels[modelId as keyof typeof qwenCodeModels] || qwenCodeModels[qwenCodeDefaultModelId] + + const config: OpenAICompatibleConfig = { + providerName: "qwen-code", + baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + apiKey: "", + modelId, + modelInfo, + } + + super(options, config) this.options = options } - private ensureClient(): OpenAI { - if (!this.client) { - // Create the client instance with dummy key initially - // The API key will be updated dynamically via ensureAuthenticated - this.client = new OpenAI({ - apiKey: "dummy-key-will-be-replaced", - baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", - }) - } - return this.client + override getModel() { + const id = this.options.apiModelId ?? qwenCodeDefaultModelId + const info = qwenCodeModels[id as keyof typeof qwenCodeModels] || qwenCodeModels[qwenCodeDefaultModelId] + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) + return { id, info, ...params } + } + + /** + * Recreate the AI SDK provider with current OAuth credentials. + */ + private updateProvider(): void { + this.provider = createOpenAICompatible({ + name: this.config.providerName, + baseURL: this.config.baseURL, + apiKey: this.config.apiKey, + headers: DEFAULT_HEADERS, + }) } + // --- OAuth lifecycle (preserved as-is) --- + private async loadCachedQwenCredentials(): Promise { try { const keyFile = getQwenCachedCredentialPath(this.options.qwenCodeOauthPath) @@ -172,10 +196,9 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan this.credentials = await this.refreshAccessToken(this.credentials) } - // After authentication, update the apiKey and baseURL on the existing client - const client = this.ensureClient() - client.apiKey = this.credentials.access_token - client.baseURL = this.getBaseUrl(this.credentials) + this.config.apiKey = this.credentials.access_token + this.config.baseURL = this.getBaseUrl(this.credentials) + this.updateProvider() } private getBaseUrl(creds: QwenOAuthCredentials): string { @@ -186,154 +209,47 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan return baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1` } - private async callApiWithRetry(apiCall: () => Promise): Promise { - try { - return await apiCall() - } catch (error: any) { - if (error.status === 401) { - // Token expired, refresh and retry - this.credentials = await this.refreshAccessToken(this.credentials!) - const client = this.ensureClient() - client.apiKey = this.credentials.access_token - client.baseURL = this.getBaseUrl(this.credentials) - return await apiCall() - } else { - throw error - } - } - } + // --- Overrides with 401 retry --- override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { await this.ensureAuthenticated() - const client = this.ensureClient() - const model = this.getModel() - - const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = { - role: "system", - content: systemPrompt, - } - - const convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)] - - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: model.id, - temperature: 0, - messages: convertedMessages, - stream: true, - stream_options: { include_usage: true }, - max_completion_tokens: model.info.maxTokens, - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, - } - const stream = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions)) - - let fullContent = "" - - for await (const apiChunk of stream) { - const delta = apiChunk.choices[0]?.delta ?? {} - const finishReason = apiChunk.choices[0]?.finish_reason - - if (delta.content) { - let newText = delta.content - if (newText.startsWith(fullContent)) { - newText = newText.substring(fullContent.length) - } - fullContent = delta.content - - if (newText) { - // Check for thinking blocks - if (newText.includes("") || newText.includes("")) { - // Simple parsing for thinking blocks - const parts = newText.split(/<\/?think>/g) - for (let i = 0; i < parts.length; i++) { - if (parts[i]) { - if (i % 2 === 0) { - // Outside thinking block - yield { - type: "text", - text: parts[i], - } - } else { - // Inside thinking block - yield { - type: "reasoning", - text: parts[i], - } - } - } - } - } else { - yield { - type: "text", - text: newText, - } - } - } - } - - if ("reasoning_content" in delta && delta.reasoning_content) { - yield { - type: "reasoning", - text: (delta.reasoning_content as string | undefined) || "", - } - } - - // Handle tool calls in stream - emit partial chunks for NativeToolCallParser - if (delta.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - // Process finish_reason to emit tool_call_end events - if (finishReason) { - const endEvents = NativeToolCallParser.processFinishReason(finishReason) - for (const event of endEvents) { - yield event - } - } - - if (apiChunk.usage) { - yield { - type: "usage", - inputTokens: apiChunk.usage.prompt_tokens || 0, - outputTokens: apiChunk.usage.completion_tokens || 0, - } + try { + yield* super.createMessage(systemPrompt, messages, metadata) + } catch (error: unknown) { + if ((error as any).status === 401) { + // Token expired mid-request, refresh and retry + this.credentials = await this.refreshAccessToken(this.credentials!) + this.config.apiKey = this.credentials.access_token + this.config.baseURL = this.getBaseUrl(this.credentials) + this.updateProvider() + yield* super.createMessage(systemPrompt, messages, metadata) + } else { + throw error } } } - override getModel(): { id: string; info: ModelInfo } { - const id = this.options.apiModelId ?? qwenCodeDefaultModelId - const info = qwenCodeModels[id as keyof typeof qwenCodeModels] || qwenCodeModels[qwenCodeDefaultModelId] - return { id, info } - } - - async completePrompt(prompt: string): Promise { + override async completePrompt(prompt: string): Promise { await this.ensureAuthenticated() - const client = this.ensureClient() - const model = this.getModel() - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: model.id, - messages: [{ role: "user", content: prompt }], - max_completion_tokens: model.info.maxTokens, + try { + return await super.completePrompt(prompt) + } catch (error: unknown) { + if ((error as any).status === 401) { + // Token expired mid-request, refresh and retry + this.credentials = await this.refreshAccessToken(this.credentials!) + this.config.apiKey = this.credentials.access_token + this.config.baseURL = this.getBaseUrl(this.credentials) + this.updateProvider() + return await super.completePrompt(prompt) + } else { + throw error + } } - - const response = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions)) - - return response.choices[0]?.message.content || "" } } diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index b241c347b08..f00dd51e6c1 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -1,88 +1,65 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { streamText, ToolSet } from "ai" import { type ModelInfo, type ModelRecord, requestyDefaultModelId, requestyDefaultModelInfo } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" -import { convertToOpenAiMessages } from "../transform/openai-format" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { AnthropicReasoningParams } from "../transform/reasoning" -import { DEFAULT_HEADERS } from "./constants" -import { getModels } from "./fetchers/modelCache" -import { BaseProvider } from "./base-provider" -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { OpenAICompatibleHandler, type OpenAICompatibleConfig } from "./openai-compatible" +import type { ApiHandlerCreateMessageMetadata } from "../index" +import { getModels, getModelsFromCache } from "./fetchers/modelCache" import { toRequestyServiceUrl } from "../../shared/utils/requesty" -import { handleOpenAIError } from "./utils/openai-error-handler" import { applyRouterToolPreferences } from "./utils/router-tool-preferences" -// Requesty usage includes an extra field for Anthropic use cases. -// Safely cast the prompt token details section to the appropriate structure. -interface RequestyUsage extends OpenAI.CompletionUsage { - prompt_tokens_details?: { - caching_tokens?: number - cached_tokens?: number - } - total_cost?: number -} - -type RequestyChatCompletionParamsStreaming = OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & { - requesty?: { - trace_id?: string - extra?: { - mode?: string - } - } - thinking?: AnthropicReasoningParams -} - -type RequestyChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { - requesty?: { - trace_id?: string - extra?: { - mode?: string - } - } - thinking?: AnthropicReasoningParams -} - -export class RequestyHandler extends BaseProvider implements SingleCompletionHandler { - protected options: ApiHandlerOptions - protected models: ModelRecord = {} - private client: OpenAI - private baseURL: string - private readonly providerName = "Requesty" +export class RequestyHandler extends OpenAICompatibleHandler { + private models: ModelRecord = {} constructor(options: ApiHandlerOptions) { - super() - - this.options = options - this.baseURL = toRequestyServiceUrl(options.requestyBaseUrl) - - const apiKey = this.options.requestyApiKey ?? "not-provided" + const modelId = options.requestyModelId ?? requestyDefaultModelId + const cached = getModelsFromCache("requesty") + const modelInfo = (cached && modelId && cached[modelId]) || requestyDefaultModelInfo + + const config: OpenAICompatibleConfig = { + providerName: "requesty", + baseURL: toRequestyServiceUrl(options.requestyBaseUrl), + apiKey: options.requestyApiKey ?? "not-provided", + modelId, + modelInfo, + modelMaxTokens: options.modelMaxTokens ?? undefined, + temperature: options.modelTemperature ?? undefined, + } - this.client = new OpenAI({ - baseURL: this.baseURL, - apiKey: apiKey, - defaultHeaders: DEFAULT_HEADERS, - }) + super(options, config) } public async fetchModel() { - this.models = await getModels({ provider: "requesty", baseUrl: this.baseURL }) - return this.getModel() + this.models = await getModels({ + provider: "requesty", + baseUrl: toRequestyServiceUrl(this.options.requestyBaseUrl), + }) + const model = this.getModel() + this.config.modelInfo = model.info + return model } override getModel() { const id = this.options.requestyModelId ?? requestyDefaultModelId - const cachedInfo = this.models[id] ?? requestyDefaultModelInfo - let info: ModelInfo = cachedInfo - - // Apply tool preferences for models accessed through routers (OpenAI, Gemini) - info = applyRouterToolPreferences(id, info) + const cached = getModelsFromCache("requesty") + let info: ModelInfo = applyRouterToolPreferences( + id, + (cached && id && cached[id]) || this.models[id] || requestyDefaultModelInfo, + ) const params = getModelParams({ format: "anthropic", @@ -95,125 +72,81 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan return { id, info, ...params } } - protected processUsageMetrics(usage: any, modelInfo?: ModelInfo): ApiStreamUsageChunk { - const requestyUsage = usage as RequestyUsage - const inputTokens = requestyUsage?.prompt_tokens || 0 - const outputTokens = requestyUsage?.completion_tokens || 0 - const cacheWriteTokens = requestyUsage?.prompt_tokens_details?.caching_tokens || 0 - const cacheReadTokens = requestyUsage?.prompt_tokens_details?.cached_tokens || 0 - const { totalCost } = modelInfo - ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) - : { totalCost: 0 } - - return { - type: "usage", - inputTokens: inputTokens, - outputTokens: outputTokens, - cacheWriteTokens: cacheWriteTokens, - cacheReadTokens: cacheReadTokens, - totalCost: totalCost, - } - } - override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const { - id: model, - info, - maxTokens: max_tokens, - temperature, - reasoningEffort: reasoning_effort, - reasoning: thinking, - } = await this.fetchModel() - - const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), - ] - - // Map extended efforts to OpenAI Chat Completions-accepted values (omit unsupported) - const allowedEffort = (["low", "medium", "high"] as const).includes(reasoning_effort as any) - ? (reasoning_effort as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming["reasoning_effort"]) - : undefined - - const completionParams: RequestyChatCompletionParamsStreaming = { - messages: openAiMessages, - model, - max_tokens, - temperature, - ...(allowedEffort && { reasoning_effort: allowedEffort }), - ...(thinking && { thinking }), - stream: true, - stream_options: { include_usage: true }, - requesty: { trace_id: metadata?.taskId, extra: { mode: metadata?.mode } }, - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - } + await this.fetchModel() + const model = this.getModel() + const aiSdkMessages = convertToAiSdkMessages(messages) + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const result = streamText({ + model: this.getLanguageModel(), + system: systemPrompt, + messages: aiSdkMessages, + temperature: model.temperature ?? 0, + maxOutputTokens: model.maxTokens, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + providerOptions: { + requesty: { trace_id: metadata?.taskId, extra: { mode: metadata?.mode } }, + } as any, + }) - let stream try { - // With streaming params type, SDK returns an async iterable stream - stream = await this.client.chat.completions.create(completionParams) - } catch (error) { - throw handleOpenAIError(error, this.providerName) - } - let lastUsage: any = undefined - - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - - if (delta?.content) { - yield { type: "text", text: delta.content } - } - - if (delta && "reasoning_content" in delta && delta.reasoning_content) { - yield { type: "reasoning", text: (delta.reasoning_content as string | undefined) || "" } - } - - // Handle native tool calls - if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk } } - - if (chunk.usage) { - lastUsage = chunk.usage + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) } - } - - if (lastUsage) { - yield this.processUsageMetrics(lastUsage, info) + } catch (error) { + throw handleAiSdkError(error, this.config.providerName) } } - async completePrompt(prompt: string): Promise { - const { id: model, maxTokens: max_tokens, temperature } = await this.fetchModel() + override async completePrompt(prompt: string): Promise { + await this.fetchModel() + return super.completePrompt(prompt) + } - let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [{ role: "system", content: prompt }] + protected override processUsageMetrics(usage: { + inputTokens?: number + outputTokens?: number + details?: { cachedInputTokens?: number; reasoningTokens?: number } + raw?: Record + }): ApiStreamUsageChunk { + const rawUsage = usage.raw as + | { prompt_tokens_details?: { caching_tokens?: number; cached_tokens?: number } } + | undefined + + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + const cacheWriteTokens = rawUsage?.prompt_tokens_details?.caching_tokens || 0 + const cacheReadTokens = rawUsage?.prompt_tokens_details?.cached_tokens ?? usage.details?.cachedInputTokens ?? 0 + + const modelInfo = this.getModel().info + const { totalCost } = calculateApiCostOpenAI( + modelInfo, + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + ) - const completionParams: RequestyChatCompletionParams = { - model, - max_tokens, - messages: openAiMessages, - temperature: temperature, - } - - let response: OpenAI.Chat.ChatCompletion - try { - response = await this.client.chat.completions.create(completionParams) - } catch (error) { - throw handleOpenAIError(error, this.providerName) + return { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + totalCost, } - return response.choices[0]?.message.content || "" } } diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index b455a1885ed..68734d1f4eb 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -1,45 +1,49 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { streamText, generateText, ToolSet } from "ai" import { rooDefaultModelId, getApiProtocol, type ImageGenerationApiMethod } from "@roo-code/types" import { CloudService } from "@roo-code/cloud" -import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" - import { Package } from "../../shared/package" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" -import { ApiStream } from "../transform/stream" + +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { convertToOpenAiMessages } from "../transform/openai-format" -import type { RooReasoningParams } from "../transform/reasoning" import { getRooReasoning } from "../transform/reasoning" +import { OpenAICompatibleHandler, type OpenAICompatibleConfig } from "./openai-compatible" +import { DEFAULT_HEADERS } from "./constants" import type { ApiHandlerCreateMessageMetadata } from "../index" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" import { getModels, getModelsFromCache } from "../providers/fetchers/modelCache" -import { handleOpenAIError } from "./utils/openai-error-handler" import { generateImageWithProvider, generateImageWithImagesApi, ImageGenerationResult } from "./utils/image-generation" import { t } from "../../i18n" -// Extend OpenAI's CompletionUsage to include Roo specific fields -interface RooUsage extends OpenAI.CompletionUsage { - cache_creation_input_tokens?: number - cost?: number -} - -// Add custom interface for Roo params to support reasoning -type RooChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParamsStreaming & { - reasoning?: RooReasoningParams -} - function getSessionToken(): string { const token = CloudService.hasInstance() ? CloudService.instance.authService?.getSessionToken() : undefined return token ?? "unauthenticated" } -export class RooHandler extends BaseOpenAiCompatibleProvider { +const FALLBACK_MODEL_INFO = { + maxTokens: 16_384, + contextWindow: 262_144, + supportsImages: false, + supportsReasoningEffort: false, + supportsPromptCache: true, + inputPrice: 0, + outputPrice: 0, + isFree: false, +} + +export class RooHandler extends OpenAICompatibleHandler { private fetcherBaseURL: string - private currentReasoningDetails: any[] = [] constructor(options: ApiHandlerOptions) { const sessionToken = options.rooApiKey ?? getSessionToken() @@ -51,16 +55,19 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { baseURL = `${baseURL}/v1` } - // Always construct the handler, even without a valid token. - // The provider-proxy server will return 401 if authentication fails. - super({ - ...options, - providerName: "Roo Code Cloud", - baseURL, // Already has /v1 suffix + const modelId = options.apiModelId || rooDefaultModelId + const models = getModelsFromCache("roo") || {} + const modelInfo = models[modelId] || FALLBACK_MODEL_INFO + + const config: OpenAICompatibleConfig = { + providerName: "roo", + baseURL, apiKey: sessionToken, - defaultProviderModelId: rooDefaultModelId, - providerModels: {}, - }) + modelId, + modelInfo, + } + + super(options, config) // Load dynamic models asynchronously - strip /v1 from baseURL for fetcher this.fetcherBaseURL = baseURL.endsWith("/v1") ? baseURL.slice(0, -3) : baseURL @@ -70,322 +77,205 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { }) } - protected override createStream( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - requestOptions?: OpenAI.RequestOptions, - ) { - const { id: model, info } = this.getModel() + // ── Auth & Provider recreation ───────────────────────────────── - // Get model parameters including reasoning - const params = getModelParams({ - format: "openai", - modelId: model, - model: info, - settings: this.options, - defaultTemperature: this.defaultTemperature, - }) + /** + * Refresh the session token and recreate the AI SDK provider with + * up-to-date credentials and per-request custom headers. + * `createOpenAICompatible()` captures baseURL/apiKey at creation time, + * so the provider must be recreated for dynamic credentials. + */ + private refreshProvider(taskId?: string): void { + const sessionToken = this.options.rooApiKey ?? getSessionToken() + this.config.apiKey = sessionToken - // Get Roo-specific reasoning parameters - const reasoning = getRooReasoning({ - model: info, - reasoningBudget: params.reasoningBudget, - reasoningEffort: params.reasoningEffort, - settings: this.options, + const headers: Record = { + ...DEFAULT_HEADERS, + "X-Roo-App-Version": Package.version, + } + + if (taskId) { + headers["X-Roo-Task-ID"] = taskId + } + + this.provider = createOpenAICompatible({ + name: this.config.providerName, + baseURL: this.config.baseURL, + apiKey: this.config.apiKey, + headers, }) + } - const max_tokens = params.maxTokens ?? undefined - const temperature = params.temperature ?? this.defaultTemperature + // ── Model resolution ─────────────────────────────────────────── - const rooParams: RooChatCompletionParams = { - model, - max_tokens, - temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], - stream: true, - stream_options: { include_usage: true }, - ...(reasoning && { reasoning }), - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, + override getModel() { + const modelId = this.options.apiModelId || rooDefaultModelId + + // Get models from shared cache (settings are already applied by the fetcher) + const models = getModelsFromCache("roo") || {} + const modelInfo = models[modelId] + + if (modelInfo) { + return { id: modelId, info: modelInfo } } + // Return the requested model ID even if not found, with fallback info. + return { id: modelId, info: FALLBACK_MODEL_INFO } + } + + private async loadDynamicModels(baseURL: string, apiKey?: string): Promise { try { - this.client.apiKey = this.options.rooApiKey ?? getSessionToken() - return this.client.chat.completions.create(rooParams, requestOptions) + await getModels({ + provider: "roo", + baseUrl: baseURL, + apiKey, + }) } catch (error) { - throw handleOpenAIError(error, this.providerName) + // Enhanced error logging with more context + console.error("[RooHandler] Error loading dynamic models:", { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + baseURL, + hasApiKey: Boolean(apiKey), + }) } } - getReasoningDetails(): any[] | undefined { - return this.currentReasoningDetails.length > 0 ? this.currentReasoningDetails : undefined - } + // ── API methods ──────────────────────────────────────────────── override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - try { - // Reset reasoning_details accumulator for this request - this.currentReasoningDetails = [] + // Refresh auth and recreate provider with custom headers + this.refreshProvider(metadata?.taskId) - const headers: Record = { - "X-Roo-App-Version": Package.version, - } + const { id: modelId, info } = this.getModel() - if (metadata?.taskId) { - headers["X-Roo-Task-ID"] = metadata.taskId - } + // Get model parameters including reasoning settings + const params = getModelParams({ + format: "openai", + modelId, + model: info, + settings: this.options, + defaultTemperature: 0, + }) - const stream = await this.createStream(systemPrompt, messages, metadata, { headers }) - - let lastUsage: RooUsage | undefined = undefined - // Accumulator for reasoning_details FROM the API. - // We preserve the original shape of reasoning_details to prevent malformed responses. - const reasoningDetailsAccumulator = new Map< - string, - { - type: string - text?: string - summary?: string - data?: string - id?: string | null - format?: string - signature?: string - index: number - } - >() - - // Track whether we've yielded displayable text from reasoning_details. - // When reasoning_details has displayable content (reasoning.text or reasoning.summary), - // we skip yielding the top-level reasoning field to avoid duplicate display. - let hasYieldedReasoningFromDetails = false - - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - const finishReason = chunk.choices[0]?.finish_reason - - if (delta) { - // Handle reasoning_details array format (used by Gemini 3, Claude, OpenAI o-series, etc.) - // See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks - // Priority: Check for reasoning_details first, as it's the newer format - const deltaWithReasoning = delta as typeof delta & { - reasoning_details?: Array<{ - type: string - text?: string - summary?: string - data?: string - id?: string | null - format?: string - signature?: string - index?: number - }> - } - - if (deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details)) { - for (const detail of deltaWithReasoning.reasoning_details) { - const index = detail.index ?? 0 - // Use id as key when available to merge chunks that share the same reasoning block id - // This ensures that reasoning.summary and reasoning.encrypted chunks with the same id - // are merged into a single object, matching the provider's expected format - const key = detail.id ?? `${detail.type}-${index}` - const existing = reasoningDetailsAccumulator.get(key) - - if (existing) { - // Accumulate text/summary/data for existing reasoning detail - if (detail.text !== undefined) { - existing.text = (existing.text || "") + detail.text - } - if (detail.summary !== undefined) { - existing.summary = (existing.summary || "") + detail.summary - } - if (detail.data !== undefined) { - existing.data = (existing.data || "") + detail.data - } - // Update other fields if provided - // Note: Don't update type - keep original type (e.g., reasoning.summary) - // even when encrypted data chunks arrive with type reasoning.encrypted - if (detail.id !== undefined) existing.id = detail.id - if (detail.format !== undefined) existing.format = detail.format - if (detail.signature !== undefined) existing.signature = detail.signature - } else { - // Start new reasoning detail accumulation - reasoningDetailsAccumulator.set(key, { - type: detail.type, - text: detail.text, - summary: detail.summary, - data: detail.data, - id: detail.id, - format: detail.format, - signature: detail.signature, - index, - }) - } - - // Yield text for display (still fragmented for live streaming) - // Only reasoning.text and reasoning.summary have displayable content - // reasoning.encrypted is intentionally skipped as it contains redacted content - let reasoningText: string | undefined - if (detail.type === "reasoning.text" && typeof detail.text === "string") { - reasoningText = detail.text - } else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") { - reasoningText = detail.summary - } - - if (reasoningText) { - hasYieldedReasoningFromDetails = true - yield { type: "reasoning", text: reasoningText } - } - } - } - - // Handle top-level reasoning field for UI display. - // Skip if we've already yielded from reasoning_details to avoid duplicate display. - if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { - if (!hasYieldedReasoningFromDetails) { - yield { type: "reasoning", text: delta.reasoning } - } - } else if ("reasoning_content" in delta && typeof delta.reasoning_content === "string") { - // Also check for reasoning_content for backward compatibility - if (!hasYieldedReasoningFromDetails) { - yield { type: "reasoning", text: delta.reasoning_content } - } - } - - // Emit raw tool call chunks - NativeToolCallParser handles state management - if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - if (delta.content) { - yield { - type: "text", - text: delta.content, - } - } - } + // Get Roo-specific reasoning parameters + const reasoning = getRooReasoning({ + model: info, + reasoningBudget: params.reasoningBudget, + reasoningEffort: params.reasoningEffort, + settings: this.options, + }) - if (finishReason) { - const endEvents = NativeToolCallParser.processFinishReason(finishReason) - for (const event of endEvents) { - yield event - } + // Create language model with transformRequestBody for reasoning params + const languageModel = this.provider.languageModel(modelId, { + transformRequestBody: (body: Record) => { + const modified = { ...body } + if (reasoning) { + modified.reasoning = reasoning as any } + return modified + }, + }) - if (chunk.usage) { - lastUsage = chunk.usage as RooUsage - } - } + // Convert messages and tools to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const result = streamText({ + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: params.temperature ?? 0, + maxOutputTokens: params.maxTokens ?? undefined, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + }) - // After streaming completes, store ONLY the reasoning_details we received from the API. - if (reasoningDetailsAccumulator.size > 0) { - this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values()) + try { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } } - if (lastUsage) { - // Check if the current model is marked as free - const model = this.getModel() - const isFreeModel = model.info.isFree ?? false - - // Normalize input tokens based on protocol expectations: - // - OpenAI protocol expects TOTAL input tokens (cached + non-cached) - // - Anthropic protocol expects NON-CACHED input tokens (caches passed separately) - const modelId = model.id - const apiProtocol = getApiProtocol("roo", modelId) - - const promptTokens = lastUsage.prompt_tokens || 0 - const cacheWrite = lastUsage.cache_creation_input_tokens || 0 - const cacheRead = lastUsage.prompt_tokens_details?.cached_tokens || 0 - const nonCached = Math.max(0, promptTokens - cacheWrite - cacheRead) - - const inputTokensForDownstream = apiProtocol === "anthropic" ? nonCached : promptTokens - - yield { - type: "usage", - inputTokens: inputTokensForDownstream, - outputTokens: lastUsage.completion_tokens || 0, - cacheWriteTokens: cacheWrite, - cacheReadTokens: cacheRead, - totalCost: isFreeModel ? 0 : (lastUsage.cost ?? 0), - } + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) } } catch (error) { - const errorContext = { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - modelId: this.options.apiModelId, - hasTaskId: Boolean(metadata?.taskId), - } - - console.error(`[RooHandler] Error during message streaming: ${JSON.stringify(errorContext)}`) - - throw error + throw handleAiSdkError(error, this.config.providerName) } } + override async completePrompt(prompt: string): Promise { - // Update API key before making request to ensure we use the latest session token - this.client.apiKey = this.options.rooApiKey ?? getSessionToken() - return super.completePrompt(prompt) - } + // Refresh auth and recreate provider + this.refreshProvider() - private async loadDynamicModels(baseURL: string, apiKey?: string): Promise { - try { - // Fetch models and cache them in the shared cache - await getModels({ - provider: "roo", - baseUrl: baseURL, - apiKey, - }) - } catch (error) { - // Enhanced error logging with more context - console.error("[RooHandler] Error loading dynamic models:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - baseURL, - hasApiKey: Boolean(apiKey), - }) - } + const { id: modelId, info } = this.getModel() + + const params = getModelParams({ + format: "openai", + modelId, + model: info, + settings: this.options, + defaultTemperature: 0, + }) + + const languageModel = this.provider.languageModel(modelId) + + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: params.maxTokens ?? undefined, + temperature: params.temperature ?? 0, + }) + + return text } - override getModel() { - const modelId = this.options.apiModelId || rooDefaultModelId + // ── Usage metrics ────────────────────────────────────────────── - // Get models from shared cache (settings are already applied by the fetcher) - const models = getModelsFromCache("roo") || {} - const modelInfo = models[modelId] + protected override processUsageMetrics(usage: { + inputTokens?: number + outputTokens?: number + details?: { cachedInputTokens?: number; reasoningTokens?: number } + raw?: Record + }): ApiStreamUsageChunk { + const model = this.getModel() + const isFreeModel = (model.info as any).isFree ?? false - if (modelInfo) { - return { id: modelId, info: modelInfo } - } + const rawUsage = usage.raw as RooRawUsage | undefined - // Return the requested model ID even if not found, with fallback info. - const fallbackInfo = { - maxTokens: 16_384, - contextWindow: 262_144, - supportsImages: false, - supportsReasoningEffort: false, - supportsPromptCache: true, - inputPrice: 0, - outputPrice: 0, - isFree: false, - } + // Normalize input tokens based on protocol expectations: + // - OpenAI protocol expects TOTAL input tokens (cached + non-cached) + // - Anthropic protocol expects NON-CACHED input tokens (caches passed separately) + const apiProtocol = getApiProtocol("roo", model.id) + + const promptTokens = rawUsage?.prompt_tokens || usage.inputTokens || 0 + const cacheWrite = rawUsage?.cache_creation_input_tokens || 0 + const cacheRead = rawUsage?.prompt_tokens_details?.cached_tokens || usage.details?.cachedInputTokens || 0 + const nonCached = Math.max(0, promptTokens - cacheWrite - cacheRead) + + const inputTokensForDownstream = apiProtocol === "anthropic" ? nonCached : promptTokens + const outputTokens = usage.outputTokens || 0 return { - id: modelId, - info: fallbackInfo, + type: "usage", + inputTokens: inputTokensForDownstream, + outputTokens, + cacheWriteTokens: cacheWrite > 0 ? cacheWrite : undefined, + cacheReadTokens: cacheRead > 0 ? cacheRead : undefined, + totalCost: isFreeModel ? 0 : (rawUsage?.cost ?? 0), } } + // ── Image generation ─────────────────────────────────────────── + /** * Generate an image using Roo Code Cloud's image generation API * @param prompt The text prompt for image generation @@ -433,3 +323,12 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { }) } } + +/** Roo proxy raw usage data with cache-related and cost fields */ +interface RooRawUsage { + prompt_tokens?: number + completion_tokens?: number + cache_creation_input_tokens?: number + prompt_tokens_details?: { cached_tokens?: number } + cost?: number +} diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts deleted file mode 100644 index 09b102d5b25..00000000000 --- a/src/api/providers/router-provider.ts +++ /dev/null @@ -1,87 +0,0 @@ -import OpenAI from "openai" - -import { type ModelInfo, type ModelRecord } from "@roo-code/types" - -import { ApiHandlerOptions, RouterName } from "../../shared/api" - -import { BaseProvider } from "./base-provider" -import { getModels, getModelsFromCache } from "./fetchers/modelCache" - -import { DEFAULT_HEADERS } from "./constants" - -type RouterProviderOptions = { - name: RouterName - baseURL: string - apiKey?: string - modelId?: string - defaultModelId: string - defaultModelInfo: ModelInfo - options: ApiHandlerOptions -} - -export abstract class RouterProvider extends BaseProvider { - protected readonly options: ApiHandlerOptions - protected readonly name: RouterName - protected models: ModelRecord = {} - protected readonly modelId?: string - protected readonly defaultModelId: string - protected readonly defaultModelInfo: ModelInfo - protected readonly client: OpenAI - - constructor({ - options, - name, - baseURL, - apiKey = "not-provided", - modelId, - defaultModelId, - defaultModelInfo, - }: RouterProviderOptions) { - super() - - this.options = options - this.name = name - this.modelId = modelId - this.defaultModelId = defaultModelId - this.defaultModelInfo = defaultModelInfo - - this.client = new OpenAI({ - baseURL, - apiKey, - defaultHeaders: { - ...DEFAULT_HEADERS, - ...(options.openAiHeaders || {}), - }, - }) - } - - public async fetchModel() { - this.models = await getModels({ provider: this.name, apiKey: this.client.apiKey, baseUrl: this.client.baseURL }) - return this.getModel() - } - - override getModel(): { id: string; info: ModelInfo } { - const id = this.modelId ?? this.defaultModelId - - // First check instance models (populated by fetchModel) - if (this.models[id]) { - return { id, info: this.models[id] } - } - - // Fall back to global cache (synchronous disk/memory cache) - // This ensures models are available before fetchModel() is called - const cachedModels = getModelsFromCache(this.name) - if (cachedModels?.[id]) { - // Also populate instance models for future calls - this.models = cachedModels - return { id, info: cachedModels[id] } - } - - // Last resort: return default model - return { id: this.defaultModelId, info: this.defaultModelInfo } - } - - protected supportsTemperature(modelId: string): boolean { - return !modelId.startsWith("openai/o3-mini") - } -} diff --git a/src/api/providers/sambanova.ts b/src/api/providers/sambanova.ts index e1fee215060..5b8016119f6 100644 --- a/src/api/providers/sambanova.ts +++ b/src/api/providers/sambanova.ts @@ -1,9 +1,9 @@ -import { Anthropic } from "@anthropic-ai/sdk" import { createSambaNova } from "sambanova-ai-provider" import { streamText, generateText, ToolSet } from "ai" import { sambaNovaModels, sambaNovaDefaultModelId, type ModelInfo } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" import { @@ -110,7 +110,7 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa */ override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { temperature, info } = this.getModel() diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index 51b0eb5f513..e64db109f4a 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -1,132 +1,76 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - import { vercelAiGatewayDefaultModelId, vercelAiGatewayDefaultModelInfo, VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE, - VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS, } from "@roo-code/types" -import { ApiHandlerOptions } from "../../shared/api" - -import { ApiStream } from "../transform/stream" -import { convertToOpenAiMessages } from "../transform/openai-format" -import { addCacheBreakpoints } from "../transform/caching/vercel-ai-gateway" +import type { ApiHandlerOptions } from "../../shared/api" -import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { RouterProvider } from "./router-provider" +import type { ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" -// Extend OpenAI's CompletionUsage to include Vercel AI Gateway specific fields -interface VercelAiGatewayUsage extends OpenAI.CompletionUsage { - cache_creation_input_tokens?: number - cost?: number -} +import { OpenAICompatibleHandler, type OpenAICompatibleConfig } from "./openai-compatible" +import { getModelsFromCache } from "./fetchers/modelCache" -export class VercelAiGatewayHandler extends RouterProvider implements SingleCompletionHandler { +export class VercelAiGatewayHandler extends OpenAICompatibleHandler { constructor(options: ApiHandlerOptions) { - super({ - options, - name: "vercel-ai-gateway", - baseURL: "https://ai-gateway.vercel.sh/v1", - apiKey: options.vercelAiGatewayApiKey, - modelId: options.vercelAiGatewayModelId, - defaultModelId: vercelAiGatewayDefaultModelId, - defaultModelInfo: vercelAiGatewayDefaultModelInfo, - }) - } + const modelId = options.vercelAiGatewayModelId ?? vercelAiGatewayDefaultModelId + const models = getModelsFromCache("vercel-ai-gateway") + const modelInfo = (models && models[modelId]) || vercelAiGatewayDefaultModelInfo - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - const { id: modelId, info } = await this.fetchModel() - - const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), - ] - - if (VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS.has(modelId) && info.supportsPromptCache) { - addCacheBreakpoints(systemPrompt, openAiMessages) - } - - const body: OpenAI.Chat.ChatCompletionCreateParams = { - model: modelId, - messages: openAiMessages, - temperature: this.supportsTemperature(modelId) - ? (this.options.modelTemperature ?? VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE) - : undefined, - max_completion_tokens: info.maxTokens, - stream: true, - stream_options: { include_usage: true }, - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, + const config: OpenAICompatibleConfig = { + providerName: "vercel-ai-gateway", + baseURL: "https://ai-gateway.vercel.sh/v1", + apiKey: options.vercelAiGatewayApiKey ?? "not-provided", + modelId, + modelInfo, + temperature: options.modelTemperature ?? undefined, } - const completion = await this.client.chat.completions.create(body) - - for await (const chunk of completion) { - const delta = chunk.choices[0]?.delta - if (delta?.content) { - yield { - type: "text", - text: delta.content, - } - } - - // Emit raw tool call chunks - NativeToolCallParser handles state management - if (delta?.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - if (chunk.usage) { - const usage = chunk.usage as VercelAiGatewayUsage - yield { - type: "usage", - inputTokens: usage.prompt_tokens || 0, - outputTokens: usage.completion_tokens || 0, - cacheWriteTokens: usage.cache_creation_input_tokens || undefined, - cacheReadTokens: usage.prompt_tokens_details?.cached_tokens || undefined, - totalCost: usage.cost ?? 0, - } - } - } + super(options, config) } - async completePrompt(prompt: string): Promise { - const { id: modelId, info } = await this.fetchModel() - - try { - const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = { - model: modelId, - messages: [{ role: "user", content: prompt }], - stream: false, - } - - if (this.supportsTemperature(modelId)) { - requestOptions.temperature = this.options.modelTemperature ?? VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE - } - - requestOptions.max_completion_tokens = info.maxTokens + override getModel() { + const id = this.options.vercelAiGatewayModelId ?? vercelAiGatewayDefaultModelId + const models = getModelsFromCache("vercel-ai-gateway") + const info = (models && models[id]) || vercelAiGatewayDefaultModelInfo + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE, + }) + return { id, info, ...params } + } - const response = await this.client.chat.completions.create(requestOptions) - return response.choices[0]?.message.content || "" - } catch (error) { - if (error instanceof Error) { - throw new Error(`Vercel AI Gateway completion error: ${error.message}`) - } - throw error + /** + * Override to handle Vercel AI Gateway's usage metrics, including caching and cost. + * The gateway returns cache_creation_input_tokens and cost in raw usage data. + */ + protected override processUsageMetrics(usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + raw?: Record + }): ApiStreamUsageChunk { + const rawUsage = usage.raw as + | { + cache_creation_input_tokens?: number + cost?: number + } + | undefined + + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheWriteTokens: rawUsage?.cache_creation_input_tokens || undefined, + cacheReadTokens: usage.details?.cachedInputTokens, + totalCost: rawUsage?.cost ?? 0, } } } diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index c772741e6a0..e6621734bb9 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -1,4 +1,3 @@ -import type { Anthropic } from "@anthropic-ai/sdk" import { createVertex, type GoogleVertexProvider } from "@ai-sdk/google-vertex" import { streamText, generateText, ToolSet } from "ai" @@ -11,6 +10,7 @@ import { } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" import { @@ -64,7 +64,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl async *createMessage( systemInstruction: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { id: modelId, info, reasoning: thinkingConfig, maxTokens } = this.getModel() @@ -91,10 +91,10 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl // The message list can include provider-specific meta entries such as // `{ type: "reasoning", ... }` that are intended only for providers like // openai-native. Vertex should never see those; they are not valid - // Anthropic.MessageParam values and will cause failures. + // NeutralMessageParam values and will cause failures. type ReasoningMetaLike = { type?: string } - const filteredMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => { + const filteredMessages = messages.filter((message): message is NeutralMessageParam => { const meta = message as ReasoningMetaLike if (meta.type === "reasoning") { return false diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 8fb564a9d59..12284aa977f 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -1,64 +1,434 @@ -import { Anthropic } from "@anthropic-ai/sdk" import * as vscode from "vscode" -import OpenAI from "openai" +import { streamText, generateText } from "ai" +import type { + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3GenerateResult, + LanguageModelV3StreamPart, + LanguageModelV3StreamResult, + LanguageModelV3Prompt, + LanguageModelV3FunctionTool, + LanguageModelV3ProviderTool, + LanguageModelV3Usage, + LanguageModelV3FinishReason, +} from "@ai-sdk/provider" import { type ModelInfo, openAiModelInfoSaneDefaults } from "@roo-code/types" +import type { NeutralMessageParam, NeutralContentBlock } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils" import { normalizeToolSchema } from "../../utils/json-schema" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" -import { convertToVsCodeLmMessages, extractTextCountFromMessage } from "../transform/vscode-lm-format" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +// ──────────────────────────────────────────────────────────────────────────── +// Utility: Convert LanguageModelV3Prompt → vscode.LanguageModelChatMessage[] +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Safely converts a value into a plain object. + */ +function asObjectSafe(value: unknown): object { + if (!value) { + return {} + } + try { + if (typeof value === "string") { + return JSON.parse(value) + } + if (typeof value === "object") { + return { ...value } + } + return {} + } catch { + return {} + } +} + +/** + * Converts an AI SDK LanguageModelV3Prompt to VS Code LM messages. + * This bridges the AI SDK's standard prompt format to the VS Code Language Model API. + */ +function convertV3PromptToVsCodeLm(prompt: LanguageModelV3Prompt): vscode.LanguageModelChatMessage[] { + const messages: vscode.LanguageModelChatMessage[] = [] + + for (const message of prompt) { + switch (message.role) { + case "system": + // VS Code LM has no system role — prepend as assistant message (matching existing behavior) + messages.push(vscode.LanguageModelChatMessage.Assistant(message.content)) + break + + case "user": { + const parts: (vscode.LanguageModelTextPart | vscode.LanguageModelToolResultPart)[] = [] + for (const part of message.content) { + if (part.type === "text") { + parts.push(new vscode.LanguageModelTextPart(part.text)) + } else if (part.type === "file") { + // VS Code LM doesn't support files/images — emit placeholder + parts.push( + new vscode.LanguageModelTextPart( + `[File: ${part.mediaType} not supported by VS Code LM API]`, + ), + ) + } + } + if (parts.length > 0) { + messages.push(vscode.LanguageModelChatMessage.User(parts)) + } + break + } + + case "assistant": { + const parts: (vscode.LanguageModelTextPart | vscode.LanguageModelToolCallPart)[] = [] + for (const part of message.content) { + if (part.type === "text") { + parts.push(new vscode.LanguageModelTextPart(part.text)) + } else if (part.type === "tool-call") { + parts.push( + new vscode.LanguageModelToolCallPart( + part.toolCallId, + part.toolName, + asObjectSafe(part.input), + ), + ) + } else if (part.type === "file") { + parts.push( + new vscode.LanguageModelTextPart("[File generation not supported by VS Code LM API]"), + ) + } + // reasoning parts are not supported by VS Code LM — skip + } + if (parts.length > 0) { + messages.push(vscode.LanguageModelChatMessage.Assistant(parts)) + } + break + } + + case "tool": { + const parts: vscode.LanguageModelToolResultPart[] = [] + for (const part of message.content) { + if (part.type === "tool-result") { + let textContent: string + if (part.output.type === "text") { + textContent = part.output.value + } else if (part.output.type === "json") { + textContent = JSON.stringify(part.output.value) + } else if (part.output.type === "execution-denied") { + textContent = part.output.reason ?? "Tool execution denied" + } else { + // error-text or other types + textContent = "value" in part.output ? String(part.output.value) : "(empty)" + } + parts.push( + new vscode.LanguageModelToolResultPart(part.toolCallId, [ + new vscode.LanguageModelTextPart(textContent), + ]), + ) + } + // tool-approval-response parts are not supported — skip + } + if (parts.length > 0) { + messages.push(vscode.LanguageModelChatMessage.User(parts)) + } + break + } + } + } + + return messages +} + +// ──────────────────────────────────────────────────────────────────────────── +// Utility: Convert LanguageModelV3 tools → vscode.LanguageModelChatTool[] +// ──────────────────────────────────────────────────────────────────────────── + /** - * Converts OpenAI-format tools to VSCode Language Model tools. + * Converts AI SDK tools to VS Code Language Model tools. * Normalizes the JSON Schema to draft 2020-12 compliant format required by - * GitHub Copilot's backend, converting type: ["T", "null"] to anyOf format. - * @param tools Array of OpenAI ChatCompletionTool definitions - * @returns Array of VSCode LanguageModelChatTool definitions + * GitHub Copilot's backend. */ -function convertToVsCodeLmTools(tools: OpenAI.Chat.ChatCompletionTool[]): vscode.LanguageModelChatTool[] { +function convertV3ToolsToVsCodeLm( + tools: Array | undefined, +): vscode.LanguageModelChatTool[] { + if (!tools) { + return [] + } return tools - .filter((tool) => tool.type === "function") - .map((tool) => ({ - name: tool.function.name, - description: tool.function.description || "", - inputSchema: tool.function.parameters - ? normalizeToolSchema(tool.function.parameters as Record) - : undefined, + .filter((t): t is LanguageModelV3FunctionTool => t.type === "function") + .map((t) => ({ + name: t.name, + description: t.description ?? "", + inputSchema: normalizeToolSchema(t.inputSchema as Record), })) } +// ──────────────────────────────────────────────────────────────────────────── +// Helper: Build LanguageModelV3Usage with all-undefined fields +// ──────────────────────────────────────────────────────────────────────────── + +function makeEmptyUsage(): LanguageModelV3Usage { + return { + inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: undefined, text: undefined, reasoning: undefined }, + } +} + +function makeFinishReason(hasToolCalls: boolean): LanguageModelV3FinishReason { + return { + unified: hasToolCalls ? "tool-calls" : "stop", + raw: undefined, + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// Utility: Extract text from a VS Code LM message (for token counting) +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Extracts the text content from a VS Code Language Model chat message. + * @param message A VS Code Language Model chat message. + * @returns The extracted text content. + */ +function extractTextCountFromMessage(message: vscode.LanguageModelChatMessage): string { + let text = "" + if (Array.isArray(message.content)) { + for (const item of message.content) { + if (item instanceof vscode.LanguageModelTextPart) { + text += item.value + } + if (item instanceof vscode.LanguageModelToolResultPart) { + text += item.callId + for (const part of item.content) { + if (part instanceof vscode.LanguageModelTextPart) { + text += part.value + } + } + } + if (item instanceof vscode.LanguageModelToolCallPart) { + text += item.name + text += item.callId + if (item.input && Object.keys(item.input).length > 0) { + try { + text += JSON.stringify(item.input) + } catch (error) { + console.error("Roo Code : Failed to stringify tool call input:", error) + } + } + } + } + } else if (typeof message.content === "string") { + text += message.content + } + return text +} + +// ──────────────────────────────────────────────────────────────────────────── +// VsCodeLmLanguageModel — LanguageModelV3 adapter for vscode.LanguageModelChat +// ──────────────────────────────────────────────────────────────────────────── + +/** + * A custom LanguageModelV3 adapter that wraps `vscode.LanguageModelChat`. + * This allows using VS Code's native Language Model API through the AI SDK's + * `streamText` / `generateText` functions. + */ +class VsCodeLmLanguageModel implements LanguageModelV3 { + readonly specificationVersion = "v3" as const + readonly provider = "vscode-lm" + readonly modelId: string + readonly supportedUrls: Record = {} + + private client: vscode.LanguageModelChat + private cancellationTokenSource: vscode.CancellationTokenSource | undefined + + constructor(client: vscode.LanguageModelChat, cancellationTokenSource?: vscode.CancellationTokenSource) { + this.client = client + this.modelId = client.id ?? "unknown" + this.cancellationTokenSource = cancellationTokenSource + } + + async doGenerate(options: LanguageModelV3CallOptions): Promise { + const messages = convertV3PromptToVsCodeLm(options.prompt) + const tools = convertV3ToolsToVsCodeLm(options.tools) + const cancellationToken = this.cancellationTokenSource?.token ?? new vscode.CancellationTokenSource().token + + // Bridge abort signal to VS Code cancellation + this.bridgeAbortSignal(options.abortSignal) + + const requestOptions: vscode.LanguageModelChatRequestOptions = { + justification: `Roo Code would like to use '${this.client.name}' from '${this.client.vendor}', Click 'Allow' to proceed.`, + tools: tools.length > 0 ? tools : undefined, + } + + try { + const response = await this.client.sendRequest(messages, requestOptions, cancellationToken) + + const content: LanguageModelV3GenerateResult["content"] = [] + let hasToolCalls = false + + for await (const chunk of response.stream) { + if (chunk instanceof vscode.LanguageModelTextPart) { + // Merge consecutive text parts + const lastContent = content[content.length - 1] + if (lastContent && lastContent.type === "text") { + lastContent.text += chunk.value + } else { + content.push({ type: "text", text: chunk.value }) + } + } else if (chunk instanceof vscode.LanguageModelToolCallPart) { + hasToolCalls = true + content.push({ + type: "tool-call", + toolCallId: chunk.callId, + toolName: chunk.name, + input: JSON.stringify(chunk.input), + }) + } + } + + return { + content, + finishReason: makeFinishReason(hasToolCalls), + usage: makeEmptyUsage(), + warnings: [], + } + } catch (error) { + if (error instanceof vscode.CancellationError) { + throw new Error("Roo Code : Request cancelled by user") + } + throw error + } + } + + async doStream(options: LanguageModelV3CallOptions): Promise { + const messages = convertV3PromptToVsCodeLm(options.prompt) + const tools = convertV3ToolsToVsCodeLm(options.tools) + const cancellationToken = this.cancellationTokenSource?.token ?? new vscode.CancellationTokenSource().token + + // Bridge abort signal to VS Code cancellation + this.bridgeAbortSignal(options.abortSignal) + + const requestOptions: vscode.LanguageModelChatRequestOptions = { + justification: `Roo Code would like to use '${this.client.name}' from '${this.client.vendor}', Click 'Allow' to proceed.`, + tools: tools.length > 0 ? tools : undefined, + } + + const response = await this.client.sendRequest(messages, requestOptions, cancellationToken) + + let hasToolCalls = false + const textId = "text-0" + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue({ type: "stream-start", warnings: [] }) + + let textStarted = false + + try { + for await (const chunk of response.stream) { + if (chunk instanceof vscode.LanguageModelTextPart) { + if (typeof chunk.value !== "string") { + continue + } + if (!textStarted) { + controller.enqueue({ type: "text-start", id: textId }) + textStarted = true + } + controller.enqueue({ type: "text-delta", id: textId, delta: chunk.value }) + } else if (chunk instanceof vscode.LanguageModelToolCallPart) { + if (!chunk.name || !chunk.callId) { + continue + } + // Close any open text segment before tool calls + if (textStarted) { + controller.enqueue({ type: "text-end", id: textId }) + textStarted = false + } + hasToolCalls = true + + // Emit streaming tool call pattern + const inputStr = JSON.stringify(chunk.input ?? {}) + controller.enqueue({ + type: "tool-input-start", + id: chunk.callId, + toolName: chunk.name, + }) + controller.enqueue({ + type: "tool-input-delta", + id: chunk.callId, + delta: inputStr, + }) + controller.enqueue({ + type: "tool-input-end", + id: chunk.callId, + }) + } + } + + // Close any open text segment + if (textStarted) { + controller.enqueue({ type: "text-end", id: textId }) + } + + controller.enqueue({ + type: "finish", + finishReason: makeFinishReason(hasToolCalls), + usage: makeEmptyUsage(), + }) + + controller.close() + } catch (error) { + if (textStarted) { + try { + controller.enqueue({ type: "text-end", id: textId }) + } catch { + // controller may be errored already + } + } + + if (error instanceof vscode.CancellationError) { + controller.error(new Error("Roo Code : Request cancelled by user")) + } else { + controller.error(error) + } + } + }, + }) + + return { stream } + } + + /** + * Bridges an AbortSignal to the VS Code CancellationTokenSource. + */ + private bridgeAbortSignal(abortSignal: AbortSignal | undefined): void { + if (abortSignal && this.cancellationTokenSource) { + const cts = this.cancellationTokenSource + abortSignal.addEventListener("abort", () => cts.cancel(), { once: true }) + } + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// VsCodeLmHandler — Provider handler using the AI SDK via the adapter +// ──────────────────────────────────────────────────────────────────────────── + /** * Handles interaction with VS Code's Language Model API for chat-based operations. - * This handler extends BaseProvider to provide VS Code LM specific functionality. + * Uses the AI SDK's `streamText` / `generateText` through a custom LanguageModelV3 adapter. * * @extends {BaseProvider} - * - * @remarks - * The handler manages a VS Code language model chat client and provides methods to: - * - Create and manage chat client instances - * - Stream messages using VS Code's Language Model API - * - Retrieve model information - * - * @example - * ```typescript - * const options = { - * vsCodeLmModelSelector: { vendor: "copilot", family: "gpt-4" } - * }; - * const handler = new VsCodeLmHandler(options); - * - * // Stream a conversation - * const systemPrompt = "You are a helpful assistant"; - * const messages = [{ role: "user", content: "Hello!" }]; - * for await (const chunk of handler.createMessage(systemPrompt, messages)) { - * console.log(chunk); - * } - * ``` */ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions @@ -89,52 +459,34 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } catch (error) { // Ensure cleanup if constructor fails this.dispose() - throw new Error( `Roo Code : Failed to initialize handler: ${error instanceof Error ? error.message : "Unknown error"}`, ) } } + /** * Initializes the VS Code Language Model client. - * This method is called during the constructor to set up the client. - * This useful when the client is not created yet and call getModel() before the client is created. - * @returns Promise - * @throws Error when client initialization fails */ async initializeClient(): Promise { try { - // Check if the client is already initialized if (this.client) { - console.debug("Roo Code : Client already initialized") return } - // Create a new client instance this.client = await this.createClient(this.options.vsCodeLmModelSelector || {}) - console.debug("Roo Code : Client initialized successfully") } catch (error) { - // Handle errors during client initialization const errorMessage = error instanceof Error ? error.message : "Unknown error" - console.error("Roo Code : Client initialization failed:", errorMessage) throw new Error(`Roo Code : Failed to initialize client: ${errorMessage}`) } } + /** * Creates a language model chat client based on the provided selector. - * - * @param selector - Selector criteria to filter language model chat instances - * @returns Promise resolving to the first matching language model chat instance - * @throws Error when no matching models are found with the given selector - * - * @example - * const selector = { vendor: "copilot", family: "gpt-4o" }; - * const chatClient = await createClient(selector); */ async createClient(selector: vscode.LanguageModelChatSelector): Promise { try { const models = await vscode.lm.selectChatModels(selector) - // Use first available model or create a minimal model object if (models && Array.isArray(models) && models.length > 0) { return models[0] } @@ -148,7 +500,6 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan version: "1.0", maxInputTokens: 8192, sendRequest: async (_messages, _options, _token) => { - // Provide a minimal implementation return { stream: (async function* () { yield new vscode.LanguageModelTextPart( @@ -168,28 +519,14 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } - /** - * Creates and streams a message using the VS Code Language Model API. - * - * @param systemPrompt - The system prompt to initialize the conversation context - * @param messages - An array of message parameters following the Anthropic message format - * @param metadata - Optional metadata for the message - * - * @yields {ApiStream} An async generator that yields either text chunks or tool calls from the model response - * - * @throws {Error} When vsCodeLmModelSelector option is not provided - * @throws {Error} When the response stream encounters an error - * - * @remarks - * This method handles the initialization of the VS Code LM client if not already created, - * converts the messages to VS Code LM format, and streams the response chunks. - * Tool calls handling is currently a work in progress. - */ + override isAiSdkProvider(): boolean { + return true + } + dispose(): void { if (this.disposable) { this.disposable.dispose() } - if (this.currentRequestCancellation) { this.currentRequestCancellation.cancel() this.currentRequestCancellation.dispose() @@ -197,45 +534,32 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } /** - * Implements the ApiHandler countTokens interface method - * Provides token counting for Anthropic content blocks - * - * @param content The content blocks to count tokens for - * @returns A promise resolving to the token count + * Implements the ApiHandler countTokens interface method. + * Uses VS Code's native token counting API. */ - override async countTokens(content: Array): Promise { - // Convert Anthropic content blocks to a string for VSCode LM token counting + override async countTokens(content: NeutralContentBlock[]): Promise { let textContent = "" - for (const block of content) { if (block.type === "text") { textContent += block.text || "" } else if (block.type === "image") { - // VSCode LM doesn't support images directly, so we'll just use a placeholder textContent += "[IMAGE]" } } - return this.internalCountTokens(textContent) } /** - * Private implementation of token counting used internally by VsCodeLmHandler + * Private implementation of token counting used internally. */ private async internalCountTokens(text: string | vscode.LanguageModelChatMessage): Promise { - // Check for required dependencies if (!this.client) { - console.warn("Roo Code : No client available for token counting") return 0 } - - // Validate input if (!text) { - console.debug("Roo Code : Empty text provided for token counting") return 0 } - // Create a temporary cancellation token if we don't have one (e.g., when called outside a request) let cancellationToken: vscode.CancellationToken let tempCancellation: vscode.CancellationTokenSource | null = null @@ -247,66 +571,37 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } try { - // Handle different input types let tokenCount: number if (typeof text === "string") { tokenCount = await this.client.countTokens(text, cancellationToken) } else if (text instanceof vscode.LanguageModelChatMessage) { - // For chat messages, ensure we have content if (!text.content || (Array.isArray(text.content) && text.content.length === 0)) { - console.debug("Roo Code : Empty chat message content") return 0 } const countMessage = extractTextCountFromMessage(text) tokenCount = await this.client.countTokens(countMessage, cancellationToken) } else { - console.warn("Roo Code : Invalid input type for token counting") return 0 } - // Validate the result - if (typeof tokenCount !== "number") { - console.warn("Roo Code : Non-numeric token count received:", tokenCount) - return 0 - } - - if (tokenCount < 0) { - console.warn("Roo Code : Negative token count received:", tokenCount) + if (typeof tokenCount !== "number" || tokenCount < 0) { return 0 } return tokenCount } catch (error) { - // Handle specific error types if (error instanceof vscode.CancellationError) { - console.debug("Roo Code : Token counting cancelled by user") return 0 } - - const errorMessage = error instanceof Error ? error.message : "Unknown error" - console.warn("Roo Code : Token counting failed:", errorMessage) - - // Log additional error details if available - if (error instanceof Error && error.stack) { - console.debug("Token counting error stack:", error.stack) - } - - return 0 // Fallback to prevent stream interruption + return 0 } finally { - // Clean up temporary cancellation token if (tempCancellation) { tempCancellation.dispose() } } } - private async calculateTotalInputTokens(vsCodeLmMessages: vscode.LanguageModelChatMessage[]): Promise { - const messageTokens: number[] = await Promise.all(vsCodeLmMessages.map((msg) => this.internalCountTokens(msg))) - - return messageTokens.reduce((sum: number, tokens: number): number => sum + tokens, 0) - } - private ensureCleanState(): void { if (this.currentRequestCancellation) { this.currentRequestCancellation.cancel() @@ -317,226 +612,75 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan private async getClient(): Promise { if (!this.client) { - console.debug("Roo Code : Getting client with options:", { - vsCodeLmModelSelector: this.options.vsCodeLmModelSelector, - hasOptions: !!this.options, - selectorKeys: this.options.vsCodeLmModelSelector ? Object.keys(this.options.vsCodeLmModelSelector) : [], - }) - - try { - // Use default empty selector if none provided to get all available models - const selector = this.options?.vsCodeLmModelSelector || {} - console.debug("Roo Code : Creating client with selector:", selector) - this.client = await this.createClient(selector) - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error" - console.error("Roo Code : Client creation failed:", message) - throw new Error(`Roo Code : Failed to create client: ${message}`) - } + const selector = this.options?.vsCodeLmModelSelector || {} + this.client = await this.createClient(selector) } - return this.client } - private cleanMessageContent(content: any): any { - if (!content) { - return content - } - - if (typeof content === "string") { - return content - } - - if (Array.isArray(content)) { - return content.map((item) => this.cleanMessageContent(item)) - } - - if (typeof content === "object") { - const cleaned: any = {} - for (const [key, value] of Object.entries(content)) { - cleaned[key] = this.cleanMessageContent(value) - } - return cleaned - } - - return content - } - + /** + * Creates and streams a message using the AI SDK with the VS Code LM adapter. + */ override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - // Ensure clean state before starting a new request this.ensureCleanState() - const client: vscode.LanguageModelChat = await this.getClient() - - // Process messages - const cleanedMessages = messages.map((msg) => ({ - ...msg, - content: this.cleanMessageContent(msg.content), - })) - - // Convert Anthropic messages to VS Code LM messages - const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [ - vscode.LanguageModelChatMessage.Assistant(systemPrompt), - ...convertToVsCodeLmMessages(cleanedMessages), - ] + const client = await this.getClient() - // Initialize cancellation token for the request + // Set up cancellation this.currentRequestCancellation = new vscode.CancellationTokenSource() - // Calculate input tokens before starting the stream - const totalInputTokens: number = await this.calculateTotalInputTokens(vsCodeLmMessages) - - // Accumulate the text and count at the end of the stream to reduce token counting overhead. - let accumulatedText: string = "" - - try { - // Create the response stream with required options - const requestOptions: vscode.LanguageModelChatRequestOptions = { - justification: `Roo Code would like to use '${client.name}' from '${client.vendor}', Click 'Allow' to proceed.`, - tools: convertToVsCodeLmTools(metadata?.tools ?? []), - } - - const response: vscode.LanguageModelChatResponse = await client.sendRequest( - vsCodeLmMessages, - requestOptions, - this.currentRequestCancellation.token, - ) - - // Consume the stream and handle both text and tool call chunks - for await (const chunk of response.stream) { - if (chunk instanceof vscode.LanguageModelTextPart) { - // Validate text part value - if (typeof chunk.value !== "string") { - console.warn("Roo Code : Invalid text part value received:", chunk.value) - continue - } - - accumulatedText += chunk.value - yield { - type: "text", - text: chunk.value, - } - } else if (chunk instanceof vscode.LanguageModelToolCallPart) { - try { - // Validate tool call parameters - if (!chunk.name || typeof chunk.name !== "string") { - console.warn("Roo Code : Invalid tool name received:", chunk.name) - continue - } + // Create the adapter wrapping the VS Code LM client + const model = new VsCodeLmLanguageModel(client, this.currentRequestCancellation) - if (!chunk.callId || typeof chunk.callId !== "string") { - console.warn("Roo Code : Invalid tool callId received:", chunk.callId) - continue - } + // Convert messages and tools via the AI SDK transform utilities + const aiSdkMessages = convertToAiSdkMessages(messages) + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) - // Ensure input is a valid object - if (!chunk.input || typeof chunk.input !== "object") { - console.warn("Roo Code : Invalid tool input received:", chunk.input) - continue - } + const result = streamText({ + model, + system: systemPrompt, + messages: aiSdkMessages, + tools: aiSdkTools ?? undefined, + toolChoice: mapToolChoice(metadata?.tool_choice), + }) - // Log tool call for debugging - console.debug("Roo Code : Processing tool call:", { - name: chunk.name, - callId: chunk.callId, - inputSize: JSON.stringify(chunk.input).length, - }) - - // Yield native tool_call chunk when tools are provided - if (metadata?.tools?.length) { - const argumentsString = JSON.stringify(chunk.input) - accumulatedText += argumentsString - yield { - type: "tool_call", - id: chunk.callId, - name: chunk.name, - arguments: argumentsString, - } - } - } catch (error) { - console.error("Roo Code : Failed to process tool call:", error) - // Continue processing other chunks even if one fails - continue - } - } else { - console.warn("Roo Code : Unknown chunk type received:", chunk) + try { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk } } - // Count tokens in the accumulated text after stream completion - const totalOutputTokens: number = await this.internalCountTokens(accumulatedText) - - // Report final usage after stream completion - yield { - type: "usage", - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, + const usage = await result.usage + if (usage) { + yield { + type: "usage" as const, + inputTokens: usage.inputTokens ?? 0, + outputTokens: usage.outputTokens ?? 0, + } } - } catch (error: unknown) { + } catch (error) { this.ensureCleanState() - - if (error instanceof vscode.CancellationError) { - throw new Error("Roo Code : Request cancelled by user") - } - - if (error instanceof Error) { - console.error("Roo Code : Stream error details:", { - message: error.message, - stack: error.stack, - name: error.name, - }) - - // Return original error if it's already an Error instance - throw error - } else if (typeof error === "object" && error !== null) { - // Handle error-like objects - const errorDetails = JSON.stringify(error, null, 2) - console.error("Roo Code : Stream error object:", errorDetails) - throw new Error(`Roo Code : Response stream error: ${errorDetails}`) - } else { - // Fallback for unknown error types - const errorMessage = String(error) - console.error("Roo Code : Unknown stream error:", errorMessage) - throw new Error(`Roo Code : Response stream error: ${errorMessage}`) - } + throw handleAiSdkError(error, "VS Code LM") } } - // Return model information based on the current client state override getModel(): { id: string; info: ModelInfo } { if (this.client) { - // Validate client properties - const requiredProps = { - id: this.client.id, - vendor: this.client.vendor, - family: this.client.family, - version: this.client.version, - maxInputTokens: this.client.maxInputTokens, - } - - // Log any missing properties for debugging - for (const [prop, value] of Object.entries(requiredProps)) { - if (!value && value !== 0) { - console.warn(`Roo Code : Client missing ${prop} property`) - } - } - - // Construct model ID using available information const modelParts = [this.client.vendor, this.client.family, this.client.version].filter(Boolean) - const modelId = this.client.id || modelParts.join(SELECTOR_SEPARATOR) - // Build model info with conservative defaults for missing values const modelInfo: ModelInfo = { - maxTokens: -1, // Unlimited tokens by default + maxTokens: -1, contextWindow: typeof this.client.maxInputTokens === "number" ? Math.max(0, this.client.maxInputTokens) : openAiModelInfoSaneDefaults.contextWindow, - supportsImages: false, // VSCode Language Model API currently doesn't support image inputs + supportsImages: false, supportsPromptCache: true, inputPrice: 0, outputPrice: 0, @@ -546,13 +690,10 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan return { id: modelId, info: modelInfo } } - // Fallback when no client is available const fallbackId = this.options.vsCodeLmModelSelector ? stringifyVsCodeLmModelSelector(this.options.vsCodeLmModelSelector) : "vscode-lm" - console.debug("Roo Code : No client available, using fallback model info") - return { id: fallbackId, info: { @@ -563,30 +704,34 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } async completePrompt(prompt: string): Promise { + const client = await this.getClient() + + // Set up cancellation + const cancellation = new vscode.CancellationTokenSource() + const model = new VsCodeLmLanguageModel(client, cancellation) + try { - const client = await this.getClient() - const response = await client.sendRequest( - [vscode.LanguageModelChatMessage.User(prompt)], - {}, - new vscode.CancellationTokenSource().token, - ) - let result = "" - for await (const chunk of response.stream) { - if (chunk instanceof vscode.LanguageModelTextPart) { - result += chunk.value - } - } - return result + const { text } = await generateText({ + model, + prompt, + }) + return text } catch (error) { if (error instanceof Error) { throw new Error(`VSCode LM completion error: ${error.message}`) } throw error + } finally { + cancellation.dispose() } } } -// Static blacklist of VS Code Language Model IDs that should be excluded from the model list e.g. because they will never work +// ──────────────────────────────────────────────────────────────────────────── +// Exported utility: list available VS Code LM models +// ──────────────────────────────────────────────────────────────────────────── + +// Static blacklist of VS Code Language Model IDs that should be excluded const VSCODE_LM_STATIC_BLACKLIST: string[] = ["claude-3.7-sonnet", "claude-3.7-sonnet-thought"] export async function getVsCodeLmModels() { diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 88a7aceb464..19865b90e65 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -1,9 +1,9 @@ -import { Anthropic } from "@anthropic-ai/sdk" import { createXai } from "@ai-sdk/xai" import { streamText, generateText, ToolSet } from "ai" import { type XAIModelId, xaiDefaultModelId, xaiModels, type ModelInfo } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" import type { ApiHandlerOptions } from "../../shared/api" import { @@ -118,7 +118,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler */ override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { temperature, reasoning } = this.getModel() diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index acfdd811292..98a0d2537f4 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -1,4 +1,3 @@ -import { Anthropic } from "@anthropic-ai/sdk" import { createZhipu } from "zhipu-ai-provider" import { streamText, generateText, ToolSet } from "ai" @@ -12,6 +11,7 @@ import { zaiApiLineConfigs, } from "@roo-code/types" +import type { NeutralMessageParam } from "../../core/task-persistence" import { type ApiHandlerOptions, shouldUseReasoningEffort } from "../../shared/api" import { @@ -91,7 +91,7 @@ export class ZAiHandler extends BaseProvider implements SingleCompletionHandler */ override async *createMessage( systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { id: modelId, info, temperature } = this.getModel() diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index f973fc85a6d..85e7efc1120 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -1,4 +1,4 @@ -import { Anthropic } from "@anthropic-ai/sdk" +import type { RooMessageParam } from "../../../core/task-persistence/apiMessages" import OpenAI from "openai" import { convertToAiSdkMessages, @@ -18,7 +18,7 @@ vitest.mock("ai", () => ({ describe("AI SDK conversion utilities", () => { describe("convertToAiSdkMessages", () => { it("converts simple string messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there" }, ] @@ -31,7 +31,7 @@ describe("AI SDK conversion utilities", () => { }) it("converts user messages with text content blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: [{ type: "text", text: "Hello world" }], @@ -48,18 +48,15 @@ describe("AI SDK conversion utilities", () => { }) it("converts user messages with image content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: [ { type: "text", text: "What is in this image?" }, { type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "base64encodeddata", - }, + image: "base64encodeddata", + mediaType: "image/png", }, ], }, @@ -74,7 +71,7 @@ describe("AI SDK conversion utilities", () => { { type: "text", text: "What is in this image?" }, { type: "image", - image: "data:image/png;base64,base64encodeddata", + image: "base64encodeddata", mimeType: "image/png", }, ], @@ -82,7 +79,7 @@ describe("AI SDK conversion utilities", () => { }) it("converts user messages with URL image content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: [ @@ -103,25 +100,19 @@ describe("AI SDK conversion utilities", () => { 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", - }, - ], + content: [{ type: "text", text: "What is in this image?" }], }) }) it("converts tool results into separate tool role messages with resolved tool names", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "assistant", content: [ { - type: "tool_use", - id: "call_123", - name: "read_file", + type: "tool-call", + toolCallId: "call_123", + toolName: "read_file", input: { path: "test.ts" }, }, ], @@ -130,9 +121,10 @@ describe("AI SDK conversion utilities", () => { role: "user", content: [ { - type: "tool_result", - tool_use_id: "call_123", - content: "Tool result content", + type: "tool-result", + toolCallId: "call_123", + toolName: "", + output: { type: "text" as const, value: "Tool result content" }, }, ], }, @@ -167,14 +159,15 @@ describe("AI SDK conversion utilities", () => { }) it("uses unknown_tool for tool results without matching tool call", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "user", content: [ { - type: "tool_result", - tool_use_id: "call_orphan", - content: "Orphan result", + type: "tool-result", + toolCallId: "call_orphan", + toolName: "", + output: { type: "text" as const, value: "Orphan result" }, }, ], }, @@ -190,7 +183,7 @@ describe("AI SDK conversion utilities", () => { { type: "tool-result", toolCallId: "call_orphan", - toolName: "unknown_tool", + toolName: "", output: { type: "text", value: "Orphan result" }, }, ], @@ -198,14 +191,14 @@ describe("AI SDK conversion utilities", () => { }) it("separates tool results and text content into different messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "assistant", content: [ { - type: "tool_use", - id: "call_123", - name: "read_file", + type: "tool-call", + toolCallId: "call_123", + toolName: "read_file", input: { path: "test.ts" }, }, ], @@ -214,9 +207,10 @@ describe("AI SDK conversion utilities", () => { role: "user", content: [ { - type: "tool_result", - tool_use_id: "call_123", - content: "File contents here", + type: "tool-result", + toolCallId: "call_123", + toolName: "", + output: { type: "text" as const, value: "File contents here" }, }, { type: "text", @@ -260,15 +254,15 @@ describe("AI SDK conversion utilities", () => { }) it("converts assistant messages with tool use", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "assistant", content: [ { type: "text", text: "Let me read that file" }, { - type: "tool_use", - id: "call_456", - name: "read_file", + type: "tool-call", + toolCallId: "call_456", + toolName: "read_file", input: { path: "test.ts" }, }, ], @@ -293,7 +287,7 @@ describe("AI SDK conversion utilities", () => { }) it("handles empty assistant content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "assistant", content: [], @@ -310,7 +304,7 @@ describe("AI SDK conversion utilities", () => { }) it("converts assistant reasoning blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "assistant", content: [ @@ -333,11 +327,15 @@ describe("AI SDK conversion utilities", () => { }) it("converts assistant thinking blocks to reasoning", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "assistant", content: [ - { type: "thinking" as any, thinking: "Deep thought", signature: "sig" }, + { + type: "reasoning" as any, + text: "Deep thought", + providerOptions: { anthropic: { redactedContent: "sig" } }, + }, { type: "text", text: "OK" }, ], }, @@ -352,10 +350,6 @@ describe("AI SDK conversion utilities", () => { { type: "reasoning", text: "Deep thought", - providerOptions: { - bedrock: { signature: "sig" }, - anthropic: { signature: "sig" }, - }, }, { type: "text", text: "OK" }, ], @@ -363,7 +357,7 @@ describe("AI SDK conversion utilities", () => { }) it("converts assistant message-level reasoning_content to reasoning part", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "assistant", content: [{ type: "text", text: "Answer" }], @@ -384,7 +378,7 @@ describe("AI SDK conversion utilities", () => { }) it("prefers message-level reasoning_content over reasoning blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "assistant", content: [ @@ -408,15 +402,15 @@ describe("AI SDK conversion utilities", () => { }) it("attaches thoughtSignature to first tool-call part for Gemini 3 round-tripping", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "assistant", content: [ { type: "text", text: "Let me check that." }, { - type: "tool_use", - id: "tool-1", - name: "read_file", + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", input: { path: "test.txt" }, }, { type: "thoughtSignature", thoughtSignature: "encrypted-sig-abc" } as any, @@ -442,20 +436,20 @@ describe("AI SDK conversion utilities", () => { }) it("attaches thoughtSignature only to the first tool-call in parallel calls", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "assistant", content: [ { - type: "tool_use", - id: "tool-1", - name: "get_weather", + type: "tool-call", + toolCallId: "tool-1", + toolName: "get_weather", input: { city: "Paris" }, }, { - type: "tool_use", - id: "tool-2", - name: "get_weather", + type: "tool-call", + toolCallId: "tool-2", + toolName: "get_weather", input: { city: "London" }, }, { type: "thoughtSignature", thoughtSignature: "sig-parallel" } as any, @@ -479,15 +473,15 @@ describe("AI SDK conversion utilities", () => { }) it("does not attach providerOptions when no thoughtSignature block is present", () => { - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: RooMessageParam[] = [ { role: "assistant", content: [ { type: "text", text: "Using tool" }, { - type: "tool_use", - id: "tool-1", - name: "read_file", + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", input: { path: "test.txt" }, }, ], 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__/caching.spec.ts b/src/api/transform/__tests__/caching.spec.ts new file mode 100644 index 00000000000..7854019cc5d --- /dev/null +++ b/src/api/transform/__tests__/caching.spec.ts @@ -0,0 +1,276 @@ +// npx vitest run src/api/transform/__tests__/caching.spec.ts + +import type { ModelMessage } from "ai" +import { buildCachedSystemMessage, applyCacheBreakpoints } from "../caching" + +describe("caching.ts", () => { + // ── buildCachedSystemMessage ───────────────────────────────── + + describe("buildCachedSystemMessage", () => { + it("should wrap system prompt with anthropic cache control", () => { + const result = buildCachedSystemMessage("You are a helpful assistant", "anthropic") + + expect(result).toEqual({ + role: "system", + content: "You are a helpful assistant", + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, + }, + }) + }) + + it("should wrap system prompt with openrouter cache control", () => { + const result = buildCachedSystemMessage("System prompt", "openrouter") + + expect(result).toEqual({ + role: "system", + content: "System prompt", + providerOptions: { + openrouter: { cacheControl: { type: "ephemeral" } }, + }, + }) + }) + + it("should work with any arbitrary provider key", () => { + const result = buildCachedSystemMessage("Prompt", "custom-provider") + + expect(result.providerOptions).toEqual({ + "custom-provider": { cacheControl: { type: "ephemeral" } }, + }) + }) + + it("should preserve empty system prompt", () => { + const result = buildCachedSystemMessage("", "anthropic") + + expect(result.content).toBe("") + expect(result.role).toBe("system") + }) + }) + + // ── applyCacheBreakpoints — last-n strategy ───────────────── + + describe("applyCacheBreakpoints (last-n strategy)", () => { + function makeUserMessage(text: string): ModelMessage { + return { + role: "user", + content: [{ type: "text", text }], + } as ModelMessage + } + + function makeAssistantMessage(text: string): ModelMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + } as ModelMessage + } + + it("should mark last 2 user messages by default", () => { + const messages: ModelMessage[] = [ + makeUserMessage("first"), + makeAssistantMessage("reply1"), + makeUserMessage("second"), + makeAssistantMessage("reply2"), + makeUserMessage("third"), + ] + + applyCacheBreakpoints(messages, "anthropic") + + // First user message should NOT have cache control + expect((messages[0].content as any[])[0].providerOptions).toBeUndefined() + + // Second user message (index 2) SHOULD have cache control + expect((messages[2].content as any[])[0].providerOptions).toEqual({ + anthropic: { cacheControl: { type: "ephemeral" } }, + }) + + // Third user message (index 4) SHOULD have cache control + expect((messages[4].content as any[])[0].providerOptions).toEqual({ + anthropic: { cacheControl: { type: "ephemeral" } }, + }) + }) + + it("should handle a single user message", () => { + const messages: ModelMessage[] = [makeUserMessage("only one")] + + applyCacheBreakpoints(messages, "anthropic") + + expect((messages[0].content as any[])[0].providerOptions).toEqual({ + anthropic: { cacheControl: { type: "ephemeral" } }, + }) + }) + + it("should handle no user messages", () => { + const messages: ModelMessage[] = [makeAssistantMessage("assistant only")] + + applyCacheBreakpoints(messages, "anthropic") + + // Should not throw; assistant message should be untouched + expect((messages[0].content as any[])[0].providerOptions).toBeUndefined() + }) + + it("should use openrouter provider key", () => { + const messages: ModelMessage[] = [ + makeUserMessage("first"), + makeAssistantMessage("reply"), + makeUserMessage("second"), + ] + + applyCacheBreakpoints(messages, "openrouter") + + expect((messages[0].content as any[])[0].providerOptions).toEqual({ + openrouter: { cacheControl: { type: "ephemeral" } }, + }) + expect((messages[2].content as any[])[0].providerOptions).toEqual({ + openrouter: { cacheControl: { type: "ephemeral" } }, + }) + }) + + it("should support custom count via options", () => { + const messages: ModelMessage[] = [ + makeUserMessage("first"), + makeAssistantMessage("reply1"), + makeUserMessage("second"), + makeAssistantMessage("reply2"), + makeUserMessage("third"), + makeAssistantMessage("reply3"), + makeUserMessage("fourth"), + ] + + applyCacheBreakpoints(messages, "anthropic", { count: 3 }) + + // first user message should NOT have cache control + expect((messages[0].content as any[])[0].providerOptions).toBeUndefined() + + // second, third, fourth user messages should have cache control + expect((messages[2].content as any[])[0].providerOptions).toBeDefined() + expect((messages[4].content as any[])[0].providerOptions).toBeDefined() + expect((messages[6].content as any[])[0].providerOptions).toBeDefined() + }) + + it("should handle string content by wrapping in array", () => { + const messages: ModelMessage[] = [{ role: "user", content: "plain string" } as ModelMessage] + + applyCacheBreakpoints(messages, "anthropic") + + // Should have been converted to array with text part + expect(messages[0].content).toEqual([ + { + type: "text", + text: "plain string", + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, + }, + }, + ]) + }) + + it("should apply providerOptions to last text part in multi-part content", () => { + const messages: ModelMessage[] = [ + { + role: "user", + content: [ + { type: "image", image: new Uint8Array() }, + { type: "text", text: "first text" }, + { type: "text", text: "second text" }, + ], + } as ModelMessage, + ] + + applyCacheBreakpoints(messages, "anthropic") + + // First text part should NOT have providerOptions + expect((messages[0].content as any[])[1].providerOptions).toBeUndefined() + + // Last text part should have providerOptions + expect((messages[0].content as any[])[2].providerOptions).toEqual({ + anthropic: { cacheControl: { type: "ephemeral" } }, + }) + }) + + it("should not modify assistant messages even if they have text parts", () => { + const messages: ModelMessage[] = [makeAssistantMessage("I'm an assistant"), makeUserMessage("user message")] + + applyCacheBreakpoints(messages, "anthropic") + + expect((messages[0].content as any[])[0].providerOptions).toBeUndefined() + }) + }) + + // ── applyCacheBreakpoints — every-nth strategy ────────────── + + describe("applyCacheBreakpoints (every-nth strategy)", () => { + function makeUserMessage(text: string): ModelMessage { + return { + role: "user", + content: [{ type: "text", text }], + } as ModelMessage + } + + function makeAssistantMessage(text: string): ModelMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + } as ModelMessage + } + + it("should mark every 10th user message by default (0-indexed, marks index 9, 19, ...)", () => { + // Create 12 user messages interleaved with assistant messages + const messages: ModelMessage[] = [] + for (let i = 0; i < 12; i++) { + messages.push(makeUserMessage(`user-${i}`)) + messages.push(makeAssistantMessage(`reply-${i}`)) + } + + applyCacheBreakpoints(messages, "openrouter", { style: "every-nth" }) + + // Only the 10th user message (0-indexed: 9) should be marked. + // In the messages array, user messages are at even indices: 0, 2, 4, ..., 22 + // The 10th user message (index 9) is at array position 18 + for (let i = 0; i < 12; i++) { + const userMsgIdx = i * 2 + const hasCacheControl = (messages[userMsgIdx].content as any[])[0].providerOptions !== undefined + if (i === 9) { + expect(hasCacheControl).toBe(true) + } else { + expect(hasCacheControl).toBe(false) + } + } + }) + + it("should mark every Nth user message with custom frequency", () => { + // Create 8 user messages + const messages: ModelMessage[] = [] + for (let i = 0; i < 8; i++) { + messages.push(makeUserMessage(`user-${i}`)) + messages.push(makeAssistantMessage(`reply-${i}`)) + } + + applyCacheBreakpoints(messages, "openrouter", { style: "every-nth", frequency: 3 }) + + // With frequency=3, marks user indices 2, 5 (i.e., count % 3 === 2) + for (let i = 0; i < 8; i++) { + const userMsgIdx = i * 2 + const hasCacheControl = (messages[userMsgIdx].content as any[])[0].providerOptions !== undefined + if (i === 2 || i === 5) { + expect(hasCacheControl).toBe(true) + } else { + expect(hasCacheControl).toBe(false) + } + } + }) + + it("should not mark any messages if fewer than frequency", () => { + const messages: ModelMessage[] = [ + makeUserMessage("first"), + makeAssistantMessage("reply"), + makeUserMessage("second"), + ] + + applyCacheBreakpoints(messages, "openrouter", { style: "every-nth", frequency: 10 }) + + // Only 2 user messages, less than frequency 10: none should be marked + expect((messages[0].content as any[])[0].providerOptions).toBeUndefined() + expect((messages[2].content as any[])[0].providerOptions).toBeUndefined() + }) + }) +}) diff --git a/src/api/transform/__tests__/image-cleaning.spec.ts b/src/api/transform/__tests__/image-cleaning.spec.ts index fc91e0da46e..4cb79acac1f 100644 --- a/src/api/transform/__tests__/image-cleaning.spec.ts +++ b/src/api/transform/__tests__/image-cleaning.spec.ts @@ -87,11 +87,8 @@ describe("maybeRemoveImageBlocks", () => { }, { type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64-encoded-image-data", - }, + image: "base64-encoded-image-data", + mediaType: "image/jpeg", }, ], }, @@ -116,11 +113,8 @@ describe("maybeRemoveImageBlocks", () => { }, { type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64-encoded-image-data", - }, + image: "base64-encoded-image-data", + mediaType: "image/jpeg", }, ], }, @@ -159,11 +153,8 @@ describe("maybeRemoveImageBlocks", () => { }, { type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "image-data-1", - }, + image: "image-data-1", + mediaType: "image/jpeg", }, { type: "text", @@ -171,11 +162,8 @@ describe("maybeRemoveImageBlocks", () => { }, { type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "image-data-2", - }, + image: "image-data-2", + mediaType: "image/png", }, ], }, @@ -222,11 +210,8 @@ describe("maybeRemoveImageBlocks", () => { }, { type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "image-data-1", - }, + image: "image-data-1", + mediaType: "image/jpeg", }, ], }, @@ -243,11 +228,8 @@ describe("maybeRemoveImageBlocks", () => { }, { type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "image-data-2", - }, + image: "image-data-2", + mediaType: "image/png", }, ], }, @@ -303,11 +285,8 @@ describe("maybeRemoveImageBlocks", () => { }, { type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "image-data", - }, + image: "image-data", + mediaType: "image/jpeg", }, ], ts: 1620000000000, diff --git a/src/api/transform/__tests__/minimax-format.spec.ts b/src/api/transform/__tests__/minimax-format.spec.ts deleted file mode 100644 index 271dfb51052..00000000000 --- a/src/api/transform/__tests__/minimax-format.spec.ts +++ /dev/null @@ -1,336 +0,0 @@ -// npx vitest run api/transform/__tests__/minimax-format.spec.ts - -import { Anthropic } from "@anthropic-ai/sdk" - -import { mergeEnvironmentDetailsForMiniMax } from "../minimax-format" - -describe("mergeEnvironmentDetailsForMiniMax", () => { - it("should pass through simple text messages unchanged", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: "Hi there!", - }, - ] - - const result = mergeEnvironmentDetailsForMiniMax(messages) - - expect(result).toHaveLength(2) - expect(result).toEqual(messages) - }) - - it("should pass through user messages with only tool_result blocks unchanged", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result content", - }, - ], - }, - ] - - const result = mergeEnvironmentDetailsForMiniMax(messages) - - expect(result).toHaveLength(1) - expect(result).toEqual(messages) - }) - - it("should pass through user messages with only text blocks unchanged", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Some user message", - }, - ], - }, - ] - - const result = mergeEnvironmentDetailsForMiniMax(messages) - - expect(result).toHaveLength(1) - expect(result).toEqual(messages) - }) - - it("should merge text content into last tool_result when both tool_result AND text blocks exist", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result content", - }, - { - type: "text", - text: "\nCurrent Time: 2024-01-01\n", - }, - ], - }, - ] - - const result = mergeEnvironmentDetailsForMiniMax(messages) - - // The message should have only tool_result with merged content - expect(result).toHaveLength(1) - expect(result[0].role).toBe("user") - const content = result[0].content as Anthropic.Messages.ToolResultBlockParam[] - expect(content).toHaveLength(1) - expect(content[0].type).toBe("tool_result") - expect(content[0].tool_use_id).toBe("tool-123") - expect(content[0].content).toBe( - "Tool result content\n\n\nCurrent Time: 2024-01-01\n", - ) - }) - - it("should merge multiple text blocks into last tool_result", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result 1", - }, - { - type: "text", - text: "First text block", - }, - { - type: "tool_result", - tool_use_id: "tool-456", - content: "Tool result 2", - }, - { - type: "text", - text: "Second text block", - }, - ], - }, - ] - - const result = mergeEnvironmentDetailsForMiniMax(messages) - - // The message should have only tool_result blocks, with text merged into the last one - expect(result).toHaveLength(1) - const content = result[0].content as Anthropic.Messages.ToolResultBlockParam[] - expect(content).toHaveLength(2) - expect(content[0].type).toBe("tool_result") - expect(content[0].content).toBe("Tool result 1") // First one unchanged - expect(content[1].type).toBe("tool_result") - expect(content[1].content).toBe("Tool result 2\n\nFirst text block\n\nSecond text block") // Second has merged text - }) - - it("should NOT merge text when images are present (cannot move images to tool_result)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result content", - }, - { - type: "text", - text: "Some text", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "base64data", - }, - }, - ], - }, - ] - - const result = mergeEnvironmentDetailsForMiniMax(messages) - - // Message should be unchanged since images are present - expect(result).toHaveLength(1) - expect(result).toEqual(messages) - }) - - it("should pass through assistant messages unchanged", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "text", - text: "I will help you with that.", - }, - { - type: "tool_use", - id: "tool-123", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - ] - - const result = mergeEnvironmentDetailsForMiniMax(messages) - - expect(result).toHaveLength(1) - expect(result).toEqual(messages) - }) - - it("should handle mixed conversation with merging only for eligible messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Create a file", - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I'll create the file.", - }, - { - type: "tool_use", - id: "tool-123", - name: "write_file", - input: { path: "test.ts", content: "// test" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "File created successfully", - }, - { - type: "text", - text: "\nCurrent Time: 2024-01-01\n", - }, - ], - }, - { - role: "assistant", - content: "The file has been created.", - }, - ] - - const result = mergeEnvironmentDetailsForMiniMax(messages) - - // Should have all 4 messages - expect(result).toHaveLength(4) - - // First user message unchanged (simple string) - expect(result[0]).toEqual(messages[0]) - - // Assistant message unchanged - expect(result[1]).toEqual(messages[1]) - - // Third message should have tool_result with merged environment_details - const thirdMessage = result[2].content as Anthropic.Messages.ToolResultBlockParam[] - expect(thirdMessage).toHaveLength(1) - expect(thirdMessage[0].type).toBe("tool_result") - expect(thirdMessage[0].content).toContain("File created successfully") - expect(thirdMessage[0].content).toContain("environment_details") - - // Fourth message unchanged - expect(result[3]).toEqual(messages[3]) - }) - - it("should handle string content in user messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Just a string message", - }, - ] - - const result = mergeEnvironmentDetailsForMiniMax(messages) - - expect(result).toHaveLength(1) - expect(result).toEqual(messages) - }) - - it("should handle empty messages array", () => { - const messages: Anthropic.Messages.MessageParam[] = [] - - const result = mergeEnvironmentDetailsForMiniMax(messages) - - expect(result).toHaveLength(0) - }) - - it("should handle tool_result with array content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: [ - { type: "text", text: "Part 1" }, - { type: "text", text: "Part 2" }, - ], - }, - { - type: "text", - text: "Context", - }, - ], - }, - ] - - const result = mergeEnvironmentDetailsForMiniMax(messages) - - expect(result).toHaveLength(1) - const content = result[0].content as Anthropic.Messages.ToolResultBlockParam[] - expect(content).toHaveLength(1) - expect(content[0].type).toBe("tool_result") - // Array content should be concatenated and then merged with text - expect(content[0].content).toBe("Part 1\nPart 2\n\nContext") - }) - - it("should handle tool_result with empty content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "", - }, - { - type: "text", - text: "Context", - }, - ], - }, - ] - - const result = mergeEnvironmentDetailsForMiniMax(messages) - - expect(result).toHaveLength(1) - const content = result[0].content as Anthropic.Messages.ToolResultBlockParam[] - expect(content).toHaveLength(1) - expect(content[0].type).toBe("tool_result") - expect(content[0].content).toBe("Context") - }) -}) 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 1a4c7f6518d..00000000000 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ /dev/null @@ -1,1305 +0,0 @@ -// npx vitest run api/transform/__tests__/openai-format.spec.ts - -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -import { - convertToOpenAiMessages, - consolidateReasoningDetails, - sanitizeGeminiMessages, - ReasoningDetail, -} from "../openai-format" -import { normalizeMistralToolCallId } from "../mistral-format" - -describe("convertToOpenAiMessages", () => { - it("should convert simple text messages", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - 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: 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 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 handle assistant messages with tool use (no normalization without normalizeToolCallId)", () => { - const anthropicMessages: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-456", - // content is undefined/not provided - } as Anthropic.ToolResultBlockParam, - ], - }, - ] - - 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: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-789", - content: [], // Empty array - } as Anthropic.ToolResultBlockParam, - ], - }, - ] - - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: 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 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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: Anthropic.Messages.MessageParam[] = [ - { - 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__/vscode-lm-format.spec.ts b/src/api/transform/__tests__/vscode-lm-format.spec.ts deleted file mode 100644 index e60860b5491..00000000000 --- a/src/api/transform/__tests__/vscode-lm-format.spec.ts +++ /dev/null @@ -1,348 +0,0 @@ -// npx vitest run src/api/transform/__tests__/vscode-lm-format.spec.ts - -import { Anthropic } from "@anthropic-ai/sdk" -import * as vscode from "vscode" - -import { convertToVsCodeLmMessages, convertToAnthropicRole, extractTextCountFromMessage } from "../vscode-lm-format" - -// Mock crypto using Vitest -vitest.stubGlobal("crypto", { - randomUUID: () => "test-uuid", -}) - -// Define types for our mocked classes -interface MockLanguageModelTextPart { - type: "text" - value: string -} - -interface MockLanguageModelToolCallPart { - type: "tool_call" - callId: string - name: string - input: any -} - -interface MockLanguageModelToolResultPart { - type: "tool_result" - callId: string - content: MockLanguageModelTextPart[] -} - -// Mock vscode namespace -vitest.mock("vscode", () => { - const LanguageModelChatMessageRole = { - Assistant: "assistant", - User: "user", - } - - class MockLanguageModelTextPart { - type = "text" - constructor(public value: string) {} - } - - class MockLanguageModelToolCallPart { - type = "tool_call" - constructor( - public callId: string, - public name: string, - public input: any, - ) {} - } - - class MockLanguageModelToolResultPart { - type = "tool_result" - constructor( - public callId: string, - public content: MockLanguageModelTextPart[], - ) {} - } - - return { - LanguageModelChatMessage: { - Assistant: vitest.fn((content) => ({ - role: LanguageModelChatMessageRole.Assistant, - name: "assistant", - content: Array.isArray(content) ? content : [new MockLanguageModelTextPart(content)], - })), - User: vitest.fn((content) => ({ - role: LanguageModelChatMessageRole.User, - name: "user", - content: Array.isArray(content) ? content : [new MockLanguageModelTextPart(content)], - })), - }, - LanguageModelChatMessageRole, - LanguageModelTextPart: MockLanguageModelTextPart, - LanguageModelToolCallPart: MockLanguageModelToolCallPart, - LanguageModelToolResultPart: MockLanguageModelToolResultPart, - } -}) - -describe("convertToVsCodeLmMessages", () => { - it("should convert simple string messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there" }, - ] - - const result = convertToVsCodeLmMessages(messages) - - expect(result).toHaveLength(2) - expect(result[0].role).toBe("user") - expect((result[0].content[0] as MockLanguageModelTextPart).value).toBe("Hello") - expect(result[1].role).toBe("assistant") - expect((result[1].content[0] as MockLanguageModelTextPart).value).toBe("Hi there") - }) - - it("should handle complex user messages with tool results", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "Here is the result:" }, - { - type: "tool_result", - tool_use_id: "tool-1", - content: "Tool output", - }, - ], - }, - ] - - const result = convertToVsCodeLmMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0].role).toBe("user") - expect(result[0].content).toHaveLength(2) - const [toolResult, textContent] = result[0].content as [ - MockLanguageModelToolResultPart, - MockLanguageModelTextPart, - ] - expect(toolResult.type).toBe("tool_result") - expect(textContent.type).toBe("text") - }) - - it("should handle complex assistant messages with tool calls", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "text", text: "Let me help you with that." }, - { - type: "tool_use", - id: "tool-1", - name: "calculator", - input: { operation: "add", numbers: [2, 2] }, - }, - ], - }, - ] - - const result = convertToVsCodeLmMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0].role).toBe("assistant") - expect(result[0].content).toHaveLength(2) - // Text must come before tool calls so that tool calls are at the end, - // properly followed by user message with tool results - const [textContent, toolCall] = result[0].content as [MockLanguageModelTextPart, MockLanguageModelToolCallPart] - expect(textContent.type).toBe("text") - expect(toolCall.type).toBe("tool_call") - }) - - it("should handle image blocks with appropriate placeholders", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "Look at this:" }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "base64data", - }, - }, - ], - }, - ] - - const result = convertToVsCodeLmMessages(messages) - - expect(result).toHaveLength(1) - const imagePlaceholder = result[0].content[1] as MockLanguageModelTextPart - expect(imagePlaceholder.value).toContain("[Image (base64): image/png not supported by VSCode LM API]") - }) -}) - -describe("convertToAnthropicRole", () => { - it("should convert assistant role correctly", () => { - const result = convertToAnthropicRole("assistant" as any) - expect(result).toBe("assistant") - }) - - it("should convert user role correctly", () => { - const result = convertToAnthropicRole("user" as any) - expect(result).toBe("user") - }) - - it("should return null for unknown roles", () => { - const result = convertToAnthropicRole("unknown" as any) - expect(result).toBeNull() - }) -}) - -describe("extractTextCountFromMessage", () => { - it("should extract text from simple string content", () => { - const message = { - role: "user", - content: "Hello world", - } as any - - const result = extractTextCountFromMessage(message) - expect(result).toBe("Hello world") - }) - - it("should extract text from LanguageModelTextPart", () => { - const mockTextPart = new (vitest.mocked(vscode).LanguageModelTextPart)("Text content") - const message = { - role: "user", - content: [mockTextPart], - } as any - - const result = extractTextCountFromMessage(message) - expect(result).toBe("Text content") - }) - - it("should extract text from multiple LanguageModelTextParts", () => { - const mockTextPart1 = new (vitest.mocked(vscode).LanguageModelTextPart)("First part") - const mockTextPart2 = new (vitest.mocked(vscode).LanguageModelTextPart)("Second part") - const message = { - role: "user", - content: [mockTextPart1, mockTextPart2], - } as any - - const result = extractTextCountFromMessage(message) - expect(result).toBe("First partSecond part") - }) - - it("should extract text from LanguageModelToolResultPart", () => { - const mockTextPart = new (vitest.mocked(vscode).LanguageModelTextPart)("Tool result content") - const mockToolResultPart = new (vitest.mocked(vscode).LanguageModelToolResultPart)("tool-result-id", [ - mockTextPart, - ]) - const message = { - role: "user", - content: [mockToolResultPart], - } as any - - const result = extractTextCountFromMessage(message) - expect(result).toBe("tool-result-idTool result content") - }) - - it("should extract text from LanguageModelToolCallPart without input", () => { - const mockToolCallPart = new (vitest.mocked(vscode).LanguageModelToolCallPart)("call-id", "tool-name", {}) - const message = { - role: "assistant", - content: [mockToolCallPart], - } as any - - const result = extractTextCountFromMessage(message) - expect(result).toBe("tool-namecall-id") - }) - - it("should extract text from LanguageModelToolCallPart with input", () => { - const mockInput = { operation: "add", numbers: [1, 2, 3] } - const mockToolCallPart = new (vitest.mocked(vscode).LanguageModelToolCallPart)( - "call-id", - "calculator", - mockInput, - ) - const message = { - role: "assistant", - content: [mockToolCallPart], - } as any - - const result = extractTextCountFromMessage(message) - expect(result).toBe(`calculatorcall-id${JSON.stringify(mockInput)}`) - }) - - it("should extract text from LanguageModelToolCallPart with empty input", () => { - const mockToolCallPart = new (vitest.mocked(vscode).LanguageModelToolCallPart)("call-id", "tool-name", {}) - const message = { - role: "assistant", - content: [mockToolCallPart], - } as any - - const result = extractTextCountFromMessage(message) - expect(result).toBe("tool-namecall-id") - }) - - it("should extract text from mixed content types", () => { - const mockTextPart = new (vitest.mocked(vscode).LanguageModelTextPart)("Text content") - const mockToolResultTextPart = new (vitest.mocked(vscode).LanguageModelTextPart)("Tool result") - const mockToolResultPart = new (vitest.mocked(vscode).LanguageModelToolResultPart)("result-id", [ - mockToolResultTextPart, - ]) - const mockInput = { param: "value" } - const mockToolCallPart = new (vitest.mocked(vscode).LanguageModelToolCallPart)("call-id", "tool", mockInput) - - const message = { - role: "assistant", - content: [mockTextPart, mockToolResultPart, mockToolCallPart], - } as any - - const result = extractTextCountFromMessage(message) - expect(result).toBe(`Text contentresult-idTool resulttoolcall-id${JSON.stringify(mockInput)}`) - }) - - it("should handle empty array content", () => { - const message = { - role: "user", - content: [], - } as any - - const result = extractTextCountFromMessage(message) - expect(result).toBe("") - }) - - it("should handle undefined content", () => { - const message = { - role: "user", - content: undefined, - } as any - - const result = extractTextCountFromMessage(message) - expect(result).toBe("") - }) - - it("should handle ToolResultPart with multiple text parts", () => { - const mockTextPart1 = new (vitest.mocked(vscode).LanguageModelTextPart)("Part 1") - const mockTextPart2 = new (vitest.mocked(vscode).LanguageModelTextPart)("Part 2") - const mockToolResultPart = new (vitest.mocked(vscode).LanguageModelToolResultPart)("result-id", [ - mockTextPart1, - mockTextPart2, - ]) - - const message = { - role: "user", - content: [mockToolResultPart], - } as any - - const result = extractTextCountFromMessage(message) - expect(result).toBe("result-idPart 1Part 2") - }) - - it("should handle ToolResultPart with empty parts array", () => { - const mockToolResultPart = new (vitest.mocked(vscode).LanguageModelToolResultPart)("result-id", []) - - const message = { - role: "user", - content: [mockToolResultPart], - } as any - - const result = extractTextCountFromMessage(message) - expect(result).toBe("result-id") - }) -}) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index c673fad3d27..152e71d0b7c 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -3,7 +3,7 @@ * These utilities are designed to be reused across different AI SDK providers. */ -import { Anthropic } from "@anthropic-ai/sdk" +import type { NeutralMessageParam } from "../../core/task-persistence" import OpenAI from "openai" import { tool as createTool, jsonSchema, type ModelMessage, type TextStreamPart } from "ai" import type { ApiStreamChunk } from "./stream" @@ -28,7 +28,7 @@ export interface ConvertToAiSdkMessagesOptions { * @returns Array of AI SDK ModelMessage objects */ export function convertToAiSdkMessages( - messages: Anthropic.Messages.MessageParam[], + messages: NeutralMessageParam[], options?: ConvertToAiSdkMessagesOptions, ): ModelMessage[] { const modelMessages: ModelMessage[] = [] @@ -38,8 +38,8 @@ export function convertToAiSdkMessages( 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) + if (part.type === "tool-call") { + toolCallIdToName.set(part.toolCallId, part.toolName) } } } @@ -67,40 +67,43 @@ export function convertToAiSdkMessages( 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) { + // Handle image data - ImagePart has { image: DataContent | URL, mediaType?: string } + const imageData = part.image + if (typeof imageData === "string") { parts.push({ type: "image", - image: `data:${source.media_type};base64,${source.data}`, - mimeType: source.media_type, + image: imageData, + mimeType: part.mediaType, }) - } else if (source.type === "url" && source.url) { + } else if (imageData instanceof URL) { parts.push({ type: "image", - image: source.url, + image: imageData.toString(), }) } - } else if (part.type === "tool_result") { + } else if (part.type === "tool-result") { // Convert tool results to string content let content: string - if (typeof part.content === "string") { - content = part.content - } else { + const output = part.output + if (output?.type === "text" || output?.type === "error-text") { + content = output.value + } else if (output?.type === "content") { content = - part.content - ?.map((c) => { + (output.value as Array) + ?.map((c: any) => { if (c.type === "text") return c.text if (c.type === "image") return "(image)" return "" }) .join("\n") ?? "" + } else { + content = output ? JSON.stringify(output) : "" } // Look up the tool name from the tool call ID - const toolName = toolCallIdToName.get(part.tool_use_id) ?? "unknown_tool" + const toolName = toolCallIdToName.get(part.toolCallId) ?? part.toolName ?? "unknown_tool" toolResults.push({ type: "tool-result", - toolCallId: part.tool_use_id, + toolCallId: part.toolCallId, toolName, output: { type: "text", value: content || "(empty)" }, }) @@ -160,11 +163,11 @@ export function convertToAiSdkMessages( continue } - if (part.type === "tool_use") { + if (part.type === "tool-call") { const toolCall: (typeof toolCalls)[number] = { type: "tool-call", - toolCallId: part.id, - toolName: part.name, + toolCallId: part.toolCallId, + toolName: part.toolName, input: part.input, } 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/caching.ts b/src/api/transform/caching.ts new file mode 100644 index 00000000000..a36fe846fc5 --- /dev/null +++ b/src/api/transform/caching.ts @@ -0,0 +1,151 @@ +/** + * Unified cache control utility for AI SDK providers. + * + * Adds cache control hints via providerOptions on messages. + * Works for any provider that uses the `providerOptions.{key}.cacheControl` + * pattern (e.g., anthropic, openrouter). + * + * Providers with fundamentally different caching mechanisms (bedrock's + * cachePoint, lite-llm's raw cache_control on wire format) are NOT covered + * here and keep their own inline logic. + */ +import type { SystemModelMessage, ModelMessage } from "ai" + +// ── Types ─────────────────────────────────────────────────────── + +type CacheOpts = Record> + +export interface CacheBreakpointOptions { + /** + * Strategy for selecting which user messages to mark. + * + * - `"last-n"` (default): mark the last `count` user messages. + * - `"every-nth"`: mark every `frequency`-th user message. + */ + style?: "last-n" | "every-nth" + + /** + * For `"last-n"` style: how many trailing user messages to mark. + * @default 2 + */ + count?: number + + /** + * For `"every-nth"` style: mark every N-th user message (0-indexed, + * so `frequency: 10` marks indices 9, 19, 29, …). + * @default 10 + */ + frequency?: number +} + +// ── Helpers ───────────────────────────────────────────────────── + +function buildCacheOpts(providerKey: string): CacheOpts { + return { + [providerKey]: { cacheControl: { type: "ephemeral" } }, + } as CacheOpts +} + +/** + * Add cache control providerOptions to the last text part of a user message. + * + * If the message content is a plain string it is converted to a single-element + * `[{ type: "text", text, providerOptions }]` array so the AI SDK provider can + * pick up the cache hint on the content part. + */ +function addCacheToLastTextPart(message: ModelMessage, cacheOpts: CacheOpts): void { + if (message.role !== "user") { + return + } + + // Handle string content by wrapping in array with providerOptions + if (typeof message.content === "string") { + ;(message as Record).content = [ + { type: "text", text: message.content, providerOptions: cacheOpts }, + ] + return + } + + if (Array.isArray(message.content)) { + // Find last text part and add providerOptions. + // The AI SDK provider reads cacheControl from providerOptions on content + // parts at runtime, but the static types don't expose the property. + // The same `as any` cast was used by every provider before extraction. + for (let i = message.content.length - 1; i >= 0; i--) { + if (message.content[i].type === "text") { + ;(message.content[i] as any).providerOptions = cacheOpts + return + } + } + } +} + +// ── Public API ────────────────────────────────────────────────── + +/** + * Wrap a system prompt string with cache control providerOptions for the + * given provider key. + * + * @example + * ```ts + * const system = buildCachedSystemMessage(systemPrompt, "anthropic") + * // → { role: "system", content: systemPrompt, providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } } } + * ``` + */ +export function buildCachedSystemMessage(systemPrompt: string, providerKey: string): SystemModelMessage { + return { + role: "system" as const, + content: systemPrompt, + providerOptions: buildCacheOpts(providerKey) as any, + } +} + +/** + * Apply cache control breakpoints to user messages in an AI SDK message + * array (mutates in place). + * + * Two strategies are supported: + * + * 1. **`"last-n"`** (default) – marks the last `count` (default 2) user + * messages. This matches the Anthropic prompt-caching strategy where + * the latest user message is a write-to-cache and the second-to-last + * is a read-from-cache. + * + * 2. **`"every-nth"`** – marks every `frequency`-th user message. Used + * by OpenRouter for Gemini-style caching. + * + * @param messages The AI SDK messages array (mutated in place). + * @param providerKey The provider options key (e.g. `"anthropic"`, `"openrouter"`). + * @param options Optional strategy configuration. + */ +export function applyCacheBreakpoints( + messages: ModelMessage[], + providerKey: string, + options?: CacheBreakpointOptions, +): void { + const cacheOpts = buildCacheOpts(providerKey) + const style = options?.style ?? "last-n" + + if (style === "last-n") { + const count = options?.count ?? 2 + const userIndices = messages.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0) + const targets = userIndices.slice(-count) + + for (const idx of targets) { + addCacheToLastTextPart(messages[idx], cacheOpts) + } + } else { + // "every-nth" + const frequency = options?.frequency ?? 10 + let userCount = 0 + + for (const msg of messages) { + if (msg.role === "user") { + if (userCount % frequency === frequency - 1) { + addCacheToLastTextPart(msg, cacheOpts) + } + userCount++ + } + } + } +} diff --git a/src/api/transform/caching/__tests__/anthropic.spec.ts b/src/api/transform/caching/__tests__/anthropic.spec.ts deleted file mode 100644 index b0a6269cd81..00000000000 --- a/src/api/transform/caching/__tests__/anthropic.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -// npx vitest run src/api/transform/caching/__tests__/anthropic.spec.ts - -import OpenAI from "openai" - -import { addCacheBreakpoints } from "../anthropic" - -describe("addCacheBreakpoints (Anthropic)", () => { - const systemPrompt = "You are a helpful assistant." - - it("should always add a cache breakpoint to the system prompt", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "Hello" }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - expect(messages[0].content).toEqual([ - { type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }, - ]) - }) - - it("should not add breakpoints to user messages if there are none", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [{ role: "system", content: systemPrompt }] - const originalMessages = JSON.parse(JSON.stringify(messages)) - - addCacheBreakpoints(systemPrompt, messages) - - expect(messages[0].content).toEqual([ - { type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }, - ]) - - expect(messages.length).toBe(originalMessages.length) - }) - - it("should add a breakpoint to the only user message if only one exists", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "User message 1" }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - expect(messages[1].content).toEqual([ - { type: "text", text: "User message 1", cache_control: { type: "ephemeral" } }, - ]) - }) - - it("should add breakpoints to both user messages if only two exist", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "User message 1" }, - { role: "user", content: "User message 2" }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - expect(messages[1].content).toEqual([ - { type: "text", text: "User message 1", cache_control: { type: "ephemeral" } }, - ]) - - expect(messages[2].content).toEqual([ - { type: "text", text: "User message 2", cache_control: { type: "ephemeral" } }, - ]) - }) - - it("should add breakpoints to the last two user messages when more than two exist", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "User message 1" }, // Should not get breakpoint. - { role: "user", content: "User message 2" }, // Should get breakpoint. - { role: "user", content: "User message 3" }, // Should get breakpoint. - ] - addCacheBreakpoints(systemPrompt, messages) - - expect(messages[1].content).toEqual([{ type: "text", text: "User message 1" }]) - - expect(messages[2].content).toEqual([ - { type: "text", text: "User message 2", cache_control: { type: "ephemeral" } }, - ]) - - expect(messages[3].content).toEqual([ - { type: "text", text: "User message 3", cache_control: { type: "ephemeral" } }, - ]) - }) - - it("should handle assistant messages correctly when finding last two user messages", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "User message 1" }, // Should not get breakpoint. - { role: "assistant", content: "Assistant response 1" }, - { role: "user", content: "User message 2" }, // Should get breakpoint (second to last user). - { role: "assistant", content: "Assistant response 2" }, - { role: "user", content: "User message 3" }, // Should get breakpoint (last user). - { role: "assistant", content: "Assistant response 3" }, - ] - addCacheBreakpoints(systemPrompt, messages) - - const userMessages = messages.filter((m) => m.role === "user") - - expect(userMessages[0].content).toEqual([{ type: "text", text: "User message 1" }]) - - expect(userMessages[1].content).toEqual([ - { type: "text", text: "User message 2", cache_control: { type: "ephemeral" } }, - ]) - - expect(userMessages[2].content).toEqual([ - { type: "text", text: "User message 3", cache_control: { type: "ephemeral" } }, - ]) - }) - - it("should add breakpoint to the last text part if content is an array", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "User message 1" }, - { - role: "user", - content: [ - { type: "text", text: "This is the last user message." }, - { type: "image_url", image_url: { url: "data:image/png;base64,..." } }, - { type: "text", text: "This part should get the breakpoint." }, - ], - }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - expect(messages[1].content).toEqual([ - { type: "text", text: "User message 1", cache_control: { type: "ephemeral" } }, - ]) - - expect(messages[2].content).toEqual([ - { type: "text", text: "This is the last user message." }, - { type: "image_url", image_url: { url: "data:image/png;base64,..." } }, - { type: "text", text: "This part should get the breakpoint.", cache_control: { type: "ephemeral" } }, - ]) - }) - - it("should add a placeholder text part if the target message has no text parts", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "User message 1" }, - { - role: "user", - content: [{ type: "image_url", image_url: { url: "data:image/png;base64,..." } }], - }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - expect(messages[1].content).toEqual([ - { type: "text", text: "User message 1", cache_control: { type: "ephemeral" } }, - ]) - - expect(messages[2].content).toEqual([ - { type: "image_url", image_url: { url: "data:image/png;base64,..." } }, - { type: "text", text: "...", cache_control: { type: "ephemeral" } }, // Placeholder added. - ]) - }) - - it("should ensure content is array format even if no breakpoint added", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "User message 1" }, // String content, no breakpoint. - { role: "user", content: "User message 2" }, // Gets breakpoint. - { role: "user", content: "User message 3" }, // Gets breakpoint. - ] - - addCacheBreakpoints(systemPrompt, messages) - - expect(messages[1].content).toEqual([{ type: "text", text: "User message 1" }]) - - expect(messages[2].content).toEqual([ - { type: "text", text: "User message 2", cache_control: { type: "ephemeral" } }, - ]) - - expect(messages[3].content).toEqual([ - { type: "text", text: "User message 3", cache_control: { type: "ephemeral" } }, - ]) - }) -}) diff --git a/src/api/transform/caching/__tests__/gemini.spec.ts b/src/api/transform/caching/__tests__/gemini.spec.ts deleted file mode 100644 index e7268da7fbb..00000000000 --- a/src/api/transform/caching/__tests__/gemini.spec.ts +++ /dev/null @@ -1,266 +0,0 @@ -// npx vitest run src/api/transform/caching/__tests__/gemini.spec.ts - -import OpenAI from "openai" - -import { addCacheBreakpoints } from "../gemini" - -describe("addCacheBreakpoints", () => { - const systemPrompt = "You are a helpful assistant." - - it("should always add a cache breakpoint to the system prompt", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "Hello" }, - ] - addCacheBreakpoints(systemPrompt, messages, 10) // Pass frequency - expect(messages[0].content).toEqual([ - { type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }, - ]) - }) - - it("should not add breakpoints for fewer than N user messages", () => { - const frequency = 5 - - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...Array.from({ length: frequency - 1 }, (_, i) => ({ - role: "user" as const, - content: `User message ${i + 1}`, - })), - ] - - const originalMessages = JSON.parse(JSON.stringify(messages)) - - addCacheBreakpoints(systemPrompt, messages, frequency) - - expect(messages[0].content).toEqual([ - { type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }, - ]) - - for (let i = 1; i < messages.length; i++) { - const originalContent = originalMessages[i].content - - const expectedContent = - typeof originalContent === "string" ? [{ type: "text", text: originalContent }] : originalContent - - expect(messages[i].content).toEqual(expectedContent) - } - }) - - it("should add a breakpoint to the Nth user message", () => { - const frequency = 5 - - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...Array.from({ length: frequency }, (_, i) => ({ - role: "user" as const, - content: `User message ${i + 1}`, - })), - ] - - addCacheBreakpoints(systemPrompt, messages, frequency) - - // Check Nth user message (index 'frequency' in the full array). - expect(messages[frequency].content).toEqual([ - { type: "text", text: `User message ${frequency}`, cache_control: { type: "ephemeral" } }, - ]) - - // Check (N-1)th user message (index frequency-1) - should be unchanged. - expect(messages[frequency - 1].content).toEqual([{ type: "text", text: `User message ${frequency - 1}` }]) - }) - - it("should add breakpoints to the Nth and 2*Nth user messages", () => { - const frequency = 5 - - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...Array.from({ length: frequency * 2 }, (_, i) => ({ - role: "user" as const, - content: `User message ${i + 1}`, - })), - ] - - expect(messages.length).toEqual(frequency * 2 + 1) - - addCacheBreakpoints(systemPrompt, messages, frequency) - - const indices = [] - - for (let i = 0; i < messages.length; i++) { - const content = messages[i].content?.[0] - - if (typeof content === "object" && "cache_control" in content) { - indices.push(i) - } - } - - expect(indices).toEqual([0, 5, 10]) - - // Check Nth user message (index frequency) - expect(messages[frequency].content).toEqual([ - { type: "text", text: `User message ${frequency}`, cache_control: { type: "ephemeral" } }, - ]) - - // Check (2*N-1)th user message (index 2*frequency-1) - unchanged - expect(messages[frequency * 2 - 1].content).toEqual([ - { type: "text", text: `User message ${frequency * 2 - 1}` }, - ]) - - // Check 2*Nth user message (index 2*frequency) - expect(messages[frequency * 2].content).toEqual([ - { type: "text", text: `User message ${frequency * 2}`, cache_control: { type: "ephemeral" } }, - ]) - }) - - it("should handle assistant messages correctly when counting user messages", () => { - const frequency = 5 - - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - // N-1 user messages - ...Array.from({ length: frequency - 1 }, (_, i) => ({ - role: "user" as const, - content: `User message ${i + 1}`, - })), - { role: "assistant", content: "Assistant response" }, - { role: "user", content: `User message ${frequency}` }, // This is the Nth user message. - { role: "assistant", content: "Another response" }, - { role: "user", content: `User message ${frequency + 1}` }, - ] - - addCacheBreakpoints(systemPrompt, messages, frequency) - - // Find the Nth user message. - const nthUserMessage = messages.filter((m) => m.role === "user")[frequency - 1] - expect(nthUserMessage.content).toEqual([ - { type: "text", text: `User message ${frequency}`, cache_control: { type: "ephemeral" } }, - ]) - - // Check the (N+1)th user message is unchanged. - const nPlusOneUserMessage = messages.filter((m) => m.role === "user")[frequency] - expect(nPlusOneUserMessage.content).toEqual([{ type: "text", text: `User message ${frequency + 1}` }]) - }) - - it("should add breakpoint to the last text part if content is an array", () => { - const frequency = 5 - - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...Array.from({ length: frequency - 1 }, (_, i) => ({ - role: "user" as const, - content: `User message ${i + 1}`, - })), - { - role: "user", // Nth user message - content: [ - { type: "text", text: `This is the ${frequency}th user message.` }, - { type: "image_url", image_url: { url: "data:image/png;base64,..." } }, - { type: "text", text: "This part should get the breakpoint." }, - ], - }, - ] - - addCacheBreakpoints(systemPrompt, messages, frequency) - - expect(messages[frequency].content).toEqual([ - { type: "text", text: `This is the ${frequency}th user message.` }, - { type: "image_url", image_url: { url: "data:image/png;base64,..." } }, - { type: "text", text: "This part should get the breakpoint.", cache_control: { type: "ephemeral" } }, - ]) - }) - - it("should add a placeholder text part if the target message has no text parts", () => { - const frequency = 5 - - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...Array.from({ length: frequency - 1 }, (_, i) => ({ - role: "user" as const, - content: `User message ${i + 1}`, - })), - { - role: "user", // Nth user message. - content: [{ type: "image_url", image_url: { url: "data:image/png;base64,..." } }], - }, - ] - - addCacheBreakpoints(systemPrompt, messages, frequency) - - expect(messages[frequency].content).toEqual([ - { type: "image_url", image_url: { url: "data:image/png;base64,..." } }, - { type: "text", text: "...", cache_control: { type: "ephemeral" } }, - ]) - }) - - it("should add breakpoints correctly with frequency 5", () => { - const frequency = 5 - - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...Array.from({ length: 12 }, (_, i) => ({ - role: "user" as const, - content: `User message ${i + 1}`, - })), - ] - - addCacheBreakpoints(systemPrompt, messages, frequency) - - // Check 5th user message (index 5). - expect(messages[5].content).toEqual([ - { type: "text", text: "User message 5", cache_control: { type: "ephemeral" } }, - ]) - - // Check 9th user message (index 9) - unchanged - expect(messages[9].content).toEqual([{ type: "text", text: "User message 9" }]) - - // Check 10th user message (index 10). - expect(messages[10].content).toEqual([ - { type: "text", text: "User message 10", cache_control: { type: "ephemeral" } }, - ]) - - // Check 11th user message (index 11) - unchanged - expect(messages[11].content).toEqual([{ type: "text", text: "User message 11" }]) - }) - - it("should not add breakpoints (except system) if frequency is 0", () => { - const frequency = 0 - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...Array.from({ length: 15 }, (_, i) => ({ - role: "user" as const, - content: `User message ${i + 1}`, - })), - ] - const originalMessages = JSON.parse(JSON.stringify(messages)) - - addCacheBreakpoints(systemPrompt, messages, frequency) - - // Check system prompt. - expect(messages[0].content).toEqual([ - { type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }, - ]) - - // Check all user messages - none should have cache_control - for (let i = 1; i < messages.length; i++) { - const originalContent = originalMessages[i].content - - const expectedContent = - typeof originalContent === "string" ? [{ type: "text", text: originalContent }] : originalContent - - expect(messages[i].content).toEqual(expectedContent) // Should match original (after string->array conversion). - - // Ensure no cache_control was added to user messages. - const content = messages[i].content - - if (Array.isArray(content)) { - // Assign to new variable after type check. - const contentParts = content - - contentParts.forEach((part: any) => { - // Iterate over the correctly typed variable. - expect(part).not.toHaveProperty("cache_control") - }) - } - } - }) -}) diff --git a/src/api/transform/caching/__tests__/vercel-ai-gateway.spec.ts b/src/api/transform/caching/__tests__/vercel-ai-gateway.spec.ts deleted file mode 100644 index 86dc593f4f3..00000000000 --- a/src/api/transform/caching/__tests__/vercel-ai-gateway.spec.ts +++ /dev/null @@ -1,233 +0,0 @@ -// npx vitest run src/api/transform/caching/__tests__/vercel-ai-gateway.spec.ts - -import OpenAI from "openai" -import { addCacheBreakpoints } from "../vercel-ai-gateway" - -describe("Vercel AI Gateway Caching", () => { - describe("addCacheBreakpoints", () => { - it("adds cache control to system message", () => { - const systemPrompt = "You are a helpful assistant." - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "Hello" }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - expect(messages[0]).toEqual({ - role: "system", - content: systemPrompt, - cache_control: { type: "ephemeral" }, - }) - }) - - it("adds cache control to last two user messages with string content", () => { - const systemPrompt = "You are a helpful assistant." - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "First message" }, - { role: "assistant", content: "First response" }, - { role: "user", content: "Second message" }, - { role: "assistant", content: "Second response" }, - { role: "user", content: "Third message" }, - { role: "assistant", content: "Third response" }, - { role: "user", content: "Fourth message" }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - const lastUserMessage = messages[7] - expect(Array.isArray(lastUserMessage.content)).toBe(true) - if (Array.isArray(lastUserMessage.content)) { - const textPart = lastUserMessage.content.find((part) => part.type === "text") - expect(textPart).toEqual({ - type: "text", - text: "Fourth message", - cache_control: { type: "ephemeral" }, - }) - } - - const secondLastUserMessage = messages[5] - expect(Array.isArray(secondLastUserMessage.content)).toBe(true) - if (Array.isArray(secondLastUserMessage.content)) { - const textPart = secondLastUserMessage.content.find((part) => part.type === "text") - expect(textPart).toEqual({ - type: "text", - text: "Third message", - cache_control: { type: "ephemeral" }, - }) - } - }) - - it("handles messages with existing array content", () => { - const systemPrompt = "You are a helpful assistant." - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { - role: "user", - content: [ - { type: "text", text: "Hello with image" }, - { type: "image_url", image_url: { url: "data:image/png;base64,..." } }, - ], - }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - const userMessage = messages[1] - expect(Array.isArray(userMessage.content)).toBe(true) - if (Array.isArray(userMessage.content)) { - const textPart = userMessage.content.find((part) => part.type === "text") - expect(textPart).toEqual({ - type: "text", - text: "Hello with image", - cache_control: { type: "ephemeral" }, - }) - - const imagePart = userMessage.content.find((part) => part.type === "image_url") - expect(imagePart).toEqual({ - type: "image_url", - image_url: { url: "data:image/png;base64,..." }, - }) - } - }) - - it("handles empty string content gracefully", () => { - const systemPrompt = "You are a helpful assistant." - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "" }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - const userMessage = messages[1] - expect(userMessage.content).toBe("") - }) - - it("handles messages with no text parts", () => { - const systemPrompt = "You are a helpful assistant." - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { - role: "user", - content: [{ type: "image_url", image_url: { url: "data:image/png;base64,..." } }], - }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - const userMessage = messages[1] - expect(Array.isArray(userMessage.content)).toBe(true) - if (Array.isArray(userMessage.content)) { - const textPart = userMessage.content.find((part) => part.type === "text") - expect(textPart).toBeUndefined() - - const imagePart = userMessage.content.find((part) => part.type === "image_url") - expect(imagePart).toEqual({ - type: "image_url", - image_url: { url: "data:image/png;base64,..." }, - }) - } - }) - - it("processes only user messages for conversation caching", () => { - const systemPrompt = "You are a helpful assistant." - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "First user" }, - { role: "assistant", content: "Assistant response" }, - { role: "user", content: "Second user" }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - expect(messages[2]).toEqual({ - role: "assistant", - content: "Assistant response", - }) - - const firstUser = messages[1] - const secondUser = messages[3] - - expect(Array.isArray(firstUser.content)).toBe(true) - expect(Array.isArray(secondUser.content)).toBe(true) - }) - - it("handles case with only one user message", () => { - const systemPrompt = "You are a helpful assistant." - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: "Only message" }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - const userMessage = messages[1] - expect(Array.isArray(userMessage.content)).toBe(true) - if (Array.isArray(userMessage.content)) { - const textPart = userMessage.content.find((part) => part.type === "text") - expect(textPart).toEqual({ - type: "text", - text: "Only message", - cache_control: { type: "ephemeral" }, - }) - } - }) - - it("handles case with no user messages", () => { - const systemPrompt = "You are a helpful assistant." - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { role: "assistant", content: "Assistant only" }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - expect(messages[0]).toEqual({ - role: "system", - content: systemPrompt, - cache_control: { type: "ephemeral" }, - }) - - expect(messages[1]).toEqual({ - role: "assistant", - content: "Assistant only", - }) - }) - - it("handles messages with multiple text parts", () => { - const systemPrompt = "You are a helpful assistant." - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - { - role: "user", - content: [ - { type: "text", text: "First part" }, - { type: "image_url", image_url: { url: "data:image/png;base64,..." } }, - { type: "text", text: "Second part" }, - ], - }, - ] - - addCacheBreakpoints(systemPrompt, messages) - - const userMessage = messages[1] - if (Array.isArray(userMessage.content)) { - const textParts = userMessage.content.filter((part) => part.type === "text") - expect(textParts).toHaveLength(2) - - expect(textParts[0]).toEqual({ - type: "text", - text: "First part", - }) - - expect(textParts[1]).toEqual({ - type: "text", - text: "Second part", - cache_control: { type: "ephemeral" }, - }) - } - }) - }) -}) diff --git a/src/api/transform/caching/__tests__/vertex.spec.ts b/src/api/transform/caching/__tests__/vertex.spec.ts deleted file mode 100644 index 92489649bc1..00000000000 --- a/src/api/transform/caching/__tests__/vertex.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -// npx vitest run src/api/transform/caching/__tests__/vertex.spec.ts - -import { Anthropic } from "@anthropic-ai/sdk" - -import { addCacheBreakpoints } from "../vertex" - -describe("addCacheBreakpoints (Vertex)", () => { - it("should return an empty array if input is empty", () => { - const messages: Anthropic.Messages.MessageParam[] = [] - const result = addCacheBreakpoints(messages) - expect(result).toEqual([]) - expect(result).not.toBe(messages) // Ensure new array. - }) - - it("should not add breakpoints if there are no user messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [{ role: "assistant", content: "Hello" }] - const originalMessages = JSON.parse(JSON.stringify(messages)) - const result = addCacheBreakpoints(messages) - expect(result).toEqual(originalMessages) // Should be unchanged. - expect(result).not.toBe(messages) // Ensure new array. - }) - - it("should add a breakpoint to the only user message if only one exists", () => { - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "User message 1" }] - const result = addCacheBreakpoints(messages) - - expect(result).toHaveLength(1) - - expect(result[0].content).toEqual([ - { type: "text", text: "User message 1", cache_control: { type: "ephemeral" } }, - ]) - - expect(result).not.toBe(messages) // Ensure new array. - }) - - it("should add breakpoints to both user messages if only two exist", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "User message 1" }, - { role: "user", content: "User message 2" }, - ] - - const result = addCacheBreakpoints(messages) - expect(result).toHaveLength(2) - - expect(result[0].content).toEqual([ - { type: "text", text: "User message 1", cache_control: { type: "ephemeral" } }, - ]) - - expect(result[1].content).toEqual([ - { type: "text", text: "User message 2", cache_control: { type: "ephemeral" } }, - ]) - - expect(result).not.toBe(messages) // Ensure new array. - }) - - it("should add breakpoints only to the last two user messages when more than two exist", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "User message 1" }, // Should not get breakpoint. - { role: "user", content: "User message 2" }, // Should get breakpoint. - { role: "user", content: "User message 3" }, // Should get breakpoint. - ] - - const originalMessage1 = JSON.parse(JSON.stringify(messages[0])) - const result = addCacheBreakpoints(messages) - - expect(result).toHaveLength(3) - expect(result[0]).toEqual(originalMessage1) - - expect(result[1].content).toEqual([ - { type: "text", text: "User message 2", cache_control: { type: "ephemeral" } }, - ]) - - expect(result[2].content).toEqual([ - { type: "text", text: "User message 3", cache_control: { type: "ephemeral" } }, - ]) - - expect(result).not.toBe(messages) // Ensure new array. - }) - - it("should handle assistant messages correctly when finding last two user messages", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "User message 1" }, // Should not get breakpoint. - { role: "assistant", content: "Assistant response 1" }, // Should be unchanged. - { role: "user", content: "User message 2" }, // Should get breakpoint (second to last user). - { role: "assistant", content: "Assistant response 2" }, // Should be unchanged. - { role: "user", content: "User message 3" }, // Should get breakpoint (last user). - { role: "assistant", content: "Assistant response 3" }, // Should be unchanged. - ] - const originalMessage1 = JSON.parse(JSON.stringify(messages[0])) - const originalAssistant1 = JSON.parse(JSON.stringify(messages[1])) - const originalAssistant2 = JSON.parse(JSON.stringify(messages[3])) - const originalAssistant3 = JSON.parse(JSON.stringify(messages[5])) - - const result = addCacheBreakpoints(messages) - expect(result).toHaveLength(6) - - expect(result[0]).toEqual(originalMessage1) - expect(result[1]).toEqual(originalAssistant1) - - expect(result[2].content).toEqual([ - { type: "text", text: "User message 2", cache_control: { type: "ephemeral" } }, - ]) - - expect(result[3]).toEqual(originalAssistant2) - - expect(result[4].content).toEqual([ - { type: "text", text: "User message 3", cache_control: { type: "ephemeral" } }, - ]) - - expect(result[5]).toEqual(originalAssistant3) - expect(result).not.toBe(messages) // Ensure new array. - }) - - it("should add breakpoint only to the last text part if content is an array", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "User message 1" }, // Gets breakpoint. - { - role: "user", // Gets breakpoint. - content: [ - { type: "text", text: "First text part." }, // No breakpoint. - { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } }, - { type: "text", text: "Last text part." }, // Gets breakpoint. - ], - }, - ] - - const result = addCacheBreakpoints(messages) - expect(result).toHaveLength(2) - - expect(result[0].content).toEqual([ - { type: "text", text: "User message 1", cache_control: { type: "ephemeral" } }, - ]) - - expect(result[1].content).toEqual([ - { type: "text", text: "First text part." }, // Unchanged. - { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } }, // Unchanged. - { type: "text", text: "Last text part.", cache_control: { type: "ephemeral" } }, // Breakpoint added. - ]) - - expect(result).not.toBe(messages) // Ensure new array. - }) - - it("should handle array content with no text parts gracefully", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "User message 1" }, // Gets breakpoint. - { - role: "user", // Gets breakpoint, but has no text part to add it to. - content: [{ type: "image", source: { type: "base64", media_type: "image/png", data: "..." } }], - }, - ] - - const originalMessage2 = JSON.parse(JSON.stringify(messages[1])) - - const result = addCacheBreakpoints(messages) - expect(result).toHaveLength(2) - - expect(result[0].content).toEqual([ - { type: "text", text: "User message 1", cache_control: { type: "ephemeral" } }, - ]) - - // Check second user message - should be unchanged as no text part found. - expect(result[1]).toEqual(originalMessage2) - expect(result).not.toBe(messages) // Ensure new array. - }) - - it("should not modify the original messages array", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "User message 1" }, - { role: "user", content: "User message 2" }, - ] - const originalMessagesCopy = JSON.parse(JSON.stringify(messages)) - - addCacheBreakpoints(messages) - - // Verify original array is untouched. - expect(messages).toEqual(originalMessagesCopy) - }) -}) diff --git a/src/api/transform/caching/anthropic.ts b/src/api/transform/caching/anthropic.ts deleted file mode 100644 index cff671a56ce..00000000000 --- a/src/api/transform/caching/anthropic.ts +++ /dev/null @@ -1,41 +0,0 @@ -import OpenAI from "openai" - -export function addCacheBreakpoints(systemPrompt: string, messages: OpenAI.Chat.ChatCompletionMessageParam[]) { - messages[0] = { - role: "system", - // @ts-ignore-next-line - content: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }], - } - - // Ensure all user messages have content in array format first - for (const msg of messages) { - if (msg.role === "user" && typeof msg.content === "string") { - msg.content = [{ type: "text", text: msg.content }] - } - } - - // Add `cache_control: ephemeral` to the last two user messages. - // (Note: this works because we only ever add one user message at a - // time, but if we added multiple we'd need to mark the user message - // before the last assistant message.) - messages - .filter((msg) => msg.role === "user") - .slice(-2) - .forEach((msg) => { - if (Array.isArray(msg.content)) { - // NOTE: This is fine since env details will always be added - // at the end. But if it wasn't there, and the user added a - // image_url type message, it would pop a text part before - // it and then move it after to the end. - let lastTextPart = msg.content.filter((part) => part.type === "text").pop() - - if (!lastTextPart) { - lastTextPart = { type: "text", text: "..." } - msg.content.push(lastTextPart) - } - - // @ts-ignore-next-line - lastTextPart["cache_control"] = { type: "ephemeral" } - } - }) -} diff --git a/src/api/transform/caching/gemini.ts b/src/api/transform/caching/gemini.ts deleted file mode 100644 index 66d43e85553..00000000000 --- a/src/api/transform/caching/gemini.ts +++ /dev/null @@ -1,47 +0,0 @@ -import OpenAI from "openai" - -export function addCacheBreakpoints( - systemPrompt: string, - messages: OpenAI.Chat.ChatCompletionMessageParam[], - frequency: number = 10, -) { - // *Always* cache the system prompt. - messages[0] = { - role: "system", - // @ts-ignore-next-line - content: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }], - } - - // Add breakpoints every N user messages based on frequency. - let count = 0 - - for (const msg of messages) { - if (msg.role !== "user") { - continue - } - - // Ensure content is in array format for potential modification. - if (typeof msg.content === "string") { - msg.content = [{ type: "text", text: msg.content }] - } - - const isNthMessage = count % frequency === frequency - 1 - - if (isNthMessage) { - if (Array.isArray(msg.content)) { - // Find the last text part to add the cache control to. - let lastTextPart = msg.content.filter((part) => part.type === "text").pop() - - if (!lastTextPart) { - lastTextPart = { type: "text", text: "..." } // Add a placeholder if no text part exists. - msg.content.push(lastTextPart) - } - - // @ts-ignore-next-line - Add cache control property - lastTextPart["cache_control"] = { type: "ephemeral" } - } - } - - count++ - } -} diff --git a/src/api/transform/caching/vercel-ai-gateway.ts b/src/api/transform/caching/vercel-ai-gateway.ts deleted file mode 100644 index 82eff0cd7bf..00000000000 --- a/src/api/transform/caching/vercel-ai-gateway.ts +++ /dev/null @@ -1,30 +0,0 @@ -import OpenAI from "openai" - -export function addCacheBreakpoints(systemPrompt: string, messages: OpenAI.Chat.ChatCompletionMessageParam[]) { - // Apply cache_control to system message at the message level - messages[0] = { - role: "system", - content: systemPrompt, - // @ts-ignore-next-line - cache_control: { type: "ephemeral" }, - } - - // Add cache_control to the last two user messages for conversation context caching - const lastTwoUserMessages = messages.filter((msg) => msg.role === "user").slice(-2) - - lastTwoUserMessages.forEach((msg) => { - if (typeof msg.content === "string" && msg.content.length > 0) { - msg.content = [{ type: "text", text: msg.content }] - } - - if (Array.isArray(msg.content)) { - // Find the last text part in the message content - let lastTextPart = msg.content.filter((part) => part.type === "text").pop() - - if (lastTextPart && lastTextPart.text && lastTextPart.text.length > 0) { - // @ts-ignore-next-line - lastTextPart["cache_control"] = { type: "ephemeral" } - } - } - }) -} diff --git a/src/api/transform/caching/vertex.ts b/src/api/transform/caching/vertex.ts deleted file mode 100644 index 48bf2615873..00000000000 --- a/src/api/transform/caching/vertex.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" - -export function addCacheBreakpoints(messages: Anthropic.Messages.MessageParam[]) { - // Find indices of user messages that we want to cache. - // We only cache the last two user messages to stay within the 4-block limit - // (1 block for system + 1 block each for last two user messages = 3 total). - const indices = messages.reduce((acc, msg, i) => (msg.role === "user" ? [...acc, i] : acc), [] as number[]) - - // Only cache the last two user messages. - const lastIndex = indices[indices.length - 1] ?? -1 - const secondLastIndex = indices[indices.length - 2] ?? -1 - - return messages.map((message, index) => - message.role !== "assistant" && (index === lastIndex || index === secondLastIndex) - ? cachedMessage(message) - : message, - ) -} - -function cachedMessage(message: Anthropic.Messages.MessageParam): Anthropic.Messages.MessageParam { - // For string content, we convert to array format with optional cache control. - if (typeof message.content === "string") { - return { - ...message, - // For string content, we only have one block so it's always the last block. - content: [{ type: "text" as const, text: message.content, cache_control: { type: "ephemeral" } }], - } - } - - // For array content, find the last text block index once before mapping. - const lastTextBlockIndex = message.content.reduce( - (lastIndex, content, index) => (content.type === "text" ? index : lastIndex), - -1, - ) - - // Then use this pre-calculated index in the map function. - return { - ...message, - content: message.content.map((content, index) => - content.type === "text" - ? { - ...content, - // Check if this is the last text block using our pre-calculated index. - ...(index === lastTextBlockIndex && { cache_control: { type: "ephemeral" } }), - } - : content, - ), - } -} diff --git a/src/api/transform/minimax-format.ts b/src/api/transform/minimax-format.ts deleted file mode 100644 index 32a32a4437e..00000000000 --- a/src/api/transform/minimax-format.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" - -type ContentBlock = Anthropic.Messages.ContentBlockParam - -/** - * Merges text content (like environment_details) that follows tool_result blocks - * into the last tool_result's content. This preserves reasoning continuity for - * thinking models by avoiding separate user messages after tool results. - * - * Key behavior: - * - User messages with ONLY tool_result blocks: keep as-is - * - User messages with ONLY text/image: keep as-is - * - User messages with tool_result blocks AND text blocks: merge the text blocks - * into the last tool_result's content - * - * @param messages Array of Anthropic messages - * @returns Modified messages with text merged into tool_result content - */ -export function mergeEnvironmentDetailsForMiniMax( - messages: Anthropic.Messages.MessageParam[], -): Anthropic.Messages.MessageParam[] { - const result: Anthropic.Messages.MessageParam[] = [] - - for (const message of messages) { - if (message.role === "user") { - if (typeof message.content === "string") { - // Simple string content - keep as-is - result.push(message) - } else if (Array.isArray(message.content)) { - // Check if this message has both tool_result blocks and text blocks - const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] - const textBlocks: Anthropic.Messages.TextBlockParam[] = [] - const imageBlocks: Anthropic.Messages.ImageBlockParam[] = [] - - for (const block of message.content) { - if (block.type === "tool_result") { - toolResultBlocks.push(block) - } else if (block.type === "text") { - textBlocks.push(block) - } else if (block.type === "image") { - imageBlocks.push(block) - } - } - - // If we have tool_result blocks AND text blocks (like environment_details), - // merge the text into the last tool_result's content - const hasToolResults = toolResultBlocks.length > 0 - const hasTextBlocks = textBlocks.length > 0 - const hasImageBlocks = imageBlocks.length > 0 - - if (hasToolResults && hasTextBlocks && !hasImageBlocks) { - // Merge text content into the last tool_result - const textContent = textBlocks.map((b) => b.text).join("\n\n") - const modifiedToolResults = [...toolResultBlocks] - const lastToolResult = modifiedToolResults[modifiedToolResults.length - 1] - - // Get existing content as string - let existingContent: string - if (typeof lastToolResult.content === "string") { - existingContent = lastToolResult.content - } else if (Array.isArray(lastToolResult.content)) { - existingContent = - lastToolResult.content - ?.map((c) => { - if (c.type === "text") return c.text - if (c.type === "image") return "(image)" - return "" - }) - .join("\n") ?? "" - } else { - existingContent = "" - } - - // Merge text into the last tool_result - modifiedToolResults[modifiedToolResults.length - 1] = { - ...lastToolResult, - content: existingContent ? `${existingContent}\n\n${textContent}` : textContent, - } - - result.push({ - ...message, - content: modifiedToolResults as ContentBlock[], - }) - } else { - // Keep the message as-is if: - // - Only tool_result blocks (no text to merge) - // - Only text/image blocks (no tool results) - // - Has images (can't merge into tool_result) - result.push(message) - } - } else { - // Unknown format - keep as-is - result.push(message) - } - } else { - // Assistant messages - keep as-is - result.push(message) - } - } - - return result -} - -/** - * @deprecated Use mergeEnvironmentDetailsForMiniMax instead. This function extracted - * environment_details to the system prompt, but the new approach merges them into - * tool_result content like r1-format does with mergeToolResultText. - */ -export function extractEnvironmentDetailsForMiniMax(messages: Anthropic.Messages.MessageParam[]): { - messages: Anthropic.Messages.MessageParam[] - extractedSystemContent: string[] -} { - // For backwards compatibility, just return the merged messages with empty extracted content - return { - messages: mergeEnvironmentDetailsForMiniMax(messages), - extractedSystemContent: [], - } -} 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 8974dd599ba..00000000000 --- a/src/api/transform/openai-format.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -/** - * 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 -} - -/** - * 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 Anthropic 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_use IDs and tool_result tool_use_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 -} - -export function convertToOpenAiMessages( - anthropicMessages: Anthropic.Messages.MessageParam[], - 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) - - for (const anthropicMessage of anthropicMessages) { - if (typeof anthropicMessage.content === "string") { - // Some upstream transforms (e.g. [`Task.buildCleanConversationHistory()`](src/core/task/Task.ts:4048)) - // will convert a single text block into a string for compactness. - // If a message also contains reasoning_details (Gemini 3 / xAI / o-series, etc.), - // we must preserve it here as well. - const messageWithDetails = anthropicMessage as any - const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = { - role: anthropicMessage.role, - content: anthropicMessage.content, - } - - if (anthropicMessage.role === "assistant") { - const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) - if (mapped) { - ;(baseMessage as any).reasoning_details = mapped - } - } - - openAiMessages.push(baseMessage) - } else { - // image_url.url is base64 encoded image data - // ensure it contains the content-type of the image: data:image/png;base64, - /* - { role: "user", content: "" | { type: "text", text: string } | { type: "image_url", image_url: { url: string } } }, - // content required unless tool_calls is present - { role: "assistant", content?: "" | null, tool_calls?: [{ id: "", function: { name: "", arguments: "" }, type: "function" }] }, - { role: "tool", tool_call_id: "", content: ""} - */ - 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: [] }, - ) - - // Process tool result messages FIRST since they must follow the tool use messages - let toolResultImages: Anthropic.Messages.ImageBlockParam[] = [] - toolMessages.forEach((toolMessage) => { - // The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility. - let content: string - - if (typeof toolMessage.content === "string") { - content = toolMessage.content - } else { - content = - toolMessage.content - ?.map((part) => { - if (part.type === "image") { - toolResultImages.push(part) - return "(see following user message for image)" - } - return part.text - }) - .join("\n") ?? "" - } - openAiMessages.push({ - role: "tool", - tool_call_id: normalizeId(toolMessage.tool_use_id), - // Use "(empty)" placeholder for empty content to satisfy providers like Gemini (via OpenRouter) - content: content || "(empty)", - }) - }) - - // If tool results contain images, send as a separate user message - // I ran into an issue where if I gave feedback for one of many tool uses, the request would fail. - // "Messages following `tool_use` blocks must begin with a matching number of `tool_result` blocks." - // Therefore we need to send these images after the tool result messages - // NOTE: it's actually okay to have multiple user messages in a row, the model will treat them as a continuation of the same input (this way works better than combining them into one message, since the tool result specifically mentions (see following user message for image) - // UPDATE v2.0: we don't use tools anymore, but if we did it's important to note that the openrouter prompt caching mechanism requires one user message at a time, so we would need to add these images to the user content array instead. - // if (toolResultImages.length > 0) { - // openAiMessages.push({ - // role: "user", - // content: toolResultImages.map((part) => ({ - // type: "image_url", - // image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, - // })), - // }) - // } - - // Process non-tool messages - // Filter out empty text blocks to prevent "must include at least one parts field" error - // from Gemini (via OpenRouter). Images always have content (base64 data). - const filteredNonToolMessages = nonToolMessages.filter( - (part) => part.type === "image" || (part.type === "text" && part.text), - ) - - if (filteredNonToolMessages.length > 0) { - // Check if we should merge text into the last tool message - // This is critical for reasoning/thinking models where a user message - // after tool results causes the model to drop all previous reasoning_content - const hasOnlyTextContent = filteredNonToolMessages.every((part) => part.type === "text") - const hasToolMessages = toolMessages.length > 0 - const shouldMergeIntoToolMessage = - options?.mergeToolResultText && hasToolMessages && hasOnlyTextContent - - if (shouldMergeIntoToolMessage) { - // Merge text content into the last tool message - const lastToolMessage = openAiMessages[ - openAiMessages.length - 1 - ] as OpenAI.Chat.ChatCompletionToolMessageParam - if (lastToolMessage?.role === "tool") { - const additionalText = filteredNonToolMessages - .map((part) => (part as Anthropic.TextBlockParam).text) - .join("\n") - lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` - } - } else { - // Standard behavior: add user message with text/image content - openAiMessages.push({ - role: "user", - content: filteredNonToolMessages.map((part) => { - if (part.type === "image") { - return { - type: "image_url", - image_url: { 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: [] }, - ) - - // Process non-tool messages - 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") - } - - // Process tool use messages - let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({ - id: normalizeId(toolMessage.id), - type: "function", - function: { - name: toolMessage.name, - // json string - arguments: JSON.stringify(toolMessage.input), - }, - })) - - // Check if the message has reasoning_details (used by Gemini 3, xAI, etc.) - const messageWithDetails = anthropicMessage as any - - // Build message with reasoning_details BEFORE tool_calls to preserve - // the order expected by providers like Roo. Property order matters - // when sending messages back to some APIs. - const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & { - reasoning_details?: any[] - } = { - role: "assistant", - // Use empty string instead of undefined for providers like Gemini (via OpenRouter) - // that require every message to have content in the "parts" field - content: content ?? "", - } - - // Pass through reasoning_details to preserve the original shape from the API. - // The `id` field is stripped from openai-responses-v1 blocks (see mapReasoningDetails). - const mapped = mapReasoningDetails(messageWithDetails.reasoning_details) - if (mapped) { - baseMessage.reasoning_details = mapped - } - - // Add tool_calls after reasoning_details - // Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty - 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/reasoning.ts b/src/api/transform/reasoning.ts index 446221d256f..6ff758fc02f 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -1,4 +1,3 @@ -import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta" import OpenAI from "openai" import type { GenerateContentConfig } from "@google/genai" @@ -17,7 +16,7 @@ export type RooReasoningParams = { effort?: ReasoningEffortExtended } -export type AnthropicReasoningParams = BetaThinkingConfigParam +export type AnthropicReasoningParams = { type: "enabled"; budget_tokens: number } | { type: "disabled" } export type OpenAiReasoningParams = { reasoning_effort: OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"] } diff --git a/src/api/transform/vscode-lm-format.ts b/src/api/transform/vscode-lm-format.ts deleted file mode 100644 index 388197c2c2c..00000000000 --- a/src/api/transform/vscode-lm-format.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import * as vscode from "vscode" - -/** - * Safely converts a value into a plain object. - */ -function asObjectSafe(value: any): object { - // Handle null/undefined - if (!value) { - return {} - } - - try { - // Handle strings that might be JSON - if (typeof value === "string") { - return JSON.parse(value) - } - - // Handle pre-existing objects - if (typeof value === "object") { - return { ...value } - } - - return {} - } catch (error) { - console.warn("Roo Code : Failed to parse object:", error) - return {} - } -} - -export function convertToVsCodeLmMessages( - anthropicMessages: Anthropic.Messages.MessageParam[], -): vscode.LanguageModelChatMessage[] { - const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [] - - for (const anthropicMessage of anthropicMessages) { - // Handle simple string messages - if (typeof anthropicMessage.content === "string") { - vsCodeLmMessages.push( - anthropicMessage.role === "assistant" - ? vscode.LanguageModelChatMessage.Assistant(anthropicMessage.content) - : vscode.LanguageModelChatMessage.User(anthropicMessage.content), - ) - continue - } - - // Handle complex message structures - switch (anthropicMessage.role) { - case "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) - } - return acc - }, - { nonToolMessages: [], toolMessages: [] }, - ) - - // Process tool messages first then non-tool messages - const contentParts = [ - // Convert tool messages to ToolResultParts - ...toolMessages.map((toolMessage) => { - // Process tool result content into TextParts - const toolContentParts: vscode.LanguageModelTextPart[] = - typeof toolMessage.content === "string" - ? [new vscode.LanguageModelTextPart(toolMessage.content)] - : (toolMessage.content?.map((part) => { - if (part.type === "image") { - return new vscode.LanguageModelTextPart( - `[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`, - ) - } - return new vscode.LanguageModelTextPart(part.text) - }) ?? [new vscode.LanguageModelTextPart("")]) - - return new vscode.LanguageModelToolResultPart(toolMessage.tool_use_id, toolContentParts) - }), - - // Convert non-tool messages to TextParts after tool messages - ...nonToolMessages.map((part) => { - if (part.type === "image") { - return new vscode.LanguageModelTextPart( - `[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`, - ) - } - return new vscode.LanguageModelTextPart(part.text) - }), - ] - - // Add single user message with all content parts - vsCodeLmMessages.push(vscode.LanguageModelChatMessage.User(contentParts)) - break - } - - case "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) - } - return acc - }, - { nonToolMessages: [], toolMessages: [] }, - ) - - // Process non-tool messages first, then tool messages - // Tool calls must come at the end so they are properly followed by user message with tool results - const contentParts = [ - // Convert non-tool messages to TextParts first - ...nonToolMessages.map((part) => { - if (part.type === "image") { - return new vscode.LanguageModelTextPart("[Image generation not supported by VSCode LM API]") - } - return new vscode.LanguageModelTextPart(part.text) - }), - - // Convert tool messages to ToolCallParts after text - ...toolMessages.map( - (toolMessage) => - new vscode.LanguageModelToolCallPart( - toolMessage.id, - toolMessage.name, - asObjectSafe(toolMessage.input), - ), - ), - ] - - // Add the assistant message to the list of messages - vsCodeLmMessages.push(vscode.LanguageModelChatMessage.Assistant(contentParts)) - break - } - } - } - - return vsCodeLmMessages -} - -export function convertToAnthropicRole(vsCodeLmMessageRole: vscode.LanguageModelChatMessageRole): string | null { - switch (vsCodeLmMessageRole) { - case vscode.LanguageModelChatMessageRole.Assistant: - return "assistant" - case vscode.LanguageModelChatMessageRole.User: - return "user" - default: - return null - } -} - -/** - * Extracts the text content from a VS Code Language Model chat message. - * @param message A VS Code Language Model chat message. - * @returns The extracted text content. - */ -export function extractTextCountFromMessage(message: vscode.LanguageModelChatMessage): string { - let text = "" - if (Array.isArray(message.content)) { - for (const item of message.content) { - if (item instanceof vscode.LanguageModelTextPart) { - text += item.value - } - if (item instanceof vscode.LanguageModelToolResultPart) { - text += item.callId - for (const part of item.content) { - if (part instanceof vscode.LanguageModelTextPart) { - text += part.value - } - } - } - if (item instanceof vscode.LanguageModelToolCallPart) { - text += item.name - text += item.callId - if (item.input && Object.keys(item.input).length > 0) { - try { - text += JSON.stringify(item.input) - } catch (error) { - console.error("Roo Code : Failed to stringify tool call input:", error) - } - } - } - } - } else if (typeof message.content === "string") { - text += message.content - } - return text -} diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index e7b0067dd92..f49f2f3d1c3 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -647,9 +647,10 @@ export class NativeToolCallParser { } const result: ToolUse = { - type: "tool_use" as const, - name, - params, + type: "tool-call" as const, + toolCallId: id, + toolName: name, + input: params, partial, nativeArgs, } @@ -673,11 +674,11 @@ export class NativeToolCallParser { * @param toolCall - The native tool call from the API stream * @returns A properly typed ToolUse object */ - public static parseToolCall(toolCall: { + public static parseToolCall(toolCall: { id: string - name: TName + name: ToolName | string arguments: string - }): ToolUse | McpToolUse | null { + }): ToolUse | McpToolUse | null { // Check if this is a dynamic MCP tool (mcp--serverName--toolName) // Also handle models that output underscores instead of hyphens (mcp__serverName__toolName) const mcpPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR @@ -692,7 +693,7 @@ export class NativeToolCallParser { } // Resolve tool alias to canonical name - const resolvedName = resolveToolAlias(toolCall.name as string) as TName + const resolvedName = resolveToolAlias(toolCall.name as string) as ToolName // Validate tool name (after alias resolution). if (!toolNames.includes(resolvedName as ToolName) && !customToolRegistry.has(resolvedName)) { @@ -725,7 +726,7 @@ export class NativeToolCallParser { // Build typed nativeArgs for tool execution. // Each case validates the minimum required parameters and constructs a properly typed // nativeArgs object. If validation fails, we treat the tool call as invalid and fail fast. - let nativeArgs: NativeArgsFor | undefined = undefined + let nativeArgs: any = undefined // Track if legacy format was used (for telemetry) let usedLegacyFormat = false @@ -756,7 +757,7 @@ export class NativeToolCallParser { nativeArgs = { files: this.convertFileEntries(filesArray), _legacyFormat: true as const, - } as NativeArgsFor + } } } // New format: { path: "...", mode: "..." } @@ -778,13 +779,13 @@ export class NativeToolCallParser { include_header: this.coerceOptionalBoolean(args.indentation.include_header), } : undefined, - } as NativeArgsFor + } } break case "attempt_completion": if (args.result) { - nativeArgs = { result: args.result } as NativeArgsFor + nativeArgs = { result: args.result } } break @@ -793,7 +794,7 @@ export class NativeToolCallParser { nativeArgs = { command: args.command, cwd: args.cwd, - } as NativeArgsFor + } } break @@ -802,7 +803,7 @@ export class NativeToolCallParser { nativeArgs = { path: args.path, diff: args.diff, - } as NativeArgsFor + } } break @@ -811,7 +812,7 @@ export class NativeToolCallParser { nativeArgs = { path: args.path, operations: args.operations, - } as NativeArgsFor + } } break @@ -820,7 +821,7 @@ export class NativeToolCallParser { nativeArgs = { question: args.question, follow_up: args.follow_up, - } as NativeArgsFor + } } break @@ -833,7 +834,7 @@ export class NativeToolCallParser { size: args.size, text: args.text, path: args.path, - } as NativeArgsFor + } } break @@ -842,7 +843,7 @@ export class NativeToolCallParser { nativeArgs = { query: args.query, path: args.path, - } as NativeArgsFor + } } break @@ -852,7 +853,7 @@ export class NativeToolCallParser { prompt: args.prompt, path: args.path, image: args.image, - } as NativeArgsFor + } } break @@ -861,7 +862,7 @@ export class NativeToolCallParser { nativeArgs = { command: args.command, args: args.args, - } as NativeArgsFor + } } break @@ -870,7 +871,7 @@ export class NativeToolCallParser { nativeArgs = { skill: args.skill, args: args.args, - } as NativeArgsFor + } } break @@ -880,7 +881,7 @@ export class NativeToolCallParser { path: args.path, regex: args.regex, file_pattern: args.file_pattern, - } as NativeArgsFor + } } break @@ -889,7 +890,7 @@ export class NativeToolCallParser { nativeArgs = { mode_slug: args.mode_slug, reason: args.reason, - } as NativeArgsFor + } } break @@ -897,7 +898,7 @@ export class NativeToolCallParser { if (args.todos !== undefined) { nativeArgs = { todos: args.todos, - } as NativeArgsFor + } } break @@ -908,7 +909,7 @@ export class NativeToolCallParser { search: args.search, offset: args.offset, limit: args.limit, - } as NativeArgsFor + } } break @@ -917,7 +918,7 @@ export class NativeToolCallParser { nativeArgs = { path: args.path, content: args.content, - } as NativeArgsFor + } } break @@ -927,7 +928,7 @@ export class NativeToolCallParser { server_name: args.server_name, tool_name: args.tool_name, arguments: args.arguments, - } as NativeArgsFor + } } break @@ -936,7 +937,7 @@ export class NativeToolCallParser { nativeArgs = { server_name: args.server_name, uri: args.uri, - } as NativeArgsFor + } } break @@ -944,7 +945,7 @@ export class NativeToolCallParser { if (args.patch !== undefined) { nativeArgs = { patch: args.patch, - } as NativeArgsFor + } } break @@ -958,7 +959,7 @@ export class NativeToolCallParser { file_path: args.file_path, old_string: args.old_string, new_string: args.new_string, - } as NativeArgsFor + } } break @@ -973,7 +974,7 @@ export class NativeToolCallParser { old_string: args.old_string, new_string: args.new_string, expected_replacements: args.expected_replacements, - } as NativeArgsFor + } } break @@ -982,7 +983,7 @@ export class NativeToolCallParser { nativeArgs = { path: args.path, recursive: this.coerceOptionalBoolean(args.recursive), - } as NativeArgsFor + } } break @@ -992,13 +993,13 @@ export class NativeToolCallParser { mode: args.mode, message: args.message, todos: args.todos, - } as NativeArgsFor + } } break default: if (customToolRegistry.has(resolvedName)) { - nativeArgs = args as NativeArgsFor + nativeArgs = args } break @@ -1014,17 +1015,18 @@ export class NativeToolCallParser { ) } - const result: ToolUse = { - type: "tool_use" as const, - name: resolvedName, - params, + const result: ToolUse = { + type: "tool-call" as const, + toolCallId: toolCall.id, + toolName: resolvedName, + input: params, partial: false, // Native tool calls are always complete when yielded nativeArgs, } // Preserve original name for API history when an alias was used if (toolCall.name !== resolvedName) { - result.originalName = toolCall.name + result.originalName = toolCall.name as string } // Track legacy format usage for telemetry diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index db0dc00de41..01039a301f5 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -20,8 +20,8 @@ describe("NativeToolCallParser", () => { const result = NativeToolCallParser.parseToolCall(toolCall) expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { + expect(result?.type).toBe("tool-call") + if (result?.type === "tool-call") { expect(result.nativeArgs).toBeDefined() const nativeArgs = result.nativeArgs as { path: string } expect(nativeArgs.path).toBe("src/core/task/Task.ts") @@ -43,8 +43,8 @@ describe("NativeToolCallParser", () => { const result = NativeToolCallParser.parseToolCall(toolCall) expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { + expect(result?.type).toBe("tool-call") + if (result?.type === "tool-call") { const nativeArgs = result.nativeArgs as { path: string mode?: string @@ -77,8 +77,8 @@ describe("NativeToolCallParser", () => { const result = NativeToolCallParser.parseToolCall(toolCall) expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { + expect(result?.type).toBe("tool-call") + if (result?.type === "tool-call") { const nativeArgs = result.nativeArgs as { path: string mode?: string @@ -111,8 +111,8 @@ describe("NativeToolCallParser", () => { const result = NativeToolCallParser.parseToolCall(toolCall) expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { + expect(result?.type).toBe("tool-call") + if (result?.type === "tool-call") { expect(result.usedLegacyFormat).toBe(true) const nativeArgs = result.nativeArgs as { files: Array<{ path: string }>; _legacyFormat: true } expect(nativeArgs._legacyFormat).toBe(true) @@ -133,8 +133,8 @@ describe("NativeToolCallParser", () => { const result = NativeToolCallParser.parseToolCall(toolCall) expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { + expect(result?.type).toBe("tool-call") + if (result?.type === "tool-call") { expect(result.usedLegacyFormat).toBe(true) const nativeArgs = result.nativeArgs as { files: Array<{ path: string }>; _legacyFormat: true } expect(nativeArgs.files).toHaveLength(3) @@ -164,8 +164,8 @@ describe("NativeToolCallParser", () => { const result = NativeToolCallParser.parseToolCall(toolCall) expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { + expect(result?.type).toBe("tool-call") + if (result?.type === "tool-call") { expect(result.usedLegacyFormat).toBe(true) const nativeArgs = result.nativeArgs as { files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> @@ -197,8 +197,8 @@ describe("NativeToolCallParser", () => { const result = NativeToolCallParser.parseToolCall(toolCall) expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { + expect(result?.type).toBe("tool-call") + if (result?.type === "tool-call") { expect(result.usedLegacyFormat).toBe(true) const nativeArgs = result.nativeArgs as { files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> @@ -226,8 +226,8 @@ describe("NativeToolCallParser", () => { const result = NativeToolCallParser.parseToolCall(toolCall) expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { + expect(result?.type).toBe("tool-call") + if (result?.type === "tool-call") { expect(result.usedLegacyFormat).toBe(true) const nativeArgs = result.nativeArgs as { files: Array<{ path: string; lineRanges?: Array<{ start: number; end: number }> }> @@ -255,8 +255,8 @@ describe("NativeToolCallParser", () => { const result = NativeToolCallParser.parseToolCall(toolCall) expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { + expect(result?.type).toBe("tool-call") + if (result?.type === "tool-call") { expect(result.usedLegacyFormat).toBe(true) const nativeArgs = result.nativeArgs as { files: Array<{ path: string }> @@ -284,8 +284,8 @@ describe("NativeToolCallParser", () => { const result = NativeToolCallParser.parseToolCall(toolCall) expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { + expect(result?.type).toBe("tool-call") + if (result?.type === "tool-call") { expect(result.usedLegacyFormat).toBeUndefined() } }) @@ -333,8 +333,8 @@ describe("NativeToolCallParser", () => { const result = NativeToolCallParser.finalizeStreamingToolCall(id) expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { + expect(result?.type).toBe("tool-call") + if (result?.type === "tool-call") { const nativeArgs = result.nativeArgs as { path: string; offset?: number; limit?: number } expect(nativeArgs.path).toBe("finalized.ts") expect(nativeArgs.offset).toBe(1) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts index 690861bb56a..a5e3d2f177a 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -85,7 +85,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { // Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { const existingResult = mockTask.userMessageContent.find( - (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + (block: any) => block.type === "tool-result" && block.toolCallId === toolResult.toolCallId, ) if (existingResult) { return false @@ -100,11 +100,10 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { const toolCallId = "tool_call_custom_123" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, - name: "my_custom_tool", - params: { value: "test" }, - partial: false, + type: "tool-call", + toolCallId: toolCallId, + toolName: "my_custom_tool", + input: { value: "test" }, }, ] @@ -129,11 +128,10 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { const toolCallId = "tool_call_custom_error_123" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, - name: "failing_custom_tool", - params: {}, - partial: false, + type: "tool-call", + toolCallId: toolCallId, + toolName: "failing_custom_tool", + input: {}, }, ] @@ -158,11 +156,10 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { const toolCallId = "tool_call_read_file_123" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, - name: "read_file", - params: { path: "test.txt" }, - partial: false, + type: "tool-call", + toolCallId: toolCallId, + toolName: "read_file", + input: { path: "test.txt" }, }, ] @@ -180,15 +177,14 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { const toolCallId = "tool_call_mcp_123" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: toolCallId, + toolName: "use_mcp_tool", + input: { server_name: "test-server", tool_name: "test-tool", arguments: "{}", }, - partial: false, }, ] @@ -224,11 +220,10 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { const toolCallId = "tool_call_disabled_123" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, - name: "my_custom_tool", - params: {}, - partial: false, + type: "tool-call", + toolCallId: toolCallId, + toolName: "my_custom_tool", + input: {}, }, ] @@ -272,11 +267,10 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { it("should not call customToolRegistry.has() when experiment is disabled", async () => { mockTask.assistantMessageContent = [ { - type: "tool_use", - id: "tool_call_123", - name: "some_tool", - params: {}, - partial: false, + type: "tool-call", + toolCallId: "tool_call_123", + toolName: "some_tool", + input: {}, }, ] diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts index 7316884984f..50805d9a97b 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts @@ -1,9 +1,9 @@ // npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts import { describe, it, expect, beforeEach, vi } from "vitest" -import { Anthropic } from "@anthropic-ai/sdk" import { presentAssistantMessage } from "../presentAssistantMessage" import { Task } from "../../task/Task" +import type { ImagePart } from "ai" // Mock dependencies vi.mock("../../task/Task") @@ -67,7 +67,7 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = // Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { const existingResult = mockTask.userMessageContent.find( - (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + (block: any) => block.type === "tool-result" && block.toolCallId === toolResult.toolCallId, ) if (existingResult) { return false @@ -82,22 +82,19 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = const toolCallId = "tool_call_123" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, // ID indicates native tool calling - name: "ask_followup_question", - params: { question: "What do you see?" }, + type: "tool-call", + toolCallId: toolCallId, // ID indicates native tool calling + toolName: "ask_followup_question", + input: { question: "What do you see?" }, nativeArgs: { question: "What do you see?", follow_up: [] }, }, ] // Create a mock askApproval that includes images in the response - const imageBlock: Anthropic.ImageBlockParam = { + const imageBlock: ImagePart = { type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "base64ImageData", - }, + image: "base64ImageData", + mediaType: "image/png", } mockTask.ask = vi.fn().mockResolvedValue({ @@ -114,20 +111,19 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = // Find the tool_result block const toolResult = mockTask.userMessageContent.find( - (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, ) expect(toolResult).toBeDefined() - expect(toolResult.tool_use_id).toBe(toolCallId) + expect(toolResult.toolCallId).toBe(toolCallId) - // For native tool calling, tool_result content should be a string (text only) - expect(typeof toolResult.content).toBe("string") - expect(toolResult.content).toContain("I see a cat") + // For native tool calling, tool_result output should contain the text + const outputValue = typeof toolResult.output === "string" ? toolResult.output : toolResult.output?.value + expect(outputValue).toContain("I see a cat") // Images should be added as separate blocks AFTER the tool_result const imageBlocks = mockTask.userMessageContent.filter((item: any) => item.type === "image") expect(imageBlocks.length).toBeGreaterThan(0) - expect(imageBlocks[0].source.data).toBe("base64ImageData") }) it("should convert to string when no images are present (native tool calling)", async () => { @@ -135,10 +131,10 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = const toolCallId = "tool_call_456" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, - name: "ask_followup_question", - params: { question: "What is your name?" }, + type: "tool-call", + toolCallId: toolCallId, + toolName: "ask_followup_question", + input: { question: "What is your name?" }, nativeArgs: { question: "What is your name?", follow_up: [] }, }, ] @@ -153,22 +149,22 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = await presentAssistantMessage(mockTask) const toolResult = mockTask.userMessageContent.find( - (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, ) expect(toolResult).toBeDefined() - // When no images, content should be a string - expect(typeof toolResult.content).toBe("string") + // When no images, output should be defined + expect(toolResult.output).toBeDefined() }) it("should fail fast when tool_use is missing id (legacy/XML-style tool call)", async () => { // tool_use without an id is treated as legacy/XML-style tool call and must be rejected. mockTask.assistantMessageContent = [ { - type: "tool_use", - name: "ask_followup_question", - params: { question: "What do you see?" }, + type: "tool-call", + toolName: "ask_followup_question", + input: { question: "What do you see?" }, }, ] @@ -193,10 +189,10 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = const toolCallId = "tool_call_789" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, - name: "attempt_completion", - params: { result: "Task completed" }, + type: "tool-call", + toolCallId: toolCallId, + toolName: "attempt_completion", + input: { result: "Task completed" }, }, ] @@ -210,12 +206,12 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = await presentAssistantMessage(mockTask) const toolResult = mockTask.userMessageContent.find( - (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, ) expect(toolResult).toBeDefined() - // Should have fallback text - expect(toolResult.content).toBeTruthy() + // Should have fallback output + expect(toolResult.output).toBeDefined() }) describe("Multiple tool calls handling", () => { @@ -226,16 +222,16 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId1, - name: "read_file", - params: { path: "test.txt" }, + type: "tool-call", + toolCallId: toolCallId1, + toolName: "read_file", + input: { path: "test.txt" }, }, { - type: "tool_use", - id: toolCallId2, - name: "write_to_file", - params: { path: "output.txt", content: "test" }, + type: "tool-call", + toolCallId: toolCallId2, + toolName: "write_to_file", + input: { path: "output.txt", content: "test" }, }, ] @@ -248,14 +244,16 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = // Find the tool_result for the second tool const toolResult = mockTask.userMessageContent.find( - (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId2, + (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId2, ) // Verify that a tool_result block was created (not a text block) expect(toolResult).toBeDefined() - expect(toolResult.tool_use_id).toBe(toolCallId2) - expect(toolResult.is_error).toBe(true) - expect(toolResult.content).toContain("due to user rejecting a previous tool") + expect(toolResult.toolCallId).toBe(toolCallId2) + // Error is indicated by output type, not isError flag + expect(toolResult.output).toBeDefined() + const outputValue = typeof toolResult.output === "string" ? toolResult.output : toolResult.output?.value + expect(outputValue).toContain("due to user rejecting a previous tool") // Ensure no text blocks were added for this rejection const textBlocks = mockTask.userMessageContent.filter( @@ -267,14 +265,14 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = it("should reject subsequent tool calls when a legacy/XML-style tool call is encountered", async () => { mockTask.assistantMessageContent = [ { - type: "tool_use", - name: "read_file", - params: { path: "test.txt" }, + type: "tool-call", + toolName: "read_file", + input: { path: "test.txt" }, }, { - type: "tool_use", - name: "write_to_file", - params: { path: "output.txt", content: "test" }, + type: "tool-call", + toolName: "write_to_file", + input: { path: "output.txt", content: "test" }, }, ] @@ -290,7 +288,7 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = true, ) // Ensure no tool_result blocks were added - expect(mockTask.userMessageContent.some((item: any) => item.type === "tool_result")).toBe(false) + expect(mockTask.userMessageContent.some((item: any) => item.type === "tool-result")).toBe(false) }) it("should handle partial tool blocks when didRejectTool is true in native tool calling", async () => { @@ -298,10 +296,10 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, - name: "write_to_file", - params: { path: "output.txt", content: "test" }, + type: "tool-call", + toolCallId: toolCallId, + toolName: "write_to_file", + input: { path: "output.txt", content: "test" }, partial: true, // Partial tool block }, ] @@ -312,13 +310,15 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = // Find the tool_result const toolResult = mockTask.userMessageContent.find( - (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, ) // Verify tool_result was created for partial block expect(toolResult).toBeDefined() - expect(toolResult.is_error).toBe(true) - expect(toolResult.content).toContain("was interrupted and not executed") + // Error is indicated by output type, not isError flag + expect(toolResult.output).toBeDefined() + const outputValue = typeof toolResult.output === "string" ? toolResult.output : toolResult.output?.value + expect(outputValue).toContain("was interrupted and not executed") }) }) }) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts index 15a1e2d8672..4602c16c01c 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts @@ -63,7 +63,7 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { // Add pushToolResultToUserContent method after mockTask is created so 'this' binds correctly mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => { const existingResult = mockTask.userMessageContent.find( - (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + (block: any) => block.type === "tool-result" && block.toolCallId === toolResult.toolCallId, ) if (existingResult) { return false @@ -78,11 +78,10 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { const toolCallId = "tool_call_unknown_123" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, // ID indicates native tool calling - name: "nonexistent_tool", - params: { some: "param" }, - partial: false, + type: "tool-call", + toolCallId: toolCallId, // ID indicates native tool calling + toolName: "nonexistent_tool", + input: { some: "param" }, }, ] @@ -91,15 +90,16 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { // Verify that a tool_result with error was pushed const toolResult = mockTask.userMessageContent.find( - (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, ) expect(toolResult).toBeDefined() - expect(toolResult.tool_use_id).toBe(toolCallId) + expect(toolResult.toolCallId).toBe(toolCallId) // The error is wrapped in JSON by formatResponse.toolError - expect(toolResult.content).toContain("nonexistent_tool") - expect(toolResult.content).toContain("does not exist") - expect(toolResult.content).toContain("error") + const outputValue = typeof toolResult.output === "string" ? toolResult.output : toolResult.output?.value + expect(outputValue).toContain("nonexistent_tool") + expect(outputValue).toContain("does not exist") + expect(outputValue).toContain("error") // Verify consecutiveMistakeCount was incremented expect(mockTask.consecutiveMistakeCount).toBe(1) @@ -118,10 +118,9 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { // tool_use without an id is treated as legacy/XML-style tool call and must be rejected. mockTask.assistantMessageContent = [ { - type: "tool_use", - name: "fake_tool_that_does_not_exist", - params: { param1: "value1" }, - partial: false, + type: "tool-call", + toolName: "fake_tool_that_does_not_exist", + input: { param1: "value1" }, }, ] @@ -150,11 +149,10 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { const toolCallId = "tool_call_freeze_test" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, // Native tool calling - name: "this_tool_definitely_does_not_exist", - params: {}, - partial: false, + type: "tool-call", + toolCallId: toolCallId, // Native tool calling + toolName: "this_tool_definitely_does_not_exist", + input: {}, }, ] @@ -171,7 +169,7 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { // Verify a tool_result was pushed (critical for API not to freeze) const toolResult = mockTask.userMessageContent.find( - (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, ) expect(toolResult).toBeDefined() }) @@ -181,11 +179,10 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { const toolCallId = "tool_call_mistake_test" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, - name: "unknown_tool_1", - params: {}, - partial: false, + type: "tool-call", + toolCallId: toolCallId, + toolName: "unknown_tool_1", + input: {}, }, ] @@ -200,11 +197,10 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { const toolCallId = "tool_call_ready_test" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, - name: "unknown_tool", - params: {}, - partial: false, + type: "tool-call", + toolCallId: toolCallId, + toolName: "unknown_tool", + input: {}, }, ] @@ -221,11 +217,10 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { const toolCallId = "tool_call_rejected_test" mockTask.assistantMessageContent = [ { - type: "tool_use", - id: toolCallId, - name: "unknown_tool", - params: {}, - partial: false, + type: "tool-call", + toolCallId: toolCallId, + toolName: "unknown_tool", + input: {}, }, ] @@ -235,11 +230,13 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { // When didRejectTool is true, should send error tool_result const toolResult = mockTask.userMessageContent.find( - (item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId, + (item: any) => item.type === "tool-result" && item.toolCallId === toolCallId, ) expect(toolResult).toBeDefined() - expect(toolResult.is_error).toBe(true) - expect(toolResult.content).toContain("due to user rejecting a previous tool") + // Error is indicated by output type, not isError flag + expect(toolResult.output).toBeDefined() + const outputValue = typeof toolResult.output === "string" ? toolResult.output : toolResult.output?.value + expect(outputValue).toContain("due to user rejecting a previous tool") }) }) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index c183d51ca53..eaf3ffb4c0d 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -1,6 +1,6 @@ import { serializeError } from "serialize-error" -import { Anthropic } from "@anthropic-ai/sdk" +import type { NeutralImageBlock, NeutralTextBlock } from "../task-persistence" import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types" import { ConsecutiveMistakeError, TelemetryEventName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -118,10 +118,10 @@ export async function presentAssistantMessage(cline: Task) { if (toolCallId) { cline.pushToolResultToUserContent({ - type: "tool_result", - tool_use_id: sanitizeToolUseId(toolCallId), - content: errorMessage, - is_error: true, + type: "tool-result", + toolCallId: sanitizeToolUseId(toolCallId), + toolName: mcpBlock.toolName || "", + output: { type: "error-text" as const, value: errorMessage }, }) } break @@ -143,15 +143,15 @@ export async function presentAssistantMessage(cline: Task) { } let resultContent: string - let imageBlocks: Anthropic.ImageBlockParam[] = [] + let imageBlocks: NeutralImageBlock[] = [] if (typeof content === "string") { resultContent = content || "(tool did not return anything)" } else { const textBlocks = content.filter((item) => item.type === "text") - imageBlocks = content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[] + imageBlocks = content.filter((item) => item.type === "image") as NeutralImageBlock[] resultContent = - textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") || + textBlocks.map((item) => (item as NeutralTextBlock).text).join("\n") || "(tool did not return anything)" } @@ -169,9 +169,10 @@ export async function presentAssistantMessage(cline: Task) { if (toolCallId) { cline.pushToolResultToUserContent({ - type: "tool_result", - tool_use_id: sanitizeToolUseId(toolCallId), - content: resultContent, + type: "tool-result", + toolCallId: sanitizeToolUseId(toolCallId), + toolName: mcpBlock.toolName || "", + output: { type: "text" as const, value: resultContent }, }) if (imageBlocks.length > 0) { @@ -253,11 +254,11 @@ export async function presentAssistantMessage(cline: Task) { // Execute the MCP tool using the same handler as use_mcp_tool // Create a synthetic ToolUse block that the useMcpToolTool can handle - const syntheticToolUse: ToolUse<"use_mcp_tool"> = { - type: "tool_use", - id: mcpBlock.id, - name: "use_mcp_tool", - params: { + const syntheticToolUse: ToolUse = { + type: "tool-call", + toolCallId: mcpBlock.id ?? "", + toolName: "use_mcp_tool", + input: { server_name: resolvedServerName, tool_name: mcpBlock.toolName, arguments: JSON.stringify(mcpBlock.arguments), @@ -308,20 +309,17 @@ export async function presentAssistantMessage(cline: Task) { await cline.say("text", content, undefined, block.partial) break } - case "tool_use": { + case "tool-call": { // Native tool calling is the only supported tool calling mechanism. - // A tool_use block without an id is invalid and cannot be executed. - const toolCallId = (block as any).id as string | undefined + // A tool-call block without a toolCallId is invalid and cannot be executed. + const toolCallId = block.toolCallId as string | undefined if (!toolCallId) { const errorMessage = - "Invalid tool call: missing tool_use.id. XML tool calls are no longer supported. Remove any XML tool markup (e.g. ...) and use native tool calling instead." + "Invalid tool call: missing toolCallId. XML tool calls are no longer supported. Remove any XML tool markup (e.g. ...) and use native tool calling instead." // Record a tool error for visibility/telemetry. Use the reported tool name if present. try { - if ( - typeof (cline as any).recordToolError === "function" && - typeof (block as any).name === "string" - ) { - ;(cline as any).recordToolError((block as any).name as ToolName, errorMessage) + if (typeof (cline as any).recordToolError === "function" && typeof block.toolName === "string") { + ;(cline as any).recordToolError(block.toolName as ToolName, errorMessage) } } catch { // Best-effort only @@ -338,67 +336,69 @@ export async function presentAssistantMessage(cline: Task) { const { mode, customModes, experiments: stateExperiments, disabledTools } = state ?? {} const toolDescription = (): string => { - switch (block.name) { + switch (block.toolName) { case "execute_command": - return `[${block.name} for '${block.params.command}']` + return `[${block.toolName} for '${block.input.command}']` case "read_file": - // Prefer native typed args when available; fall back to legacy params + // Prefer native typed args when available; fall back to legacy input // Check if nativeArgs exists (native protocol) if (block.nativeArgs) { - return readFileTool.getReadFileToolDescription(block.name, block.nativeArgs) + return readFileTool.getReadFileToolDescription(block.toolName, block.nativeArgs) } - return readFileTool.getReadFileToolDescription(block.name, block.params) + return readFileTool.getReadFileToolDescription(block.toolName, block.input) case "write_to_file": - return `[${block.name} for '${block.params.path}']` + return `[${block.toolName} for '${block.input.path}']` case "apply_diff": // Native-only: tool args are structured (no XML payloads). - return block.params?.path ? `[${block.name} for '${block.params.path}']` : `[${block.name}]` + return block.input?.path + ? `[${block.toolName} for '${block.input.path}']` + : `[${block.toolName}]` case "search_files": - return `[${block.name} for '${block.params.regex}'${ - block.params.file_pattern ? ` in '${block.params.file_pattern}'` : "" + return `[${block.toolName} for '${block.input.regex}'${ + block.input.file_pattern ? ` in '${block.input.file_pattern}'` : "" }]` case "search_and_replace": - return `[${block.name} for '${block.params.path}']` + return `[${block.toolName} for '${block.input.path}']` case "search_replace": - return `[${block.name} for '${block.params.file_path}']` + return `[${block.toolName} for '${block.input.file_path}']` case "edit_file": - return `[${block.name} for '${block.params.file_path}']` + return `[${block.toolName} for '${block.input.file_path}']` case "apply_patch": - return `[${block.name}]` + return `[${block.toolName}]` case "list_files": - return `[${block.name} for '${block.params.path}']` + return `[${block.toolName} for '${block.input.path}']` case "browser_action": - return `[${block.name} for '${block.params.action}']` + return `[${block.toolName} for '${block.input.action}']` case "use_mcp_tool": - return `[${block.name} for '${block.params.server_name}']` + return `[${block.toolName} for '${block.input.server_name}']` case "access_mcp_resource": - return `[${block.name} for '${block.params.server_name}']` + return `[${block.toolName} for '${block.input.server_name}']` case "ask_followup_question": - return `[${block.name} for '${block.params.question}']` + return `[${block.toolName} for '${block.input.question}']` case "attempt_completion": - return `[${block.name}]` + return `[${block.toolName}]` case "switch_mode": - return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]` + return `[${block.toolName} to '${block.input.mode_slug}'${block.input.reason ? ` because: ${block.input.reason}` : ""}]` case "codebase_search": - return `[${block.name} for '${block.params.query}']` + return `[${block.toolName} for '${block.input.query}']` case "read_command_output": - return `[${block.name} for '${block.params.artifact_id}']` + return `[${block.toolName} for '${block.input.artifact_id}']` case "update_todo_list": - return `[${block.name}]` + return `[${block.toolName}]` case "new_task": { - const mode = block.params.mode ?? defaultModeSlug - const message = block.params.message ?? "(no message)" + const mode = block.input.mode ?? defaultModeSlug + const message = block.input.message ?? "(no message)" const modeName = getModeBySlug(mode, customModes)?.name ?? mode - return `[${block.name} in ${modeName} mode: '${message}']` + return `[${block.toolName} in ${modeName} mode: '${message}']` } case "run_slash_command": - return `[${block.name} for '${block.params.command}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` + return `[${block.toolName} for '${block.input.command}'${block.input.args ? ` with args: ${block.input.args}` : ""}]` case "skill": - return `[${block.name} for '${block.params.skill}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` + return `[${block.toolName} for '${block.input.skill}'${block.input.args ? ` with args: ${block.input.args}` : ""}]` case "generate_image": - return `[${block.name} for '${block.params.path}']` + return `[${block.toolName} for '${block.input.path}']` default: - return `[${block.name}]` + return `[${block.toolName}]` } } @@ -410,10 +410,10 @@ export async function presentAssistantMessage(cline: Task) { : `Tool ${toolDescription()} was interrupted and not executed due to user rejecting a previous tool.` cline.pushToolResultToUserContent({ - type: "tool_result", - tool_use_id: sanitizeToolUseId(toolCallId), - content: errorMessage, - is_error: true, + type: "tool-result", + toolCallId: sanitizeToolUseId(toolCallId), + toolName: block.toolName || "", + output: { type: "error-text" as const, value: errorMessage }, }) break @@ -430,16 +430,16 @@ export async function presentAssistantMessage(cline: Task) { // This avoids executing an invalid tool_use block and prevents duplicate/fragmented // error reporting. if (!block.partial) { - const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined - const isKnownTool = isValidToolName(String(block.name), stateExperiments) + const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.toolName) : undefined + const isKnownTool = isValidToolName(String(block.toolName), stateExperiments) if (isKnownTool && !block.nativeArgs && !customTool) { const errorMessage = - `Invalid tool call for '${block.name}': missing nativeArgs. ` + + `Invalid tool call for '${block.toolName}': missing nativeArgs. ` + `This usually means the model streamed invalid or incomplete arguments and the call could not be finalized.` cline.consecutiveMistakeCount++ try { - cline.recordToolError(block.name as ToolName, errorMessage) + cline.recordToolError(block.toolName as ToolName, errorMessage) } catch { // Best-effort only } @@ -447,10 +447,10 @@ export async function presentAssistantMessage(cline: Task) { // Push tool_result directly without setting didAlreadyUseTool so streaming can // continue gracefully. cline.pushToolResultToUserContent({ - type: "tool_result", - tool_use_id: sanitizeToolUseId(toolCallId), - content: formatResponse.toolError(errorMessage), - is_error: true, + type: "tool-result", + toolCallId: sanitizeToolUseId(toolCallId), + toolName: block.toolName || "", + output: { type: "error-text" as const, value: formatResponse.toolError(errorMessage) }, }) break @@ -470,15 +470,15 @@ export async function presentAssistantMessage(cline: Task) { } let resultContent: string - let imageBlocks: Anthropic.ImageBlockParam[] = [] + let imageBlocks: NeutralImageBlock[] = [] if (typeof content === "string") { resultContent = content || "(tool did not return anything)" } else { const textBlocks = content.filter((item) => item.type === "text") - imageBlocks = content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[] + imageBlocks = content.filter((item) => item.type === "image") as NeutralImageBlock[] resultContent = - textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") || + textBlocks.map((item) => (item as NeutralTextBlock).text).join("\n") || "(tool did not return anything)" } @@ -493,9 +493,10 @@ export async function presentAssistantMessage(cline: Task) { } cline.pushToolResultToUserContent({ - type: "tool_result", - tool_use_id: sanitizeToolUseId(toolCallId), - content: resultContent, + type: "tool-result", + toolCallId: sanitizeToolUseId(toolCallId), + toolName: block.toolName || "", + output: { type: "text" as const, value: resultContent }, }) if (imageBlocks.length > 0) { @@ -585,25 +586,25 @@ export async function presentAssistantMessage(cline: Task) { } const sessionActive = hasStarted && !isClosed // Only auto-close when no active browser session is present, and this isn't a browser_action - if (!sessionActive && block.name !== "browser_action") { + if (!sessionActive && block.toolName !== "browser_action") { await cline.browserSession.closeBrowser() } } catch { // On any unexpected error, fall back to conservative behavior - if (block.name !== "browser_action") { + if (block.toolName !== "browser_action") { await cline.browserSession.closeBrowser() } } if (!block.partial) { // Check if this is a custom tool - if so, record as "custom_tool" (like MCP tools) - const isCustomTool = stateExperiments?.customTools && customToolRegistry.has(block.name) - const recordName = isCustomTool ? "custom_tool" : block.name + const isCustomTool = stateExperiments?.customTools && customToolRegistry.has(block.toolName) + const recordName = isCustomTool ? "custom_tool" : block.toolName cline.recordToolUsage(recordName) TelemetryService.instance.captureToolUsage(cline.taskId, recordName) // Track legacy format usage for read_file tool (for migration monitoring) - if (block.name === "read_file" && block.usedLegacyFormat) { + if (block.toolName === "read_file" && block.usedLegacyFormat) { const modelInfo = cline.api.getModel() TelemetryService.instance.captureEvent(TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, { taskId: cline.taskId, @@ -635,11 +636,11 @@ export async function presentAssistantMessage(cline: Task) { ) ?? {} validateToolUse( - block.name as ToolName, + block.toolName as ToolName, mode ?? defaultModeSlug, customModes ?? [], toolRequirements, - block.params, + block.input, stateExperiments, includedTools, ) @@ -653,10 +654,13 @@ export async function presentAssistantMessage(cline: Task) { const errorContent = formatResponse.toolError(error.message) // Push tool_result directly without setting didAlreadyUseTool cline.pushToolResultToUserContent({ - type: "tool_result", - tool_use_id: sanitizeToolUseId(toolCallId), - content: typeof errorContent === "string" ? errorContent : "(validation error)", - is_error: true, + type: "tool-result", + toolCallId: sanitizeToolUseId(toolCallId), + toolName: block.toolName || "", + output: { + type: "error-text" as const, + value: typeof errorContent === "string" ? errorContent : "(validation error)", + }, }) break @@ -674,7 +678,7 @@ export async function presentAssistantMessage(cline: Task) { // Handle repetition similar to mistake_limit_reached pattern. const { response, text, images } = await cline.ask( repetitionCheck.askUser.messageKey as ClineAsk, - repetitionCheck.askUser.messageDetail.replace("{toolName}", block.name), + repetitionCheck.askUser.messageDetail.replace("{toolName}", block.toolName), ) if (response === "messageResponse") { @@ -695,7 +699,7 @@ export async function presentAssistantMessage(cline: Task) { TelemetryService.instance.captureConsecutiveMistakeError(cline.taskId) TelemetryService.instance.captureException( new ConsecutiveMistakeError( - `Tool repetition limit reached for ${block.name}`, + `Tool repetition limit reached for ${block.toolName}`, cline.taskId, cline.consecutiveMistakeCount, cline.consecutiveMistakeLimit, @@ -708,24 +712,24 @@ export async function presentAssistantMessage(cline: Task) { // Return tool result message about the repetition pushToolResult( formatResponse.toolError( - `Tool call repetition limit reached for ${block.name}. Please try a different approach.`, + `Tool call repetition limit reached for ${block.toolName}. Please try a different approach.`, ), ) break } } - switch (block.name) { + switch (block.toolName) { case "write_to_file": await checkpointSaveAndMark(cline) - await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, { + await writeToFileTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, }) break case "update_todo_list": - await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, { + await updateTodoListTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, @@ -733,7 +737,7 @@ export async function presentAssistantMessage(cline: Task) { break case "apply_diff": await checkpointSaveAndMark(cline) - await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { + await applyDiffToolClass.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, @@ -741,7 +745,7 @@ export async function presentAssistantMessage(cline: Task) { break case "search_and_replace": await checkpointSaveAndMark(cline) - await searchAndReplaceTool.handle(cline, block as ToolUse<"search_and_replace">, { + await searchAndReplaceTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, @@ -749,7 +753,7 @@ export async function presentAssistantMessage(cline: Task) { break case "search_replace": await checkpointSaveAndMark(cline) - await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, { + await searchReplaceTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, @@ -757,7 +761,7 @@ export async function presentAssistantMessage(cline: Task) { break case "edit_file": await checkpointSaveAndMark(cline) - await editFileTool.handle(cline, block as ToolUse<"edit_file">, { + await editFileTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, @@ -765,7 +769,7 @@ export async function presentAssistantMessage(cline: Task) { break case "apply_patch": await checkpointSaveAndMark(cline) - await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, { + await applyPatchTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, @@ -773,79 +777,73 @@ export async function presentAssistantMessage(cline: Task) { break case "read_file": // Type assertion is safe here because we're in the "read_file" case - await readFileTool.handle(cline, block as ToolUse<"read_file">, { + await readFileTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, }) break case "list_files": - await listFilesTool.handle(cline, block as ToolUse<"list_files">, { + await listFilesTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, }) break case "codebase_search": - await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, { + await codebaseSearchTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, }) break case "search_files": - await searchFilesTool.handle(cline, block as ToolUse<"search_files">, { + await searchFilesTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, }) break case "browser_action": - await browserActionTool( - cline, - block as ToolUse<"browser_action">, - askApproval, - handleError, - pushToolResult, - ) + await browserActionTool(cline, block as ToolUse, askApproval, handleError, pushToolResult) break case "execute_command": - await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, { + await executeCommandTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, }) break case "read_command_output": - await readCommandOutputTool.handle(cline, block as ToolUse<"read_command_output">, { + await readCommandOutputTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, }) break case "use_mcp_tool": - await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, { + await useMcpToolTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, }) break case "access_mcp_resource": - await accessMcpResourceTool.handle(cline, block as ToolUse<"access_mcp_resource">, { + await accessMcpResourceTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, }) break case "ask_followup_question": - await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, { + await askFollowupQuestionTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, }) break case "switch_mode": - await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, { + await switchModeTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, @@ -853,11 +851,11 @@ export async function presentAssistantMessage(cline: Task) { break case "new_task": await checkpointSaveAndMark(cline) - await newTaskTool.handle(cline, block as ToolUse<"new_task">, { + await newTaskTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, - toolCallId: block.id, + toolCallId: block.toolCallId, }) break case "attempt_completion": { @@ -868,22 +866,18 @@ export async function presentAssistantMessage(cline: Task) { askFinishSubTaskApproval, toolDescription, } - await attemptCompletionTool.handle( - cline, - block as ToolUse<"attempt_completion">, - completionCallbacks, - ) + await attemptCompletionTool.handle(cline, block as ToolUse, completionCallbacks) break } case "run_slash_command": - await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, { + await runSlashCommandTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, }) break case "skill": - await skillTool.handle(cline, block as ToolUse<"skill">, { + await skillTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, @@ -891,7 +885,7 @@ export async function presentAssistantMessage(cline: Task) { break case "generate_image": await checkpointSaveAndMark(cline) - await generateImageTool.handle(cline, block as ToolUse<"generate_image">, { + await generateImageTool.handle(cline, block as ToolUse, { askApproval, handleError, pushToolResult, @@ -908,7 +902,9 @@ export async function presentAssistantMessage(cline: Task) { break } - const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined + const customTool = stateExperiments?.customTools + ? customToolRegistry.get(block.toolName) + : undefined if (customTool) { try { @@ -916,9 +912,9 @@ export async function presentAssistantMessage(cline: Task) { if (customTool.parameters) { try { - customToolArgs = customTool.parameters.parse(block.nativeArgs || block.params || {}) + customToolArgs = customTool.parameters.parse(block.nativeArgs || block.input || {}) } catch (parseParamsError) { - const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}` + const message = `Custom tool "${block.toolName}" argument validation failed: ${parseParamsError.message}` console.error(message) cline.consecutiveMistakeCount++ await cline.say("error", message) @@ -942,24 +938,24 @@ export async function presentAssistantMessage(cline: Task) { cline.consecutiveMistakeCount++ // Record custom tool error with static name cline.recordToolError("custom_tool", executionError.message) - await handleError(`executing custom tool "${block.name}"`, executionError) + await handleError(`executing custom tool "${block.toolName}"`, executionError) } break } // Not a custom tool - handle as unknown tool error - const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.` + const errorMessage = `Unknown tool "${block.toolName}". This tool does not exist. Please use one of the available tools.` cline.consecutiveMistakeCount++ - cline.recordToolError(block.name as ToolName, errorMessage) - await cline.say("error", t("tools:unknownToolError", { toolName: block.name })) + cline.recordToolError(block.toolName as ToolName, errorMessage) + await cline.say("error", t("tools:unknownToolError", { toolName: block.toolName })) // Push tool_result directly WITHOUT setting didAlreadyUseTool // This prevents the stream from being interrupted with "Response interrupted by tool use result" cline.pushToolResultToUserContent({ - type: "tool_result", - tool_use_id: sanitizeToolUseId(toolCallId), - content: formatResponse.toolError(errorMessage), - is_error: true, + type: "tool-result", + toolCallId: sanitizeToolUseId(toolCallId), + toolName: block.toolName || "", + output: { type: "error-text" as const, value: formatResponse.toolError(errorMessage) }, }) break } diff --git a/src/core/condense/__tests__/condense.spec.ts b/src/core/condense/__tests__/condense.spec.ts index c209fa97243..c53da1f1ec7 100644 --- a/src/core/condense/__tests__/condense.spec.ts +++ b/src/core/condense/__tests__/condense.spec.ts @@ -1,6 +1,6 @@ // npx vitest src/core/condense/__tests__/condense.spec.ts -import { Anthropic } from "@anthropic-ai/sdk" +import type { NeutralContentBlock } from "../../task-persistence/apiMessages" import type { ModelInfo } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -41,7 +41,7 @@ class MockApiHandler extends BaseProvider { } } - override async countTokens(content: Array): Promise { + override async countTokens(content: Array): Promise { // Simple token counting for testing let tokens = 0 for (const block of content) { @@ -228,7 +228,7 @@ Line 2 }) it("should handle complex first message content", async () => { - const complexContent: Anthropic.Messages.ContentBlockParam[] = [ + const complexContent: NeutralContentBlock[] = [ { type: "text", text: "/mode code" }, { type: "text", text: "Additional context from the user" }, ] diff --git a/src/core/condense/__tests__/foldedFileContext.spec.ts b/src/core/condense/__tests__/foldedFileContext.spec.ts index 3bd9b390f5a..facab41ffc1 100644 --- a/src/core/condense/__tests__/foldedFileContext.spec.ts +++ b/src/core/condense/__tests__/foldedFileContext.spec.ts @@ -1,7 +1,7 @@ // npx vitest src/core/condense/__tests__/foldedFileContext.spec.ts +import type { NeutralContentBlock } from "../../task-persistence/apiMessages" import * as path from "path" -import { Anthropic } from "@anthropic-ai/sdk" import type { ModelInfo } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { BaseProvider } from "../../../api/providers/base-provider" @@ -246,7 +246,7 @@ describe("foldedFileContext", () => { } } - override async countTokens(content: Array): Promise { + override async countTokens(content: Array): Promise { let tokens = 0 for (const block of content) { if (block.type === "text") { diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index 10092f71dc7..9ea51fdfde5 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -1,8 +1,9 @@ // npx vitest core/condense/__tests__/index.spec.ts +import type { TextPart, ToolCallPart, ToolResultPart } from "ai" +import type { RooContentBlock } from "../../task-persistence/apiMessages" import type { Mock } from "vitest" -import { Anthropic } from "@anthropic-ai/sdk" import { TelemetryService } from "@roo-code/telemetry" import { ApiHandler } from "../../../api" @@ -111,12 +112,21 @@ describe("injectSyntheticToolResults", () => { { role: "user", content: "Hello", ts: 1 }, { role: "assistant", - content: [{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "test.ts" } }], + content: [ + { type: "tool-call", toolCallId: "tool-1", toolName: "read_file", input: { path: "test.ts" } }, + ], ts: 2, }, { role: "user", - content: [{ type: "tool_result", tool_use_id: "tool-1", content: "file contents" }], + content: [ + { + type: "tool-result", + toolCallId: "tool-1", + toolName: "", + output: { type: "text" as const, value: "file contents" }, + }, + ], ts: 3, }, ] @@ -131,7 +141,12 @@ describe("injectSyntheticToolResults", () => { { role: "assistant", content: [ - { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } }, + { + type: "tool-call", + toolCallId: "tool-orphan", + toolName: "attempt_completion", + input: { result: "Done" }, + }, ], ts: 2, }, @@ -145,9 +160,12 @@ describe("injectSyntheticToolResults", () => { const content = result[2].content as any[] expect(content.length).toBe(1) - expect(content[0].type).toBe("tool_result") - expect(content[0].tool_use_id).toBe("tool-orphan") - expect(content[0].content).toBe("Context condensation triggered. Tool execution deferred.") + expect(content[0].type).toBe("tool-result") + expect(content[0].toolCallId).toBe("tool-orphan") + expect(content[0].output).toEqual({ + type: "text", + value: "Context condensation triggered. Tool execution deferred.", + }) }) it("should inject synthetic tool_results for multiple orphan tool_calls", () => { @@ -156,8 +174,13 @@ describe("injectSyntheticToolResults", () => { { role: "assistant", content: [ - { type: "tool_use", id: "tool-1", name: "read_file", input: { path: "test.ts" } }, - { type: "tool_use", id: "tool-2", name: "write_file", input: { path: "out.ts", content: "code" } }, + { type: "tool-call", toolCallId: "tool-1", toolName: "read_file", input: { path: "test.ts" } }, + { + type: "tool-call", + toolCallId: "tool-2", + toolName: "write_file", + input: { path: "out.ts", content: "code" }, + }, ], ts: 2, }, @@ -169,8 +192,8 @@ describe("injectSyntheticToolResults", () => { expect(result.length).toBe(3) const content = result[2].content as any[] expect(content.length).toBe(2) - expect(content[0].tool_use_id).toBe("tool-1") - expect(content[1].tool_use_id).toBe("tool-2") + expect(content[0].toolCallId).toBe("tool-1") + expect(content[1].toolCallId).toBe("tool-2") }) it("should only inject for orphan tool_calls, not matched ones", () => { @@ -179,14 +202,31 @@ describe("injectSyntheticToolResults", () => { { role: "assistant", content: [ - { type: "tool_use", id: "matched-tool", name: "read_file", input: { path: "test.ts" } }, - { type: "tool_use", id: "orphan-tool", name: "attempt_completion", input: { result: "Done" } }, + { + type: "tool-call", + toolCallId: "matched-tool", + toolName: "read_file", + input: { path: "test.ts" }, + }, + { + type: "tool-call", + toolCallId: "orphan-tool", + toolName: "attempt_completion", + input: { result: "Done" }, + }, ], ts: 2, }, { role: "user", - content: [{ type: "tool_result", tool_use_id: "matched-tool", content: "file contents" }], + content: [ + { + type: "tool-result", + toolCallId: "matched-tool", + toolName: "", + output: { type: "text" as const, value: "file contents" }, + }, + ], ts: 3, }, // No tool_result for orphan-tool @@ -197,7 +237,7 @@ describe("injectSyntheticToolResults", () => { expect(result.length).toBe(4) const syntheticContent = result[3].content as any[] expect(syntheticContent.length).toBe(1) - expect(syntheticContent[0].tool_use_id).toBe("orphan-tool") + expect(syntheticContent[0].toolCallId).toBe("orphan-tool") }) it("should handle messages with string content (no tool_use/tool_result)", () => { @@ -221,19 +261,33 @@ describe("injectSyntheticToolResults", () => { { role: "assistant", content: [ - { type: "tool_use", id: "tool-1", name: "read_file", input: { path: "a.ts" } }, - { type: "tool_use", id: "tool-2", name: "read_file", input: { path: "b.ts" } }, + { type: "tool-call", toolCallId: "tool-1", toolName: "read_file", input: { path: "a.ts" } }, + { type: "tool-call", toolCallId: "tool-2", toolName: "read_file", input: { path: "b.ts" } }, ], ts: 2, }, { role: "user", - content: [{ type: "tool_result", tool_use_id: "tool-1", content: "contents a" }], + content: [ + { + type: "tool-result", + toolCallId: "tool-1", + toolName: "", + output: { type: "text" as const, value: "contents a" }, + }, + ], ts: 3, }, { role: "user", - content: [{ type: "tool_result", tool_use_id: "tool-2", content: "contents b" }], + content: [ + { + type: "tool-result", + toolCallId: "tool-2", + toolName: "", + output: { type: "text" as const, value: "contents b" }, + }, + ], ts: 4, }, ] @@ -416,7 +470,12 @@ describe("getEffectiveApiHistory", () => { { role: "assistant", content: [ - { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } }, + { + type: "tool-call", + toolCallId: "tool-orphan", + toolName: "attempt_completion", + input: { result: "Done" }, + }, ], condenseParent: condenseId, }, @@ -430,7 +489,14 @@ describe("getEffectiveApiHistory", () => { // This tool_result references a tool_use that was condensed away (orphan!) { role: "user", - content: [{ type: "tool_result", tool_use_id: "tool-orphan", content: "Rejected by user" }], + content: [ + { + type: "tool-result", + toolCallId: "tool-orphan", + toolName: "", + output: { type: "text" as const, value: "Rejected by user" }, + }, + ], }, ] @@ -454,12 +520,21 @@ describe("getEffectiveApiHistory", () => { // This tool_use is AFTER the summary, so it's not condensed away { role: "assistant", - content: [{ type: "tool_use", id: "tool-valid", name: "read_file", input: { path: "test.ts" } }], + content: [ + { type: "tool-call", toolCallId: "tool-valid", toolName: "read_file", input: { path: "test.ts" } }, + ], }, // This tool_result has a matching tool_use, so it should be kept { role: "user", - content: [{ type: "tool_result", tool_use_id: "tool-valid", content: "file contents" }], + content: [ + { + type: "tool-result", + toolCallId: "tool-valid", + toolName: "", + output: { type: "text" as const, value: "file contents" }, + }, + ], }, ] @@ -468,8 +543,8 @@ describe("getEffectiveApiHistory", () => { // All messages after summary should be included expect(result).toHaveLength(3) expect(result[0].isSummary).toBe(true) - expect((result[1].content as any[])[0].id).toBe("tool-valid") - expect((result[2].content as any[])[0].tool_use_id).toBe("tool-valid") + expect((result[1].content as any[])[0].toolCallId).toBe("tool-valid") + expect((result[2].content as any[])[0].toolCallId).toBe("tool-valid") }) it("should filter orphan tool_results but keep other content in mixed user message", () => { @@ -479,7 +554,12 @@ describe("getEffectiveApiHistory", () => { { role: "assistant", content: [ - { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } }, + { + type: "tool-call", + toolCallId: "tool-orphan", + toolName: "attempt_completion", + input: { result: "Done" }, + }, ], condenseParent: condenseId, }, @@ -492,14 +572,26 @@ describe("getEffectiveApiHistory", () => { // This tool_use is AFTER the summary { role: "assistant", - content: [{ type: "tool_use", id: "tool-valid", name: "read_file", input: { path: "test.ts" } }], + content: [ + { type: "tool-call", toolCallId: "tool-valid", toolName: "read_file", input: { path: "test.ts" } }, + ], }, // Mixed content: one orphan tool_result and one valid tool_result { role: "user", content: [ - { type: "tool_result", tool_use_id: "tool-orphan", content: "Orphan result" }, - { type: "tool_result", tool_use_id: "tool-valid", content: "Valid result" }, + { + type: "tool-result", + toolCallId: "tool-orphan", + toolName: "", + output: { type: "text" as const, value: "Orphan result" }, + }, + { + type: "tool-result", + toolCallId: "tool-valid", + toolName: "", + output: { type: "text" as const, value: "Valid result" }, + }, ], }, ] @@ -512,7 +604,7 @@ describe("getEffectiveApiHistory", () => { // The user message should only contain the valid tool_result const userContent = result[2].content as any[] expect(userContent).toHaveLength(1) - expect(userContent[0].tool_use_id).toBe("tool-valid") + expect(userContent[0].toolCallId).toBe("tool-valid") }) it("should handle multiple orphan tool_results in a single message", () => { @@ -521,8 +613,13 @@ describe("getEffectiveApiHistory", () => { { role: "assistant", content: [ - { type: "tool_use", id: "orphan-1", name: "read_file", input: { path: "a.ts" } }, - { type: "tool_use", id: "orphan-2", name: "write_file", input: { path: "b.ts", content: "code" } }, + { type: "tool-call", toolCallId: "orphan-1", toolName: "read_file", input: { path: "a.ts" } }, + { + type: "tool-call", + toolCallId: "orphan-2", + toolName: "write_file", + input: { path: "b.ts", content: "code" }, + }, ], condenseParent: condenseId, }, @@ -536,8 +633,18 @@ describe("getEffectiveApiHistory", () => { { role: "user", content: [ - { type: "tool_result", tool_use_id: "orphan-1", content: "Result 1" }, - { type: "tool_result", tool_use_id: "orphan-2", content: "Result 2" }, + { + type: "tool-result", + toolCallId: "orphan-1", + toolName: "", + output: { type: "text" as const, value: "Result 1" }, + }, + { + type: "tool-result", + toolCallId: "orphan-2", + toolName: "", + output: { type: "text" as const, value: "Result 2" }, + }, ], }, ] @@ -555,7 +662,12 @@ describe("getEffectiveApiHistory", () => { { role: "assistant", content: [ - { type: "tool_use", id: "tool-orphan", name: "attempt_completion", input: { result: "Done" } }, + { + type: "tool-call", + toolCallId: "tool-orphan", + toolName: "attempt_completion", + input: { result: "Done" }, + }, ], condenseParent: condenseId, }, @@ -570,7 +682,12 @@ describe("getEffectiveApiHistory", () => { role: "user", content: [ { type: "text", text: "User added some text" }, - { type: "tool_result", tool_use_id: "tool-orphan", content: "Orphan result" }, + { + type: "tool-result", + toolCallId: "tool-orphan", + toolName: "", + output: { type: "text" as const, value: "Orphan result" }, + }, ], }, ] @@ -1289,10 +1406,10 @@ describe("summarizeConversation with custom settings", () => { describe("toolUseToText", () => { it("should convert tool_use block with object input to text", () => { - const block: Anthropic.Messages.ToolUseBlockParam = { - type: "tool_use", - id: "tool-123", - name: "read_file", + const block: ToolCallPart = { + type: "tool-call", + toolCallId: "tool-123", + toolName: "read_file", input: { path: "test.ts", encoding: "utf-8" }, } @@ -1302,10 +1419,10 @@ describe("toolUseToText", () => { }) it("should convert tool_use block with nested object input to text", () => { - const block: Anthropic.Messages.ToolUseBlockParam = { - type: "tool_use", - id: "tool-456", - name: "write_file", + const block: ToolCallPart = { + type: "tool-call", + toolCallId: "tool-456", + toolName: "write_file", input: { path: "output.json", content: { key: "value", nested: { a: 1 } }, @@ -1322,10 +1439,10 @@ describe("toolUseToText", () => { }) it("should convert tool_use block with string input to text", () => { - const block: Anthropic.Messages.ToolUseBlockParam = { - type: "tool_use", - id: "tool-789", - name: "execute_command", + const block: ToolCallPart = { + type: "tool-call", + toolCallId: "tool-789", + toolName: "execute_command", input: "ls -la" as unknown as Record, } @@ -1335,10 +1452,10 @@ describe("toolUseToText", () => { }) it("should handle empty object input", () => { - const block: Anthropic.Messages.ToolUseBlockParam = { - type: "tool_use", - id: "tool-empty", - name: "some_tool", + const block: ToolCallPart = { + type: "tool-call", + toolCallId: "tool-empty", + toolName: "some_tool", input: {}, } @@ -1350,10 +1467,11 @@ describe("toolUseToText", () => { describe("toolResultToText", () => { it("should convert tool_result with string content to text", () => { - const block: Anthropic.Messages.ToolResultBlockParam = { - type: "tool_result", - tool_use_id: "tool-123", - content: "File contents here", + const block: ToolResultPart = { + type: "tool-result", + toolCallId: "tool-123", + toolName: "", + output: { type: "text" as const, value: "File contents here" }, } const result = toolResultToText(block) @@ -1362,11 +1480,11 @@ describe("toolResultToText", () => { }) it("should convert tool_result with error flag to text", () => { - const block: Anthropic.Messages.ToolResultBlockParam = { - type: "tool_result", - tool_use_id: "tool-456", - content: "File not found", - is_error: true, + const block: ToolResultPart = { + type: "tool-result", + toolCallId: "tool-456", + toolName: "", + output: { type: "error-text" as const, value: "File not found" }, } const result = toolResultToText(block) @@ -1375,13 +1493,17 @@ describe("toolResultToText", () => { }) it("should convert tool_result with array content to text", () => { - const block: Anthropic.Messages.ToolResultBlockParam = { - type: "tool_result", - tool_use_id: "tool-789", - content: [ - { type: "text", text: "First line" }, - { type: "text", text: "Second line" }, - ], + const block: ToolResultPart = { + type: "tool-result", + toolCallId: "tool-789", + toolName: "", + output: { + type: "content" as const, + value: [ + { type: "text", text: "First line" }, + { type: "text", text: "Second line" }, + ], + }, } const result = toolResultToText(block) @@ -1390,29 +1512,35 @@ describe("toolResultToText", () => { }) it("should handle tool_result with image in array content", () => { - const block: Anthropic.Messages.ToolResultBlockParam = { - type: "tool_result", - tool_use_id: "tool-img", - content: [ - { type: "text", text: "Screenshot:" }, - { type: "image", source: { type: "base64", media_type: "image/png", data: "abc123" } }, - ], + const block: ToolResultPart = { + type: "tool-result", + toolCallId: "tool-img", + toolName: "", + output: { + type: "content" as const, + value: [ + { type: "text", text: "Screenshot:" }, + { type: "image-data" as const, data: "abc123", mediaType: "image/png" }, + ], + }, } const result = toolResultToText(block) - expect(result).toBe("[Tool Result]\nScreenshot:\n[Image]") + expect(result).toBe("[Tool Result]\nScreenshot:\n[image-data]") }) it("should handle tool_result with no content", () => { - const block: Anthropic.Messages.ToolResultBlockParam = { - type: "tool_result", - tool_use_id: "tool-empty", + const block: ToolResultPart = { + type: "tool-result", + toolCallId: "tool-empty", + toolName: "", + output: { type: "text" as const, value: "" }, } const result = toolResultToText(block) - expect(result).toBe("[Tool Result]") + expect(result).toBe("[Tool Result]\n") }) }) @@ -1426,11 +1554,11 @@ describe("convertToolBlocksToText", () => { }) it("should convert tool_use blocks to text blocks", () => { - const content: Anthropic.Messages.ContentBlockParam[] = [ + const content: RooContentBlock[] = [ { - type: "tool_use", - id: "tool-123", - name: "read_file", + type: "tool-call", + toolCallId: "tool-123", + toolName: "read_file", input: { path: "test.ts" }, }, ] @@ -1438,33 +1566,34 @@ describe("convertToolBlocksToText", () => { const result = convertToolBlocksToText(content) expect(Array.isArray(result)).toBe(true) - expect((result as Anthropic.Messages.ContentBlockParam[])[0].type).toBe("text") - expect((result as Anthropic.Messages.TextBlockParam[])[0].text).toContain("[Tool Use: read_file]") + expect((result as RooContentBlock[])[0].type).toBe("text") + expect((result as TextPart[])[0].text).toContain("[Tool Use: read_file]") }) it("should convert tool_result blocks to text blocks", () => { - const content: Anthropic.Messages.ContentBlockParam[] = [ + const content: RooContentBlock[] = [ { - type: "tool_result", - tool_use_id: "tool-123", - content: "File contents", + type: "tool-result", + toolCallId: "tool-123", + toolName: "", + output: { type: "text" as const, value: "File contents" }, }, ] const result = convertToolBlocksToText(content) expect(Array.isArray(result)).toBe(true) - expect((result as Anthropic.Messages.ContentBlockParam[])[0].type).toBe("text") - expect((result as Anthropic.Messages.TextBlockParam[])[0].text).toContain("[Tool Result]") + expect((result as RooContentBlock[])[0].type).toBe("text") + expect((result as TextPart[])[0].text).toContain("[Tool Result]") }) it("should preserve non-tool blocks unchanged", () => { - const content: Anthropic.Messages.ContentBlockParam[] = [ + const content: RooContentBlock[] = [ { type: "text", text: "Hello" }, { - type: "tool_use", - id: "tool-123", - name: "read_file", + type: "tool-call", + toolCallId: "tool-123", + toolName: "read_file", input: { path: "test.ts" }, }, { type: "text", text: "World" }, @@ -1473,37 +1602,38 @@ describe("convertToolBlocksToText", () => { const result = convertToolBlocksToText(content) expect(Array.isArray(result)).toBe(true) - const resultArray = result as Anthropic.Messages.ContentBlockParam[] + const resultArray = result as RooContentBlock[] expect(resultArray).toHaveLength(3) expect(resultArray[0]).toEqual({ type: "text", text: "Hello" }) expect(resultArray[1].type).toBe("text") - expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Use: read_file]") + expect((resultArray[1] as TextPart).text).toContain("[Tool Use: read_file]") expect(resultArray[2]).toEqual({ type: "text", text: "World" }) }) it("should handle mixed content with multiple tool blocks", () => { - const content: Anthropic.Messages.ContentBlockParam[] = [ + const content: RooContentBlock[] = [ { - type: "tool_use", - id: "tool-1", - name: "read_file", + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", input: { path: "a.ts" }, }, { - type: "tool_result", - tool_use_id: "tool-1", - content: "contents of a.ts", + type: "tool-result", + toolCallId: "tool-1", + toolName: "", + output: { type: "text" as const, value: "contents of a.ts" }, }, ] const result = convertToolBlocksToText(content) expect(Array.isArray(result)).toBe(true) - const resultArray = result as Anthropic.Messages.ContentBlockParam[] + const resultArray = result as RooContentBlock[] expect(resultArray).toHaveLength(2) - expect((resultArray[0] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Use: read_file]") - expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("[Tool Result]") - expect((resultArray[1] as Anthropic.Messages.TextBlockParam).text).toContain("contents of a.ts") + expect((resultArray[0] as TextPart).text).toContain("[Tool Use: read_file]") + expect((resultArray[1] as TextPart).text).toContain("[Tool Result]") + expect((resultArray[1] as TextPart).text).toContain("contents of a.ts") }) }) @@ -1515,9 +1645,9 @@ describe("transformMessagesForCondensing", () => { role: "assistant" as const, content: [ { - type: "tool_use" as const, - id: "tool-1", - name: "read_file", + type: "tool-call" as const, + toolCallId: "tool-1", + toolName: "read_file", input: { path: "test.ts" }, }, ], @@ -1526,9 +1656,10 @@ describe("transformMessagesForCondensing", () => { role: "user" as const, content: [ { - type: "tool_result" as const, - tool_use_id: "tool-1", - content: "file contents", + type: "tool-result" as const, + toolCallId: "tool-1", + toolName: "", + output: { type: "text" as const, value: "file contents" }, }, ], }, @@ -1552,9 +1683,9 @@ describe("transformMessagesForCondensing", () => { role: "assistant" as const, content: [ { - type: "tool_use" as const, - id: "tool-1", - name: "execute", + type: "tool-call" as const, + toolCallId: "tool-1", + toolName: "execute", input: { cmd: "ls" }, }, ], @@ -1575,9 +1706,9 @@ describe("transformMessagesForCondensing", () => { it("should not mutate original messages", () => { const originalContent = [ { - type: "tool_use" as const, - id: "tool-1", - name: "read_file", + type: "tool-call" as const, + toolCallId: "tool-1", + toolName: "read_file", input: { path: "test.ts" }, }, ] @@ -1586,6 +1717,6 @@ describe("transformMessagesForCondensing", () => { transformMessagesForCondensing(messages) // Original should still have tool_use type - expect(messages[0].content[0].type).toBe("tool_use") + expect(messages[0].content[0].type).toBe("tool-call") }) }) diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index 0438bf6bcb1..b83f4848135 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -1,11 +1,17 @@ -import Anthropic from "@anthropic-ai/sdk" import crypto from "crypto" import { TelemetryService } from "@roo-code/telemetry" import { t } from "../../i18n" import { ApiHandler, ApiHandlerCreateMessageMetadata } from "../../api" -import { ApiMessage } from "../task-persistence/apiMessages" +import { + type ApiMessage, + type NeutralContentBlock, + type NeutralTextBlock, + type NeutralToolUseBlock, + type NeutralToolResultBlock, + type NeutralMessageParam, +} from "../task-persistence" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" @@ -18,10 +24,10 @@ export type { FoldedFileContextResult, FoldedFileContextOptions } from "./folded * Converts a tool_use block to a text representation. * This allows the conversation to be summarized without requiring the tools parameter. */ -export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam): string { - let input: string +export function toolUseToText(block: NeutralToolUseBlock): string { + let inputStr: string if (typeof block.input === "object" && block.input !== null) { - input = Object.entries(block.input) + inputStr = Object.entries(block.input as Record) .map(([key, value]) => { const formattedValue = typeof value === "object" && value !== null ? JSON.stringify(value, null, 2) : String(value) @@ -29,22 +35,23 @@ export function toolUseToText(block: Anthropic.Messages.ToolUseBlockParam): stri }) .join("\n") } else { - input = String(block.input) + inputStr = String(block.input) } - return `[Tool Use: ${block.name}]\n${input}` + return `[Tool Use: ${block.toolName}]\n${inputStr}` } /** * Converts a tool_result block to a text representation. * This allows the conversation to be summarized without requiring the tools parameter. */ -export function toolResultToText(block: Anthropic.Messages.ToolResultBlockParam): string { - const errorSuffix = block.is_error ? " (Error)" : "" - if (typeof block.content === "string") { - return `[Tool Result${errorSuffix}]\n${block.content}` - } else if (Array.isArray(block.content)) { - const contentText = block.content - .map((contentBlock) => { +export function toolResultToText(block: NeutralToolResultBlock): string { + const isError = block.output?.type === "error-text" || block.output?.type === "error-json" + const errorSuffix = isError ? " (Error)" : "" + if (block.output?.type === "text" || block.output?.type === "error-text") { + return `[Tool Result${errorSuffix}]\n${block.output.value}` + } else if (block.output?.type === "content") { + const contentText = (block.output.value as Array) + .map((contentBlock: any) => { if (contentBlock.type === "text") { return contentBlock.text } @@ -56,6 +63,8 @@ export function toolResultToText(block: Anthropic.Messages.ToolResultBlockParam) }) .join("\n") return `[Tool Result${errorSuffix}]\n${contentText}` + } else if (block.output?.type === "json" || block.output?.type === "error-json") { + return `[Tool Result${errorSuffix}]\n${JSON.stringify(block.output.value)}` } return `[Tool Result${errorSuffix}]` } @@ -68,21 +77,19 @@ export function toolResultToText(block: Anthropic.Messages.ToolResultBlockParam) * @param content - The message content (string or array of content blocks) * @returns The transformed content with tool blocks converted to text blocks */ -export function convertToolBlocksToText( - content: string | Anthropic.Messages.ContentBlockParam[], -): string | Anthropic.Messages.ContentBlockParam[] { +export function convertToolBlocksToText(content: string | NeutralContentBlock[]): string | NeutralContentBlock[] { if (typeof content === "string") { return content } return content.map((block) => { - if (block.type === "tool_use") { + if (block.type === "tool-call") { return { type: "text" as const, text: toolUseToText(block), } } - if (block.type === "tool_result") { + if (block.type === "tool-result") { return { type: "text" as const, text: toolResultToText(block), @@ -99,9 +106,9 @@ export function convertToolBlocksToText( * @param messages - The messages to transform * @returns The transformed messages with tool blocks converted to text */ -export function transformMessagesForCondensing< - T extends { role: string; content: string | Anthropic.Messages.ContentBlockParam[] }, ->(messages: T[]): T[] { +export function transformMessagesForCondensing( + messages: T[], +): T[] { return messages.map((msg) => ({ ...msg, content: convertToolBlocksToText(msg.content), @@ -140,15 +147,15 @@ export function injectSyntheticToolResults(messages: ApiMessage[]): ApiMessage[] for (const msg of messages) { if (msg.role === "assistant" && Array.isArray(msg.content)) { for (const block of msg.content) { - if (block.type === "tool_use") { - toolCallIds.add(block.id) + if (block.type === "tool-call") { + toolCallIds.add(block.toolCallId) } } } if (msg.role === "user" && Array.isArray(msg.content)) { for (const block of msg.content) { - if (block.type === "tool_result") { - toolResultIds.add(block.tool_use_id) + if (block.type === "tool-result") { + toolResultIds.add(block.toolCallId) } } } @@ -162,10 +169,11 @@ export function injectSyntheticToolResults(messages: ApiMessage[]): ApiMessage[] } // Inject synthetic tool_results as a new user message - const syntheticResults: Anthropic.Messages.ToolResultBlockParam[] = orphanIds.map((id) => ({ - type: "tool_result" as const, - tool_use_id: id, - content: "Context condensation triggered. Tool execution deferred.", + const syntheticResults: NeutralToolResultBlock[] = orphanIds.map((id) => ({ + type: "tool-result" as const, + toolCallId: id, + toolName: "", + output: { type: "text" as const, value: "Context condensation triggered. Tool execution deferred." }, })) const syntheticMessage: ApiMessage = { @@ -193,7 +201,7 @@ export function extractCommandBlocks(message: ApiMessage): string { } else if (Array.isArray(content)) { // Concatenate all text blocks text = content - .filter((block): block is Anthropic.Messages.TextBlockParam => block.type === "text") + .filter((block): block is NeutralTextBlock => block.type === "text") .map((block) => block.text) .join("\n") } else { @@ -298,7 +306,7 @@ export async function summarizeConversation(options: SummarizeConversationOption // This respects user's custom condensing prompt setting const condenseInstructions = customCondensingPrompt?.trim() || supportPrompt.default.CONDENSE - const finalRequestMessage: Anthropic.MessageParam = { + const finalRequestMessage: NeutralMessageParam = { role: "user", content: condenseInstructions, } @@ -398,9 +406,7 @@ export async function summarizeConversation(options: SummarizeConversationOption const commandBlocks = firstMessage ? extractCommandBlocks(firstMessage) : "" // Build the summary content as separate text blocks - const summaryContent: Anthropic.Messages.ContentBlockParam[] = [ - { type: "text", text: `## Conversation Summary\n${summary}` }, - ] + const summaryContent: NeutralContentBlock[] = [{ type: "text", text: `## Conversation Summary\n${summary}` }] // Add command blocks (active workflows) in their own system-reminder block if present if (commandBlocks) { @@ -559,8 +565,8 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { for (const msg of messagesFromSummary) { if (msg.role === "assistant" && Array.isArray(msg.content)) { for (const block of msg.content) { - if (block.type === "tool_use" && (block as Anthropic.Messages.ToolUseBlockParam).id) { - toolUseIds.add((block as Anthropic.Messages.ToolUseBlockParam).id) + if (block.type === "tool-call" && (block as NeutralToolUseBlock).toolCallId) { + toolUseIds.add((block as NeutralToolUseBlock).toolCallId) } } } @@ -571,8 +577,8 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] { .map((msg) => { if (msg.role === "user" && Array.isArray(msg.content)) { const filteredContent = msg.content.filter((block) => { - if (block.type === "tool_result") { - return toolUseIds.has((block as Anthropic.Messages.ToolResultBlockParam).tool_use_id) + if (block.type === "tool-result") { + return toolUseIds.has((block as NeutralToolResultBlock).toolCallId) } return true }) diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts index 9950ec536b3..4e65f606ce7 100644 --- a/src/core/context-management/__tests__/context-management.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -1,7 +1,6 @@ // cd src && npx vitest run core/context-management/__tests__/context-management.spec.ts -import { Anthropic } from "@anthropic-ai/sdk" - +import type { RooContentBlock } from "../../task-persistence/apiMessages" import type { ModelInfo } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -185,9 +184,7 @@ describe("Context Management", () => { }) it("should estimate tokens for text blocks", async () => { - const content: Array = [ - { type: "text", text: "This is a text block with 36 characters" }, - ] + const content: Array = [{ type: "text", text: "This is a text block with 36 characters" }] // With tiktoken, the exact token count may differ from character-based estimation // Instead of expecting an exact number, we verify it's a reasonable positive number @@ -195,7 +192,7 @@ describe("Context Management", () => { expect(result).toBeGreaterThan(0) // We can also verify that longer text results in more tokens - const longerContent: Array = [ + const longerContent: Array = [ { type: "text", text: "This is a longer text block with significantly more characters to encode into tokens", @@ -207,12 +204,12 @@ describe("Context Management", () => { it("should estimate tokens for image blocks based on data size", async () => { // Small image - const smallImage: Array = [ - { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "small_dummy_data" } }, + const smallImage: Array = [ + { type: "image", image: "small_dummy_data", mediaType: "image/jpeg" }, ] // Larger image with more data - const largerImage: Array = [ - { type: "image", source: { type: "base64", media_type: "image/png", data: "X".repeat(1000) } }, + const largerImage: Array = [ + { type: "image", image: "X".repeat(1000), mediaType: "image/png" }, ] // Verify the token count scales with the size of the image data @@ -230,9 +227,9 @@ describe("Context Management", () => { }) it("should estimate tokens for mixed content blocks", async () => { - const content: Array = [ + const content: Array = [ { type: "text", text: "A text block with 30 characters" }, - { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "dummy_data" } }, + { type: "image", image: "dummy_data", mediaType: "image/jpeg" }, { type: "text", text: "Another text with 24 chars" }, ] @@ -245,15 +242,15 @@ describe("Context Management", () => { expect(result).toBeGreaterThan(imageTokens) // Also test against a version with only the image to verify text adds tokens - const imageOnlyContent: Array = [ - { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "dummy_data" } }, + const imageOnlyContent: Array = [ + { type: "image", image: "dummy_data", mediaType: "image/jpeg" }, ] const imageOnlyResult = await estimateTokenCount(imageOnlyContent, mockApiHandler) expect(result).toBeGreaterThan(imageOnlyResult) }) it("should handle empty text blocks", async () => { - const content: Array = [{ type: "text", text: "" }] + const content: Array = [{ type: "text", text: "" }] expect(await estimateTokenCount(content, mockApiHandler)).toBe(0) }) diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index 243d7bd797f..64f611e4e2b 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -1,11 +1,10 @@ -import { Anthropic } from "@anthropic-ai/sdk" import crypto from "crypto" import { TelemetryService } from "@roo-code/telemetry" import { ApiHandler, ApiHandlerCreateMessageMetadata } from "../../api" import { MAX_CONDENSE_THRESHOLD, MIN_CONDENSE_THRESHOLD, summarizeConversation, SummarizeResponse } from "../condense" -import { ApiMessage } from "../task-persistence/apiMessages" +import { type ApiMessage, type NeutralContentBlock } from "../task-persistence" import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" import { RooIgnoreController } from "../ignore/RooIgnoreController" @@ -32,10 +31,7 @@ export const TOKEN_BUFFER_PERCENTAGE = 0.1 * @param {ApiHandler} apiHandler - The API handler to use for token counting * @returns {Promise} A promise resolving to the token count */ -export async function estimateTokenCount( - content: Array, - apiHandler: ApiHandler, -): Promise { +export async function estimateTokenCount(content: Array, apiHandler: ApiHandler): Promise { if (!content || content.length === 0) return 0 return apiHandler.countTokens(content) } diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts index f43bbee0dc9..12bd9d40700 100644 --- a/src/core/diff/strategies/multi-search-replace.ts +++ b/src/core/diff/strategies/multi-search-replace.ts @@ -521,7 +521,7 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy { } getProgressStatus(toolUse: ToolUse, result?: DiffResult): ToolProgressStatus { - const diffContent = toolUse.params.diff + const diffContent = toolUse.input.diff if (diffContent) { const icon = "diff-multiple" if (toolUse.partial) { diff --git a/src/core/mentions/__tests__/processUserContentMentions.spec.ts b/src/core/mentions/__tests__/processUserContentMentions.spec.ts index 7732cf279b4..d3131d1ab5e 100644 --- a/src/core/mentions/__tests__/processUserContentMentions.spec.ts +++ b/src/core/mentions/__tests__/processUserContentMentions.spec.ts @@ -77,9 +77,10 @@ describe("processUserContentMentions", () => { it("should process tool_result blocks with string content", async () => { const userContent = [ { - type: "tool_result" as const, - tool_use_id: "123", - content: "Tool feedback", + type: "tool-result" as const, + toolCallId: "123", + toolName: "", + output: { type: "text" as const, value: "Tool feedback" }, }, ] @@ -93,14 +94,18 @@ describe("processUserContentMentions", () => { expect(parseMentions).toHaveBeenCalled() // String content is now converted to array format to support content blocks expect(result.content[0]).toEqual({ - type: "tool_result", - tool_use_id: "123", - content: [ - { - type: "text", - text: "parsed: Tool feedback", - }, - ], + type: "tool-result", + toolCallId: "123", + toolName: "", + output: { + type: "content", + value: [ + { + type: "text", + text: "parsed: Tool feedback", + }, + ], + }, }) expect(result.mode).toBeUndefined() }) @@ -108,18 +113,22 @@ describe("processUserContentMentions", () => { it("should process tool_result blocks with array content", async () => { const userContent = [ { - type: "tool_result" as const, - tool_use_id: "123", - content: [ - { - type: "text" as const, - text: "Array task", - }, - { - type: "text" as const, - text: "Regular text", - }, - ], + type: "tool-result" as const, + toolCallId: "123", + toolName: "", + output: { + type: "content" as const, + value: [ + { + type: "text" as const, + text: "Array task", + }, + { + type: "text" as const, + text: "Regular text", + }, + ], + }, }, ] @@ -132,18 +141,22 @@ describe("processUserContentMentions", () => { expect(parseMentions).toHaveBeenCalledTimes(1) expect(result.content[0]).toEqual({ - type: "tool_result", - tool_use_id: "123", - content: [ - { - type: "text", - text: "parsed: Array task", - }, - { - type: "text", - text: "Regular text", - }, - ], + type: "tool-result", + toolCallId: "123", + toolName: "", + output: { + type: "content", + value: [ + { + type: "text", + text: "parsed: Array task", + }, + { + type: "text", + text: "Regular text", + }, + ], + }, }) expect(result.mode).toBeUndefined() }) @@ -156,16 +169,14 @@ describe("processUserContentMentions", () => { }, { type: "image" as const, - source: { - type: "base64" as const, - media_type: "image/png" as const, - data: "base64data", - }, + image: "base64data", + mediaType: "image/png", }, { - type: "tool_result" as const, - tool_use_id: "456", - content: "Feedback", + type: "tool-result" as const, + toolCallId: "456", + toolName: "", + output: { type: "text" as const, value: "Feedback" }, }, ] @@ -185,14 +196,18 @@ describe("processUserContentMentions", () => { expect(result.content[1]).toEqual(userContent[1]) // Image block unchanged // String content is now converted to array format to support content blocks expect(result.content[2]).toEqual({ - type: "tool_result", - tool_use_id: "456", - content: [ - { - type: "text", - text: "parsed: Feedback", - }, - ], + type: "tool-result", + toolCallId: "456", + toolName: "", + output: { + type: "content", + value: [ + { + type: "text", + text: "parsed: Feedback", + }, + ], + }, }) expect(result.mode).toBeUndefined() }) @@ -299,9 +314,10 @@ describe("processUserContentMentions", () => { const userContent = [ { - type: "tool_result" as const, - tool_use_id: "123", - content: "Tool output", + type: "tool-result" as const, + toolCallId: "123", + toolName: "", + output: { type: "text" as const, value: "Tool output" }, }, ] @@ -314,18 +330,22 @@ describe("processUserContentMentions", () => { expect(result.content).toHaveLength(1) expect(result.content[0]).toEqual({ - type: "tool_result", - tool_use_id: "123", - content: [ - { - type: "text", - text: "parsed tool output", - }, - { - type: "text", - text: "command help", - }, - ], + type: "tool-result", + toolCallId: "123", + toolName: "", + output: { + type: "content", + value: [ + { + type: "text", + text: "parsed tool output", + }, + { + type: "text", + text: "command help", + }, + ], + }, }) }) @@ -339,14 +359,18 @@ describe("processUserContentMentions", () => { const userContent = [ { - type: "tool_result" as const, - tool_use_id: "123", - content: [ - { - type: "text" as const, - text: "Array item", - }, - ], + type: "tool-result" as const, + toolCallId: "123", + toolName: "", + output: { + type: "content" as const, + value: [ + { + type: "text" as const, + text: "Array item", + }, + ], + }, }, ] @@ -359,18 +383,22 @@ describe("processUserContentMentions", () => { expect(result.content).toHaveLength(1) expect(result.content[0]).toEqual({ - type: "tool_result", - tool_use_id: "123", - content: [ - { - type: "text", - text: "parsed array item", - }, - { - type: "text", - text: "command help", - }, - ], + type: "tool-result", + toolCallId: "123", + toolName: "", + output: { + type: "content", + value: [ + { + type: "text", + text: "parsed array item", + }, + { + type: "text", + text: "command help", + }, + ], + }, }) }) }) diff --git a/src/core/mentions/processUserContentMentions.ts b/src/core/mentions/processUserContentMentions.ts index d27f2cae66a..189623d69c8 100644 --- a/src/core/mentions/processUserContentMentions.ts +++ b/src/core/mentions/processUserContentMentions.ts @@ -1,10 +1,10 @@ -import { Anthropic } from "@anthropic-ai/sdk" import { parseMentions, ParseMentionsResult, MentionContentBlock } from "./index" import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" import { FileContextTracker } from "../context-tracking/FileContextTracker" +import type { NeutralContentBlock, NeutralTextBlock } from "../task-persistence" export interface ProcessUserContentMentionsResult { - content: Anthropic.Messages.ContentBlockParam[] + content: NeutralContentBlock[] mode?: string // Mode from the first slash command that has one } @@ -13,7 +13,7 @@ export interface ProcessUserContentMentionsResult { * Each file/folder mention becomes a separate text block formatted * to look like a read_file tool result. */ -function contentBlocksToAnthropicBlocks(contentBlocks: MentionContentBlock[]): Anthropic.Messages.TextBlockParam[] { +function contentBlocksToAnthropicBlocks(contentBlocks: MentionContentBlock[]): NeutralTextBlock[] { return contentBlocks.map((block) => ({ type: "text" as const, text: block.content, @@ -37,7 +37,7 @@ export async function processUserContentMentions({ includeDiagnosticMessages = true, maxDiagnosticMessages = 50, }: { - userContent: Anthropic.Messages.ContentBlockParam[] + userContent: NeutralContentBlock[] cwd: string urlContentFetcher: UrlContentFetcher fileContextTracker: FileContextTracker @@ -82,7 +82,7 @@ export async function processUserContentMentions({ // 1. User's text (with @ mentions replaced by clean paths) // 2. File/folder content blocks (formatted like read_file results) // 3. Slash command help (if any) - const blocks: Anthropic.Messages.ContentBlockParam[] = [ + const blocks: NeutralContentBlock[] = [ { ...block, text: result.text, @@ -104,11 +104,11 @@ export async function processUserContentMentions({ } return block - } else if (block.type === "tool_result") { - if (typeof block.content === "string") { - if (shouldProcessMentions(block.content)) { + } else if (block.type === "tool-result") { + if (block.output?.type === "text") { + if (shouldProcessMentions(block.output.value)) { const result = await parseMentions( - block.content, + block.output.value, cwd, urlContentFetcher, fileContextTracker, @@ -123,7 +123,7 @@ export async function processUserContentMentions({ } // Build content array with file blocks included - const contentParts: Array<{ type: "text"; text: string }> = [ + const outputParts: Array<{ type: "text"; text: string }> = [ { type: "text" as const, text: result.text, @@ -132,14 +132,14 @@ export async function processUserContentMentions({ // Add file/folder content blocks for (const contentBlock of result.contentBlocks) { - contentParts.push({ + outputParts.push({ type: "text" as const, text: contentBlock.content, }) } if (result.slashCommandHelp) { - contentParts.push({ + outputParts.push({ type: "text" as const, text: result.slashCommandHelp, }) @@ -147,15 +147,15 @@ export async function processUserContentMentions({ return { ...block, - content: contentParts, + output: { type: "content" as const, value: outputParts }, } } return block - } else if (Array.isArray(block.content)) { + } else if (block.output?.type === "content") { const parsedContent = ( await Promise.all( - block.content.map(async (contentBlock) => { + (block.output.value as Array).map(async (contentBlock: any) => { if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) { const result = await parseMentions( contentBlock.text, @@ -202,7 +202,7 @@ export async function processUserContentMentions({ ) ).flat() - return { ...block, content: parsedContent } + return { ...block, output: { type: "content" as const, value: parsedContent } } } return block diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 60b5b4123ac..971fcb8adb7 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -1,8 +1,8 @@ -import { Anthropic } from "@anthropic-ai/sdk" import * as path from "path" import * as diff from "diff" import { RooIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" +import type { NeutralTextBlock, NeutralImageBlock } from "../task-persistence" export const formatResponse = { toolDenied: () => @@ -96,13 +96,10 @@ Otherwise, if you have not completed the task and do not need additional informa available_servers: availableServers.length > 0 ? availableServers : [], }), - toolResult: ( - text: string, - images?: string[], - ): string | Array => { + toolResult: (text: string, images?: string[]): string | Array => { if (images && images.length > 0) { - const textBlock: Anthropic.TextBlockParam = { type: "text", text } - const imageBlocks: Anthropic.ImageBlockParam[] = formatImagesIntoBlocks(images) + const textBlock: NeutralTextBlock = { type: "text", text } + const imageBlocks: NeutralImageBlock[] = formatImagesIntoBlocks(images) // Placing images after text leads to better results return [textBlock, ...imageBlocks] } else { @@ -110,7 +107,7 @@ Otherwise, if you have not completed the task and do not need additional informa } }, - imageBlocks: (images?: string[]): Anthropic.ImageBlockParam[] => { + imageBlocks: (images?: string[]): NeutralImageBlock[] => { return formatImagesIntoBlocks(images) }, @@ -202,7 +199,7 @@ Otherwise, if you have not completed the task and do not need additional informa } // to avoid circular dependency -const formatImagesIntoBlocks = (images?: string[]): Anthropic.ImageBlockParam[] => { +const formatImagesIntoBlocks = (images?: string[]): NeutralImageBlock[] => { return images ? images.map((dataUrl) => { // data:image/png;base64,base64string @@ -210,8 +207,9 @@ const formatImagesIntoBlocks = (images?: string[]): Anthropic.ImageBlockParam[] const mimeType = rest.split(":")[1].split(";")[0] return { type: "image", - source: { type: "base64", media_type: mimeType, data: base64 }, - } as Anthropic.ImageBlockParam + image: base64, + mediaType: mimeType, + } as NeutralImageBlock }) : [] } diff --git a/src/core/prompts/tools/native-tools/__tests__/converters.spec.ts b/src/core/prompts/tools/native-tools/__tests__/converters.spec.ts index dfef164659b..f46f2263eb7 100644 --- a/src/core/prompts/tools/native-tools/__tests__/converters.spec.ts +++ b/src/core/prompts/tools/native-tools/__tests__/converters.spec.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from "vitest" import type OpenAI from "openai" -import type Anthropic from "@anthropic-ai/sdk" import { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic, diff --git a/src/core/prompts/tools/native-tools/converters.ts b/src/core/prompts/tools/native-tools/converters.ts index 2496c81c804..a65352798e6 100644 --- a/src/core/prompts/tools/native-tools/converters.ts +++ b/src/core/prompts/tools/native-tools/converters.ts @@ -1,5 +1,25 @@ import type OpenAI from "openai" -import type Anthropic from "@anthropic-ai/sdk" + +/** + * Inline Anthropic-compatible types to avoid leaking `@anthropic-ai/sdk` + * into shared code. These are structurally identical to the SDK types. + */ +type AnthropicToolInputSchema = { + type: "object" + properties?: Record + [key: string]: unknown +} + +type AnthropicTool = { + name: string + description?: string + input_schema: AnthropicToolInputSchema +} + +type AnthropicToolChoice = + | { type: "auto"; disable_parallel_tool_use?: boolean } + | { type: "any"; disable_parallel_tool_use?: boolean } + | { type: "tool"; name: string; disable_parallel_tool_use?: boolean } /** * Converts an OpenAI ChatCompletionTool to Anthropic's Tool format. @@ -25,7 +45,7 @@ import type Anthropic from "@anthropic-ai/sdk" * // Returns: { name: "get_weather", description: "Get weather", input_schema: {...} } * ``` */ -export function convertOpenAIToolToAnthropic(tool: OpenAI.Chat.ChatCompletionTool): Anthropic.Tool { +export function convertOpenAIToolToAnthropic(tool: OpenAI.Chat.ChatCompletionTool): AnthropicTool { // Handle both ChatCompletionFunctionTool and ChatCompletionCustomTool if (tool.type !== "function") { throw new Error(`Unsupported tool type: ${tool.type}`) @@ -34,7 +54,7 @@ export function convertOpenAIToolToAnthropic(tool: OpenAI.Chat.ChatCompletionToo return { name: tool.function.name, description: tool.function.description || "", - input_schema: tool.function.parameters as Anthropic.Tool.InputSchema, + input_schema: tool.function.parameters as AnthropicToolInputSchema, } } @@ -44,7 +64,7 @@ export function convertOpenAIToolToAnthropic(tool: OpenAI.Chat.ChatCompletionToo * @param tools - Array of OpenAI ChatCompletionTools to convert * @returns Array of Anthropic Tool definitions */ -export function convertOpenAIToolsToAnthropic(tools: OpenAI.Chat.ChatCompletionTool[]): Anthropic.Tool[] { +export function convertOpenAIToolsToAnthropic(tools: OpenAI.Chat.ChatCompletionTool[]): AnthropicTool[] { return tools.map(convertOpenAIToolToAnthropic) } @@ -73,7 +93,7 @@ export function convertOpenAIToolsToAnthropic(tools: OpenAI.Chat.ChatCompletionT export function convertOpenAIToolChoiceToAnthropic( toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"], parallelToolCalls?: boolean, -): Anthropic.Messages.MessageCreateParams["tool_choice"] | undefined { +): AnthropicToolChoice | undefined { // Parallel tool calls are enabled by default. When parallelToolCalls is explicitly false, // we disable parallel tool use to ensure one tool call at a time. const disableParallelToolUse = parallelToolCalls === false diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 7672f6f7ee6..73db5a2f465 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -2,14 +2,63 @@ import { safeWriteJson } from "../../utils/safeWriteJson" import * as path from "path" import * as fs from "fs/promises" -import { Anthropic } from "@anthropic-ai/sdk" - import { fileExistsAtPath } from "../../utils/fs" import { GlobalFileNames } from "../../shared/globalFileNames" import { getTaskDirectoryPath } from "../../utils/storage" -export type ApiMessage = Anthropic.MessageParam & { +import type { TextPart, ImagePart, FilePart, ToolCallPart, ToolResultPart } from "ai" + +// --------------------------------------------------------------------------- +// AI SDK content part types (re-exported from "ai" package) +// Plus custom extensions for types not covered by the AI SDK. +// --------------------------------------------------------------------------- + +/** + * Reasoning content part — matches AI SDK's internal ReasoningPart from @ai-sdk/provider-utils. + * Defined locally because the `ai` package does not re-export it. + */ +export interface ReasoningPart { + type: "reasoning" + text: string + providerOptions?: Record> +} + +/** + * Custom type for Anthropic's redacted thinking blocks — no AI SDK equivalent. + */ +export interface RedactedReasoningPart { + type: "redacted_thinking" + data: string +} + +/** + * Union of all content block types used in Roo messages. + * Uses AI SDK standard types + our custom extension for redacted thinking. + */ +export type RooContentBlock = + | TextPart + | ImagePart + | FilePart + | ReasoningPart + | ToolCallPart + | ToolResultPart + | RedactedReasoningPart + +// --------------------------------------------------------------------------- +// Roo message param — the provider-agnostic message format +// --------------------------------------------------------------------------- + +export interface RooMessageParam { + role: "user" | "assistant" + content: string | RooContentBlock[] +} + +// --------------------------------------------------------------------------- +// Roo-specific metadata carried on every API conversation message +// --------------------------------------------------------------------------- + +export interface RooMessageMetadata { ts?: number isSummary?: boolean id?: string @@ -37,6 +86,213 @@ export type ApiMessage = Anthropic.MessageParam & { isTruncationMarker?: boolean } +// --------------------------------------------------------------------------- +// ApiMessage — the primary persistence type for conversation history +// --------------------------------------------------------------------------- + +export type ApiMessage = RooMessageParam & RooMessageMetadata + +// --------------------------------------------------------------------------- +// Backward-compatible aliases — these will be removed once all consumers are migrated +// --------------------------------------------------------------------------- + +/** @deprecated Use TextPart from "ai" */ +export type NeutralTextBlock = TextPart +/** @deprecated Use ToolCallPart from "ai" */ +export type NeutralToolUseBlock = ToolCallPart +/** @deprecated Use ToolResultPart from "ai" */ +export type NeutralToolResultBlock = ToolResultPart +/** @deprecated Use ImagePart from "ai" */ +export type NeutralImageBlock = ImagePart +/** @deprecated Use ReasoningPart from "ai" */ +export type NeutralThinkingBlock = ReasoningPart +/** @deprecated Use RedactedReasoningPart */ +export type NeutralRedactedThinkingBlock = RedactedReasoningPart +/** @deprecated Use RooContentBlock */ +export type NeutralContentBlock = RooContentBlock +/** @deprecated Use RooMessageParam */ +export type NeutralMessageParam = RooMessageParam + +// --------------------------------------------------------------------------- +// Migration: old "Neutral" format → AI SDK format +// --------------------------------------------------------------------------- + +/** + * Migrate a single content block from old Neutral format to AI SDK format. + * Blocks already in the new format pass through unchanged (idempotent). + * + * Old formats detected by `type` field: + * - "tool_use" → "tool-call" + * - "tool_result" → "tool-result" + * - "thinking" → "reasoning" + * - "image" with `source.type === "base64"` → "image" with `image` + `mediaType` + * - "document" with `source` → "file" with `data` + `mediaType` + */ +function migrateContentBlock(block: Record): Record { + if (block == null || typeof block !== "object") { + return block + } + + const type = block.type as string | undefined + + switch (type) { + // ----- tool_use → tool-call ----- + case "tool_use": + return { + type: "tool-call" as const, + toolCallId: block.id ?? "", + toolName: block.name ?? "", + input: block.input ?? {}, + } + + // ----- tool_result → tool-result ----- + case "tool_result": + return { + type: "tool-result" as const, + toolCallId: block.tool_use_id ?? "", + toolName: "", + output: convertToolResultContent(block.content), + ...(block.is_error != null ? { isError: Boolean(block.is_error) } : {}), + } + + // ----- thinking → reasoning ----- + case "thinking": + return { + type: "reasoning" as const, + text: (block.thinking as string) ?? "", + ...(block.signature != null + ? { + providerOptions: { + anthropic: { thinkingSignature: block.signature }, + }, + } + : {}), + } + + // ----- image with old base64 source → image with data + mediaType ----- + case "image": { + const source = block.source as Record | undefined + if (source && source.type === "base64" && source.data != null) { + return { + type: "image" as const, + image: source.data, + mediaType: source.media_type ?? "image/png", + } + } + // Already in new format or unknown shape — pass through + return block + } + + // ----- document → file ----- + case "document": { + const docSource = block.source as Record | undefined + if (docSource && docSource.data != null) { + return { + type: "file" as const, + data: docSource.data, + mediaType: docSource.media_type ?? "application/pdf", + } + } + return block + } + + // Already new format or unrecognized — pass through unchanged + default: + return block + } +} + +/** + * Convert old tool_result `content` field to new `output` discriminated union. + * + * - string → `{ type: "text", value: theString }` + * - array → `{ type: "content", value: migratedArray }` + * - null/undefined → `{ type: "text", value: "" }` + */ +function convertToolResultContent( + content: unknown, +): { type: "text"; value: string } | { type: "content"; value: unknown[] } { + if (content == null) { + return { type: "text" as const, value: "" } + } + if (typeof content === "string") { + return { type: "text" as const, value: content } + } + if (Array.isArray(content)) { + return { + type: "content" as const, + value: content.map((item: unknown) => + typeof item === "object" && item !== null ? migrateContentBlock(item as Record) : item, + ), + } + } + // Unexpected shape — wrap as JSON text + return { type: "text" as const, value: typeof content === "string" ? content : JSON.stringify(content) } +} + +/** + * Migrate an array of ApiMessages from old "Neutral" format to AI SDK format. + * + * **Idempotent**: if the data is already in the new format it passes through + * unchanged. Detection is based on the `type` field of each content block + * (e.g. `"tool_use"` = old, `"tool-call"` = new). + * + * This is automatically called by `readApiMessages()` when loading from disk. + */ +export function migrateApiMessages(messages: ApiMessage[]): ApiMessage[] { + return messages.map((msg) => { + // String content doesn't need migration + if (typeof msg.content === "string" || !Array.isArray(msg.content)) { + return msg + } + + // Check if any block needs migration by looking for old-format type values + const hasOldFormat = msg.content.some((block: unknown) => { + if (block == null || typeof block !== "object") return false + const t = (block as Record).type + return ( + t === "tool_use" || + t === "tool_result" || + t === "thinking" || + isOldImageBlock(block) || + isOldDocumentBlock(block) + ) + }) + + if (!hasOldFormat) { + return msg + } + + return { + ...msg, + content: msg.content.map((block: unknown) => + typeof block === "object" && block !== null + ? (migrateContentBlock(block as Record) as unknown as RooContentBlock) + : block, + ) as RooContentBlock[], + } + }) +} + +/** Detect old-format image block: has `source.type === "base64"` instead of `image` field */ +function isOldImageBlock(block: unknown): boolean { + if (block == null || typeof block !== "object") return false + const b = block as Record + if (b.type !== "image") return false + const source = b.source as Record | undefined + return source != null && source.type === "base64" +} + +/** Detect old-format document block: type === "document" (new format is "file") */ +function isOldDocumentBlock(block: unknown): boolean { + if (block == null || typeof block !== "object") return false + return (block as Record).type === "document" +} + +// --------------------------------------------------------------------------- +// Read / Write +// --------------------------------------------------------------------------- + export async function readApiMessages({ taskId, globalStoragePath, @@ -62,7 +318,7 @@ export async function readApiMessages({ `[Roo-Debug] readApiMessages: Found API conversation history file, but it's empty (parsed as []). TaskId: ${taskId}, Path: ${filePath}`, ) } - return parsedData + return migrateApiMessages(parsedData) } catch (error) { console.warn( `[readApiMessages] Error parsing API conversation history file, returning empty. TaskId: ${taskId}, Path: ${filePath}, Error: ${error}`, @@ -88,7 +344,7 @@ export async function readApiMessages({ ) } await fs.unlink(oldPath) - return parsedData + return migrateApiMessages(parsedData) } catch (error) { console.warn( `[readApiMessages] Error parsing OLD API conversation history file (claude_messages.json), returning empty. TaskId: ${taskId}, Path: ${oldPath}, Error: ${error}`, diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index c8656002bde..15ede8e1c42 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -1,3 +1,21 @@ -export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages" +export { + type ApiMessage, + type ReasoningPart, + type RedactedReasoningPart, + type RooContentBlock, + type RooMessageParam, + type RooMessageMetadata, + // Backward-compatible aliases (deprecated) + type NeutralTextBlock, + type NeutralImageBlock, + type NeutralToolUseBlock, + type NeutralToolResultBlock, + type NeutralThinkingBlock, + type NeutralRedactedThinkingBlock, + type NeutralContentBlock, + type NeutralMessageParam, + readApiMessages, + saveApiMessages, +} from "./apiMessages" export { readTaskMessages, saveTaskMessages } from "./taskMessages" export { taskMetadata } from "./taskMetadata" diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index caea2e9e090..50d497f0ab4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -7,7 +7,6 @@ import EventEmitter from "events" import { AskIgnoredError } from "./AskIgnoredError" -import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import debounce from "lodash.debounce" import delay from "delay" @@ -111,6 +110,12 @@ import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { type ApiMessage, + type NeutralTextBlock, + type NeutralImageBlock, + type NeutralToolUseBlock, + type NeutralToolResultBlock, + type NeutralContentBlock, + type NeutralMessageParam, readApiMessages, saveApiMessages, readTaskMessages, @@ -353,7 +358,7 @@ export class Task extends EventEmitter implements TaskLike { assistantMessageContent: AssistantMessageContent[] = [] presentAssistantMessageLocked = false presentAssistantMessageHasPendingUpdates = false - userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = [] + userMessageContent: (NeutralTextBlock | NeutralImageBlock | NeutralToolResultBlock)[] = [] userMessageContentReady = false /** @@ -377,14 +382,14 @@ export class Task extends EventEmitter implements TaskLike { * @param toolResult - The tool_result block to add * @returns true if added, false if duplicate was skipped */ - public pushToolResultToUserContent(toolResult: Anthropic.ToolResultBlockParam): boolean { + public pushToolResultToUserContent(toolResult: NeutralToolResultBlock): boolean { const existingResult = this.userMessageContent.find( - (block): block is Anthropic.ToolResultBlockParam => - block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + (block): block is NeutralToolResultBlock => + block.type === "tool-result" && block.toolCallId === toolResult.toolCallId, ) if (existingResult) { console.warn( - `[Task#pushToolResultToUserContent] Skipping duplicate tool_result for tool_use_id: ${toolResult.tool_use_id}`, + `[Task#pushToolResultToUserContent] Skipping duplicate tool_result for toolCallId: ${toolResult.toolCallId}`, ) return false } @@ -434,15 +439,13 @@ export class Task extends EventEmitter implements TaskLike { // Create initial partial tool use const partialToolUse: ToolUse = { - type: "tool_use", - name: event.name as ToolName, - params: {}, + type: "tool-call", + toolCallId: event.id, + toolName: event.name as ToolName, + input: {}, partial: true, } - // Store the ID for native protocol - ;(partialToolUse as any).id = event.id - // Add to content and present this.assistantMessageContent.push(partialToolUse) this.userMessageContentReady = false @@ -455,9 +458,6 @@ export class Task extends EventEmitter implements TaskLike { // Get the index for this tool call const toolUseIndex = this.streamingToolCallIndices.get(event.id) if (toolUseIndex !== undefined) { - // Store the ID for native protocol - ;(partialToolUse as any).id = event.id - // Update the existing tool use with new partial data this.assistantMessageContent[toolUseIndex] = partialToolUse @@ -473,9 +473,6 @@ export class Task extends EventEmitter implements TaskLike { const toolUseIndex = this.streamingToolCallIndices.get(event.id) if (finalToolUse) { - // Store the tool call ID - ;(finalToolUse as any).id = event.id - // Get the index and replace partial with final if (toolUseIndex !== undefined) { this.assistantMessageContent[toolUseIndex] = finalToolUse @@ -494,10 +491,9 @@ export class Task extends EventEmitter implements TaskLike { // Mark the tool as non-partial so it's presented as complete, but execution // will be short-circuited in presentAssistantMessage with a structured tool_result. const existingToolUse = this.assistantMessageContent[toolUseIndex] - if (existingToolUse && existingToolUse.type === "tool_use") { + if (existingToolUse && existingToolUse.type === "tool-call") { existingToolUse.partial = false - // Ensure it has the ID for native protocol - ;(existingToolUse as any).id = event.id + existingToolUse.toolCallId = event.id } // Clean up tracking @@ -1015,7 +1011,7 @@ export class Task extends EventEmitter implements TaskLike { return readApiMessages({ taskId: this.taskId, globalStoragePath: this.globalStoragePath }) } - private async addToApiConversationHistory(message: Anthropic.MessageParam, reasoning?: string) { + private async addToApiConversationHistory(message: NeutralMessageParam, reasoning?: string) { // Capture the encrypted_content / thought signatures from the provider (e.g., OpenAI Responses API, Google GenAI) if present. // We only persist data reported by the current response body. const handler = this.api as ApiHandler & { @@ -1060,19 +1056,19 @@ export class Task extends EventEmitter implements TaskLike { // Store reasoning: Anthropic thinking (with signature), plain text (most providers), or encrypted (OpenAI Native) // Skip if reasoning_details already contains the reasoning (to avoid duplication) if (isAnthropicProtocol && reasoning && thoughtSignature && !reasoningDetails) { - // Anthropic provider with extended thinking: Store as proper `thinking` block + // Anthropic provider with extended thinking: Store as proper `reasoning` block // This format passes through anthropic-filter.ts and is properly round-tripped // for interleaved thinking with tool use (required by Anthropic API) const thinkingBlock = { - type: "thinking", - thinking: reasoning, - signature: thoughtSignature, + type: "reasoning", + text: reasoning, + providerOptions: { anthropic: { thinking: reasoning, thinkingSignature: thoughtSignature } }, } if (typeof messageWithTs.content === "string") { messageWithTs.content = [ thinkingBlock, - { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, + { type: "text", text: messageWithTs.content } satisfies NeutralTextBlock, ] } else if (Array.isArray(messageWithTs.content)) { messageWithTs.content = [thinkingBlock, ...messageWithTs.content] @@ -1099,7 +1095,7 @@ export class Task extends EventEmitter implements TaskLike { if (typeof messageWithTs.content === "string") { messageWithTs.content = [ reasoningBlock, - { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, + { type: "text", text: messageWithTs.content } satisfies NeutralTextBlock, ] } else if (Array.isArray(messageWithTs.content)) { messageWithTs.content = [reasoningBlock, ...messageWithTs.content] @@ -1118,7 +1114,7 @@ export class Task extends EventEmitter implements TaskLike { if (typeof messageWithTs.content === "string") { messageWithTs.content = [ reasoningBlock, - { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, + { type: "text", text: messageWithTs.content } satisfies NeutralTextBlock, ] } else if (Array.isArray(messageWithTs.content)) { messageWithTs.content = [reasoningBlock, ...messageWithTs.content] @@ -1138,7 +1134,7 @@ export class Task extends EventEmitter implements TaskLike { if (typeof messageWithTs.content === "string") { messageWithTs.content = [ - { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, + { type: "text", text: messageWithTs.content } satisfies NeutralTextBlock, thoughtSignatureBlock, ] } else if (Array.isArray(messageWithTs.content)) { @@ -1168,10 +1164,10 @@ export class Task extends EventEmitter implements TaskLike { messageToAdd = { ...message, content: message.content.map((block) => - block.type === "tool_result" + block.type === "tool-result" ? { type: "text" as const, - text: `Tool result:\n${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}`, + text: `Tool result:\n${typeof (block as NeutralToolResultBlock).output === "object" && (block as NeutralToolResultBlock).output?.type === "text" ? ((block as NeutralToolResultBlock).output as { type: "text"; value: string }).value : JSON.stringify((block as NeutralToolResultBlock).output)}`, } : block, ), @@ -1247,7 +1243,7 @@ export class Task extends EventEmitter implements TaskLike { } // Save the user message with tool_result blocks - const userMessage: Anthropic.MessageParam = { + const userMessage: NeutralMessageParam = { role: "user", content: this.userMessageContent, } @@ -2144,7 +2140,7 @@ export class Task extends EventEmitter implements TaskLike { } this.isInitialized = true - const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images) + const imageBlocks: NeutralImageBlock[] = formatResponse.imageBlocks(images) // Task starting await this.initiateTaskLoop([ @@ -2270,7 +2266,7 @@ export class Task extends EventEmitter implements TaskLike { // if the last message is a user message, we can need to get the assistant message before it to see if it made tool calls, and if so, fill in the remaining tool responses with 'interrupted' - let modifiedOldUserContent: Anthropic.Messages.ContentBlockParam[] // either the last message if its user message, or the user message before the last (assistant) message + let modifiedOldUserContent: NeutralContentBlock[] // either the last message if its user message, or the user message before the last (assistant) message let modifiedApiConversationHistory: ApiMessage[] // need to remove the last user message to replace with new modified user message if (existingApiConversationHistory.length > 0) { const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1] @@ -2279,16 +2275,18 @@ export class Task extends EventEmitter implements TaskLike { const content = Array.isArray(lastMessage.content) ? lastMessage.content : [{ type: "text", text: lastMessage.content }] - const hasToolUse = content.some((block) => block.type === "tool_use") + const hasToolUse = content.some((block) => block.type === "tool-call") if (hasToolUse) { - const toolUseBlocks = content.filter( - (block) => block.type === "tool_use", - ) as Anthropic.Messages.ToolUseBlock[] - const toolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks.map((block) => ({ - type: "tool_result", - tool_use_id: block.id, - content: "Task was interrupted before this tool call could be completed.", + const toolUseBlocks = content.filter((block) => block.type === "tool-call") as NeutralToolUseBlock[] + const toolResponses: NeutralToolResultBlock[] = toolUseBlocks.map((block) => ({ + type: "tool-result" as const, + toolCallId: block.toolCallId, + toolName: block.toolName, + output: { + type: "text" as const, + value: "Task was interrupted before this tool call could be completed.", + }, })) modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes modifiedOldUserContent = [...toolResponses] @@ -2300,7 +2298,7 @@ export class Task extends EventEmitter implements TaskLike { const previousAssistantMessage: ApiMessage | undefined = existingApiConversationHistory[existingApiConversationHistory.length - 2] - const existingUserContent: Anthropic.Messages.ContentBlockParam[] = Array.isArray(lastMessage.content) + const existingUserContent: NeutralContentBlock[] = Array.isArray(lastMessage.content) ? lastMessage.content : [{ type: "text", text: lastMessage.content }] if (previousAssistantMessage && previousAssistantMessage.role === "assistant") { @@ -2309,22 +2307,27 @@ export class Task extends EventEmitter implements TaskLike { : [{ type: "text", text: previousAssistantMessage.content }] const toolUseBlocks = assistantContent.filter( - (block) => block.type === "tool_use", - ) as Anthropic.Messages.ToolUseBlock[] + (block) => block.type === "tool-call", + ) as NeutralToolUseBlock[] if (toolUseBlocks.length > 0) { const existingToolResults = existingUserContent.filter( - (block) => block.type === "tool_result", - ) as Anthropic.ToolResultBlockParam[] + (block) => block.type === "tool-result", + ) as NeutralToolResultBlock[] - const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks + const missingToolResponses: NeutralToolResultBlock[] = toolUseBlocks .filter( - (toolUse) => !existingToolResults.some((result) => result.tool_use_id === toolUse.id), + (toolUse) => + !existingToolResults.some((result) => result.toolCallId === toolUse.toolCallId), ) .map((toolUse) => ({ - type: "tool_result", - tool_use_id: toolUse.id, - content: "Task was interrupted before this tool call could be completed.", + type: "tool-result" as const, + toolCallId: toolUse.toolCallId, + toolName: toolUse.toolName, + output: { + type: "text" as const, + value: "Task was interrupted before this tool call could be completed.", + }, })) modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) // removes the last user message @@ -2344,7 +2347,7 @@ export class Task extends EventEmitter implements TaskLike { throw new Error("Unexpected: No existing API conversation history") } - let newUserContent: Anthropic.Messages.ContentBlockParam[] = [...modifiedOldUserContent] + let newUserContent: NeutralContentBlock[] = [...modifiedOldUserContent] const agoText = ((): string => { const timestamp = lastClineMessage?.ts ?? Date.now() @@ -2636,17 +2639,15 @@ export class Task extends EventEmitter implements TaskLike { const lastUserMsg = this.apiConversationHistory[lastUserMsgIndex] if (Array.isArray(lastUserMsg.content)) { // Remove any existing environment_details blocks before adding fresh ones - const contentWithoutEnvDetails = lastUserMsg.content.filter( - (block: Anthropic.Messages.ContentBlockParam) => { - if (block.type === "text" && typeof block.text === "string") { - const isEnvironmentDetailsBlock = - block.text.trim().startsWith("") && - block.text.trim().endsWith("") - return !isEnvironmentDetailsBlock - } - return true - }, - ) + const contentWithoutEnvDetails = lastUserMsg.content.filter((block: NeutralContentBlock) => { + if (block.type === "text" && typeof block.text === "string") { + const isEnvironmentDetailsBlock = + block.text.trim().startsWith("") && + block.text.trim().endsWith("") + return !isEnvironmentDetailsBlock + } + return true + }) // Add fresh environment details lastUserMsg.content = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }] } @@ -2662,7 +2663,7 @@ export class Task extends EventEmitter implements TaskLike { // Task Loop - private async initiateTaskLoop(userContent: Anthropic.Messages.ContentBlockParam[]): Promise { + private async initiateTaskLoop(userContent: NeutralContentBlock[]): Promise { // Kicks off the checkpoints initialization process in the background. getCheckpointService(this) @@ -2697,11 +2698,11 @@ export class Task extends EventEmitter implements TaskLike { } public async recursivelyMakeClineRequests( - userContent: Anthropic.Messages.ContentBlockParam[], + userContent: NeutralContentBlock[], includeFileDetails: boolean = false, ): Promise { interface StackItem { - userContent: Anthropic.Messages.ContentBlockParam[] + userContent: NeutralContentBlock[] includeFileDetails: boolean retryAttempt?: number userMessageWasRemoved?: boolean // Track if user message was removed due to empty response @@ -3089,10 +3090,6 @@ export class Task extends EventEmitter implements TaskLike { break } - // Store the tool call ID on the ToolUse object for later reference - // This is needed to create tool_result blocks that reference the correct tool_use_id - toolUse.id = chunk.id - // Add the tool use to assistant message content this.assistantMessageContent.push(toolUse) @@ -3422,9 +3419,6 @@ export class Task extends EventEmitter implements TaskLike { const toolUseIndex = this.streamingToolCallIndices.get(event.id) if (finalToolUse) { - // Store the tool call ID - ;(finalToolUse as any).id = event.id - // Get the index and replace partial with final if (toolUseIndex !== undefined) { this.assistantMessageContent[toolUseIndex] = finalToolUse @@ -3443,10 +3437,9 @@ export class Task extends EventEmitter implements TaskLike { // We still need to mark the tool as non-partial so it gets executed // The tool's validation will catch any missing required parameters const existingToolUse = this.assistantMessageContent[toolUseIndex] - if (existingToolUse && existingToolUse.type === "tool_use") { + if (existingToolUse && existingToolUse.type === "tool-call") { existingToolUse.partial = false - // Ensure it has the ID for native protocol - ;(existingToolUse as any).id = event.id + existingToolUse.toolCallId = event.id } // Clean up tracking @@ -3504,7 +3497,7 @@ export class Task extends EventEmitter implements TaskLike { const hasTextContent = assistantMessage.length > 0 const hasToolUses = this.assistantMessageContent.some( - (block) => block.type === "tool_use" || block.type === "mcp_tool_use", + (block) => block.type === "tool-call" || block.type === "mcp_tool_use", ) if (hasTextContent || hasToolUses) { @@ -3521,7 +3514,7 @@ export class Task extends EventEmitter implements TaskLike { } // Build the assistant message content array - const assistantContent: Array = [] + const assistantContent: Array = [] // Add text content if present if (assistantMessage) { @@ -3538,7 +3531,7 @@ export class Task extends EventEmitter implements TaskLike { // "tool_use ids must be unique" const seenToolUseIds = new Set() const toolUseBlocks = this.assistantMessageContent.filter( - (block) => block.type === "tool_use" || block.type === "mcp_tool_use", + (block) => block.type === "tool-call" || block.type === "mcp_tool_use", ) for (const block of toolUseBlocks) { if (block.type === "mcp_tool_use") { @@ -3556,39 +3549,39 @@ export class Task extends EventEmitter implements TaskLike { } seenToolUseIds.add(sanitizedId) assistantContent.push({ - type: "tool_use" as const, - id: sanitizedId, - name: mcpBlock.name, // Original dynamic name + type: "tool-call" as const, + toolCallId: sanitizedId, + toolName: mcpBlock.name, // Original dynamic name input: mcpBlock.arguments, // Direct tool arguments }) } } else { // Regular ToolUse const toolUse = block as import("../../shared/tools").ToolUse - const toolCallId = toolUse.id + const toolCallId = toolUse.toolCallId if (toolCallId) { const sanitizedId = sanitizeToolUseId(toolCallId) // Pre-flight deduplication: Skip if we've already added this ID if (seenToolUseIds.has(sanitizedId)) { console.warn( - `[Task#${this.taskId}] Pre-flight deduplication: Skipping duplicate tool_use ID: ${sanitizedId} (tool: ${toolUse.name})`, + `[Task#${this.taskId}] Pre-flight deduplication: Skipping duplicate tool_use ID: ${sanitizedId} (tool: ${toolUse.toolName})`, ) continue } seenToolUseIds.add(sanitizedId) // nativeArgs is already in the correct API format for all tools - const input = toolUse.nativeArgs || toolUse.params + const input = toolUse.nativeArgs || toolUse.input // Use originalName (alias) if present for API history consistency. // When tool aliases are used (e.g., "edit_file" -> "search_and_replace"), // we want the alias name in the conversation history to match what the model // was told the tool was named, preventing confusion in multi-turn conversations. - const toolNameForHistory = toolUse.originalName ?? toolUse.name + const toolNameForHistory = toolUse.originalName ?? toolUse.toolName assistantContent.push({ - type: "tool_use" as const, - id: sanitizedId, - name: toolNameForHistory, + type: "tool-call" as const, + toolCallId: sanitizedId, + toolName: toolNameForHistory, input, }) } @@ -3599,7 +3592,7 @@ export class Task extends EventEmitter implements TaskLike { // truncate any tools that come after it and inject error tool_results. // This prevents orphaned tools when delegation disposes the parent task. const newTaskIndex = assistantContent.findIndex( - (block) => block.type === "tool_use" && block.name === "new_task", + (block) => block.type === "tool-call" && block.toolName === "new_task", ) if (newTaskIndex !== -1 && newTaskIndex < assistantContent.length - 1) { @@ -3612,7 +3605,9 @@ export class Task extends EventEmitter implements TaskLike { // Find new_task index in assistantMessageContent (may differ from assistantContent // due to text blocks being structured differently). const executionNewTaskIndex = this.assistantMessageContent.findIndex( - (block) => block.type === "tool_use" && block.name === "new_task", + (block) => + block.type === "tool-call" && + (block as import("../../shared/tools").ToolUse).toolName === "new_task", ) if (executionNewTaskIndex !== -1) { this.assistantMessageContent.length = executionNewTaskIndex + 1 @@ -3620,13 +3615,15 @@ export class Task extends EventEmitter implements TaskLike { // Pre-inject error tool_results for truncated tools for (const tool of truncatedTools) { - if (tool.type === "tool_use" && (tool as Anthropic.ToolUseBlockParam).id) { + if (tool.type === "tool-call" && (tool as NeutralToolUseBlock).toolCallId) { this.pushToolResultToUserContent({ - type: "tool_result", - tool_use_id: (tool as Anthropic.ToolUseBlockParam).id, - content: - "This tool was not executed because new_task was called in the same message turn. The new_task tool must be the last tool in a message.", - is_error: true, + type: "tool-result" as const, + toolCallId: (tool as NeutralToolUseBlock).toolCallId, + toolName: (tool as NeutralToolUseBlock).toolName, + output: { + type: "error-text" as const, + value: "This tool was not executed because new_task was called in the same message turn. The new_task tool must be the last tool in a message.", + }, }) } } @@ -3680,7 +3677,7 @@ export class Task extends EventEmitter implements TaskLike { // If the model did not tool use, then we need to tell it to // either use a tool or attempt_completion. const didToolUse = this.assistantMessageContent.some( - (block) => block.type === "tool_use" || block.type === "mcp_tool_use", + (block) => block.type === "tool-call" || block.type === "mcp_tool_use", ) if (!didToolUse) { @@ -4383,7 +4380,7 @@ export class Task extends EventEmitter implements TaskLike { // The provider accepts reasoning items alongside standard messages; cast to the expected parameter type. const stream = this.api.createMessage( systemPrompt, - cleanConversationHistory as unknown as Anthropic.Messages.MessageParam[], + cleanConversationHistory as unknown as NeutralMessageParam[], metadata, ) const iterator = stream[Symbol.asyncIterator]() @@ -4557,9 +4554,7 @@ export class Task extends EventEmitter implements TaskLike { private buildCleanConversationHistory( messages: ApiMessage[], - ): Array< - Anthropic.Messages.MessageParam | { type: "reasoning"; encrypted_content: string; id?: string; summary?: any[] } - > { + ): Array { type ReasoningItemForRequest = { type: "reasoning" encrypted_content: string @@ -4567,7 +4562,7 @@ export class Task extends EventEmitter implements TaskLike { summary?: any[] } - const cleanConversationHistory: (Anthropic.Messages.MessageParam | ReasoningItemForRequest)[] = [] + const cleanConversationHistory: (NeutralMessageParam | ReasoningItemForRequest)[] = [] for (const msg of messages) { // Standalone reasoning: send encrypted, skip plain text @@ -4587,12 +4582,10 @@ export class Task extends EventEmitter implements TaskLike { if (msg.role === "assistant") { const rawContent = msg.content - const contentArray: Anthropic.Messages.ContentBlockParam[] = Array.isArray(rawContent) - ? (rawContent as Anthropic.Messages.ContentBlockParam[]) + const contentArray: NeutralContentBlock[] = Array.isArray(rawContent) + ? (rawContent as NeutralContentBlock[]) : rawContent !== undefined - ? ([ - { type: "text", text: rawContent } satisfies Anthropic.Messages.TextBlockParam, - ] as Anthropic.Messages.ContentBlockParam[]) + ? ([{ type: "text", text: rawContent } satisfies NeutralTextBlock] as NeutralContentBlock[]) : [] const [first, ...rest] = contentArray @@ -4601,12 +4594,12 @@ export class Task extends EventEmitter implements TaskLike { const msgWithDetails = msg if (msgWithDetails.reasoning_details && Array.isArray(msgWithDetails.reasoning_details)) { // Build the assistant message with reasoning_details - let assistantContent: Anthropic.Messages.MessageParam["content"] + let assistantContent: NeutralMessageParam["content"] if (contentArray.length === 0) { assistantContent = "" } else if (contentArray.length === 1 && contentArray[0].type === "text") { - assistantContent = (contentArray[0] as Anthropic.Messages.TextBlockParam).text + assistantContent = (contentArray[0] as NeutralTextBlock).text } else { assistantContent = contentArray } @@ -4639,12 +4632,12 @@ export class Task extends EventEmitter implements TaskLike { }) // Send assistant message without reasoning - let assistantContent: Anthropic.Messages.MessageParam["content"] + let assistantContent: NeutralMessageParam["content"] if (rest.length === 0) { assistantContent = "" } else if (rest.length === 1 && rest[0].type === "text") { - assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text + assistantContent = (rest[0] as NeutralTextBlock).text } else { assistantContent = rest } @@ -4652,7 +4645,7 @@ export class Task extends EventEmitter implements TaskLike { cleanConversationHistory.push({ role: "assistant", content: assistantContent, - } satisfies Anthropic.Messages.MessageParam) + } satisfies NeutralMessageParam) continue } else if (hasPlainTextReasoning) { @@ -4662,7 +4655,7 @@ export class Task extends EventEmitter implements TaskLike { const shouldPreserveForApi = this.api.getModel().info.preserveReasoning === true || this.api.isAiSdkProvider() - let assistantContent: Anthropic.Messages.MessageParam["content"] + let assistantContent: NeutralMessageParam["content"] if (shouldPreserveForApi) { assistantContent = contentArray @@ -4671,7 +4664,7 @@ export class Task extends EventEmitter implements TaskLike { if (rest.length === 0) { assistantContent = "" } else if (rest.length === 1 && rest[0].type === "text") { - assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text + assistantContent = (rest[0] as NeutralTextBlock).text } else { assistantContent = rest } @@ -4680,7 +4673,7 @@ export class Task extends EventEmitter implements TaskLike { cleanConversationHistory.push({ role: "assistant", content: assistantContent, - } satisfies Anthropic.Messages.MessageParam) + } satisfies NeutralMessageParam) continue } @@ -4690,7 +4683,7 @@ export class Task extends EventEmitter implements TaskLike { if (msg.role) { cleanConversationHistory.push({ role: msg.role, - content: msg.content as Anthropic.Messages.ContentBlockParam[] | string, + content: msg.content as NeutralContentBlock[] | string, }) } } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index a065c11eaae..2c08856daed 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1,10 +1,10 @@ // npx vitest core/task/__tests__/Task.spec.ts +import type { RooMessageParam } from "../../task-persistence/apiMessages" import * as os from "os" import * as path from "path" import * as vscode from "vscode" -import { Anthropic } from "@anthropic-ai/sdk" import type { GlobalState, ProviderSettings, ModelInfo } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -23,6 +23,7 @@ vi.mock("delay", () => ({ })) import delay from "delay" +import type { ImagePart, TextPart, ToolResultPart } from "ai" vi.mock("uuid", async (importOriginal) => { const actual = await importOriginal() @@ -477,22 +478,19 @@ describe("Cline", () => { } // Create test conversation history with mixed content - const conversationHistory: (Anthropic.MessageParam & { ts?: number })[] = [ + const conversationHistory: (RooMessageParam & { ts?: number })[] = [ { role: "user" as const, content: [ { type: "text" as const, text: "Here is an image", - } satisfies Anthropic.TextBlockParam, + } satisfies TextPart, { type: "image" as const, - source: { - type: "base64" as const, - media_type: "image/jpeg", - data: "base64data", - }, - } satisfies Anthropic.ImageBlockParam, + image: "base64data", + mediaType: "image/jpeg", + } satisfies ImagePart, ], }, { @@ -501,7 +499,7 @@ describe("Cline", () => { { type: "text" as const, text: "I see the image", - } satisfies Anthropic.TextBlockParam, + } satisfies TextPart, ], }, ] @@ -585,7 +583,7 @@ describe("Cline", () => { role: "user", content: [ { type: "text", text: "Here is an image" }, - { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "base64data" } }, + { type: "image", image: "base64data", mediaType: "image/jpeg" }, ], }, ] @@ -883,25 +881,33 @@ describe("Cline", () => { text: "Text with 'some/path' (see below for file content) in user_message tags", } as const, { - type: "tool_result", - tool_use_id: "test-id", - content: [ - { - type: "text", - text: "Check 'some/path' (see below for file content)", - }, - ], - } as Anthropic.ToolResultBlockParam, + type: "tool-result", + toolCallId: "test-id", + toolName: "", + output: { + type: "content" as const, + value: [ + { + type: "text", + text: "Check 'some/path' (see below for file content)", + }, + ], + }, + } as ToolResultPart, { - type: "tool_result", - tool_use_id: "test-id-2", - content: [ - { - type: "text", - text: "Regular tool result with 'path' (see below for file content)", - }, - ], - } as Anthropic.ToolResultBlockParam, + type: "tool-result", + toolCallId: "test-id-2", + toolName: "", + output: { + type: "content" as const, + value: [ + { + type: "text", + text: "Regular tool result with 'path' (see below for file content)", + }, + ], + }, + } as ToolResultPart, ] const { content: processedContent } = await processUserContentMentions({ @@ -912,30 +918,32 @@ describe("Cline", () => { }) // Regular text should not be processed - expect((processedContent[0] as Anthropic.TextBlockParam).text).toBe( + expect((processedContent[0] as TextPart).text).toBe( "Regular text with 'some/path' (see below for file content)", ) // Text within user_message tags should be processed - expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain("processed:") - expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain( + expect((processedContent[1] as TextPart).text).toContain("processed:") + expect((processedContent[1] as TextPart).text).toContain( "Text with 'some/path' (see below for file content) in user_message tags", ) // user_message tag content should be processed - const toolResult1 = processedContent[2] as Anthropic.ToolResultBlockParam - const content1 = Array.isArray(toolResult1.content) ? toolResult1.content[0] : toolResult1.content - expect((content1 as Anthropic.TextBlockParam).text).toContain("processed:") - expect((content1 as Anthropic.TextBlockParam).text).toContain( + const toolResult1 = processedContent[2] as ToolResultPart + const output1 = ( + toolResult1.output as { type: "content"; value: Array<{ type: "text"; text: string }> } + ).value + expect(output1[0].text).toContain("processed:") + expect(output1[0].text).toContain( "Check 'some/path' (see below for file content)", ) // Regular tool result should not be processed - const toolResult2 = processedContent[3] as Anthropic.ToolResultBlockParam - const content2 = Array.isArray(toolResult2.content) ? toolResult2.content[0] : toolResult2.content - expect((content2 as Anthropic.TextBlockParam).text).toBe( - "Regular tool result with 'path' (see below for file content)", - ) + const toolResult2 = processedContent[3] as ToolResultPart + const output2 = ( + toolResult2.output as { type: "content"; value: Array<{ type: "text"; text: string }> } + ).value + expect(output2[0].text).toBe("Regular tool result with 'path' (see below for file content)") await cline.abortTask(true) await task.catch(() => {}) @@ -2032,10 +2040,11 @@ describe("pushToolResultToUserContent", () => { startTask: false, }) - const toolResult: Anthropic.ToolResultBlockParam = { - type: "tool_result", - tool_use_id: "test-id-1", - content: "Test result", + const toolResult: ToolResultPart = { + type: "tool-result", + toolCallId: "test-id-1", + toolName: "", + output: { type: "text" as const, value: "Test result" }, } const added = task.pushToolResultToUserContent(toolResult) @@ -2053,16 +2062,18 @@ describe("pushToolResultToUserContent", () => { startTask: false, }) - const toolResult1: Anthropic.ToolResultBlockParam = { - type: "tool_result", - tool_use_id: "duplicate-id", - content: "First result", + const toolResult1: ToolResultPart = { + type: "tool-result", + toolCallId: "duplicate-id", + toolName: "", + output: { type: "text" as const, value: "First result" }, } - const toolResult2: Anthropic.ToolResultBlockParam = { - type: "tool_result", - tool_use_id: "duplicate-id", - content: "Second result (should be skipped)", + const toolResult2: ToolResultPart = { + type: "tool-result", + toolCallId: "duplicate-id", + toolName: "", + output: { type: "text" as const, value: "Second result (should be skipped)" }, } // Spy on console.warn to verify warning is logged @@ -2083,7 +2094,7 @@ describe("pushToolResultToUserContent", () => { // Verify warning was logged expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("Skipping duplicate tool_result for tool_use_id: duplicate-id"), + expect.stringContaining("Skipping duplicate tool_result for toolCallId: duplicate-id"), ) warnSpy.mockRestore() @@ -2097,16 +2108,18 @@ describe("pushToolResultToUserContent", () => { startTask: false, }) - const toolResult1: Anthropic.ToolResultBlockParam = { - type: "tool_result", - tool_use_id: "id-1", - content: "Result 1", + const toolResult1: ToolResultPart = { + type: "tool-result", + toolCallId: "id-1", + toolName: "", + output: { type: "text" as const, value: "Result 1" }, } - const toolResult2: Anthropic.ToolResultBlockParam = { - type: "tool_result", - tool_use_id: "id-2", - content: "Result 2", + const toolResult2: ToolResultPart = { + type: "tool-result", + toolCallId: "id-2", + toolName: "", + output: { type: "text" as const, value: "Result 2" }, } const added1 = task.pushToolResultToUserContent(toolResult1) @@ -2127,11 +2140,11 @@ describe("pushToolResultToUserContent", () => { startTask: false, }) - const errorResult: Anthropic.ToolResultBlockParam = { - type: "tool_result", - tool_use_id: "error-id", - content: "Error message", - is_error: true, + const errorResult: ToolResultPart = { + type: "tool-result", + toolCallId: "error-id", + toolName: "", + output: { type: "text" as const, value: "Error message" }, } const added = task.pushToolResultToUserContent(errorResult) @@ -2152,13 +2165,14 @@ describe("pushToolResultToUserContent", () => { // Add text and image blocks manually task.userMessageContent.push( { type: "text", text: "Some text" }, - { type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" } }, + { type: "image", image: "base64data", mediaType: "image/png" }, ) - const toolResult: Anthropic.ToolResultBlockParam = { - type: "tool_result", - tool_use_id: "test-id", - content: "Result", + const toolResult: ToolResultPart = { + type: "tool-result", + toolCallId: "test-id", + toolName: "", + output: { type: "text" as const, value: "Result" }, } const added = task.pushToolResultToUserContent(toolResult) diff --git a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts index f19645d9697..78b3863a46e 100644 --- a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts +++ b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts @@ -252,9 +252,10 @@ describe("flushPendingToolResultsToHistory", () => { // Set up pending tool result in userMessageContent task.userMessageContent = [ { - type: "tool_result", - tool_use_id: "tool-123", - content: "File written successfully", + type: "tool-result", + toolCallId: "tool-123", + toolName: "", + output: { type: "text" as const, value: "File written successfully" }, }, ] @@ -267,8 +268,8 @@ describe("flushPendingToolResultsToHistory", () => { const userMessage = task.apiConversationHistory[0] expect(userMessage.role).toBe("user") expect(Array.isArray(userMessage.content)).toBe(true) - expect((userMessage.content as any[])[0].type).toBe("tool_result") - expect((userMessage.content as any[])[0].tool_use_id).toBe("tool-123") + expect((userMessage.content as any[])[0].type).toBe("tool-result") + expect((userMessage.content as any[])[0].toolCallId).toBe("tool-123") }) it("should clear userMessageContent after flushing", async () => { @@ -282,9 +283,10 @@ describe("flushPendingToolResultsToHistory", () => { // Set up pending tool result task.userMessageContent = [ { - type: "tool_result", - tool_use_id: "tool-456", - content: "Command executed", + type: "tool-result", + toolCallId: "tool-456", + toolName: "", + output: { type: "text" as const, value: "Command executed" }, }, ] @@ -305,14 +307,16 @@ describe("flushPendingToolResultsToHistory", () => { // Set up multiple pending tool results task.userMessageContent = [ { - type: "tool_result", - tool_use_id: "tool-1", - content: "First result", + type: "tool-result", + toolCallId: "tool-1", + toolName: "", + output: { type: "text" as const, value: "First result" }, }, { - type: "tool_result", - tool_use_id: "tool-2", - content: "Second result", + type: "tool-result", + toolCallId: "tool-2", + toolName: "", + output: { type: "text" as const, value: "Second result" }, }, ] @@ -322,8 +326,8 @@ describe("flushPendingToolResultsToHistory", () => { const userMessage = task.apiConversationHistory[0] expect(Array.isArray(userMessage.content)).toBe(true) expect((userMessage.content as any[]).length).toBe(2) - expect((userMessage.content as any[])[0].tool_use_id).toBe("tool-1") - expect((userMessage.content as any[])[1].tool_use_id).toBe("tool-2") + expect((userMessage.content as any[])[0].toolCallId).toBe("tool-1") + expect((userMessage.content as any[])[1].toolCallId).toBe("tool-2") }) it("should add timestamp to saved messages", async () => { @@ -338,9 +342,10 @@ describe("flushPendingToolResultsToHistory", () => { task.userMessageContent = [ { - type: "tool_result", - tool_use_id: "tool-ts", - content: "Result", + type: "tool-result", + toolCallId: "tool-ts", + toolName: "", + output: { type: "text" as const, value: "Result" }, }, ] @@ -367,9 +372,10 @@ describe("flushPendingToolResultsToHistory", () => { // Set up pending tool result task.userMessageContent = [ { - type: "tool_result", - tool_use_id: "tool-skip-wait", - content: "Result when flag is true", + type: "tool-result", + toolCallId: "tool-skip-wait", + toolName: "", + output: { type: "text" as const, value: "Result when flag is true" }, }, ] @@ -383,7 +389,7 @@ describe("flushPendingToolResultsToHistory", () => { // Should still save the message expect(task.apiConversationHistory.length).toBe(1) - expect((task.apiConversationHistory[0].content as any[])[0].tool_use_id).toBe("tool-skip-wait") + expect((task.apiConversationHistory[0].content as any[])[0].toolCallId).toBe("tool-skip-wait") }) it("should wait for assistantMessageSavedToHistory when flag is false", async () => { @@ -400,9 +406,10 @@ describe("flushPendingToolResultsToHistory", () => { // Set up pending tool result task.userMessageContent = [ { - type: "tool_result", - tool_use_id: "tool-wait", - content: "Result when flag is false", + type: "tool-result", + toolCallId: "tool-wait", + toolName: "", + output: { type: "text" as const, value: "Result when flag is false" }, }, ] @@ -432,9 +439,10 @@ describe("flushPendingToolResultsToHistory", () => { // Set up pending tool result task.userMessageContent = [ { - type: "tool_result", - tool_use_id: "tool-aborted", - content: "Should not be saved", + type: "tool-result", + toolCallId: "tool-aborted", + toolName: "", + output: { type: "text" as const, value: "Should not be saved" }, }, ] diff --git a/src/core/task/__tests__/new-task-isolation.spec.ts b/src/core/task/__tests__/new-task-isolation.spec.ts index 9100fb33993..9fedec10d7f 100644 --- a/src/core/task/__tests__/new-task-isolation.spec.ts +++ b/src/core/task/__tests__/new-task-isolation.spec.ts @@ -1,3 +1,4 @@ +import type { TextPart, ToolCallPart, ToolResultPart } from "ai" /** * Tests for new_task tool isolation enforcement. * @@ -9,8 +10,6 @@ * This prevents orphaned tools when delegation disposes the parent task. */ -import type { Anthropic } from "@anthropic-ai/sdk" - describe("new_task Tool Isolation Enforcement", () => { /** * Simulates the new_task isolation enforcement logic from Task.ts. @@ -18,16 +17,16 @@ describe("new_task Tool Isolation Enforcement", () => { * assistant message content for the API. */ const enforceNewTaskIsolation = ( - assistantContent: Array, + assistantContent: Array, ): { - truncatedContent: Array - injectedToolResults: Anthropic.ToolResultBlockParam[] + truncatedContent: Array + injectedToolResults: ToolResultPart[] } => { - const injectedToolResults: Anthropic.ToolResultBlockParam[] = [] + const injectedToolResults: ToolResultPart[] = [] // Find the index of new_task tool in the assistantContent array const newTaskIndex = assistantContent.findIndex( - (block) => block.type === "tool_use" && block.name === "new_task", + (block) => block.type === "tool-call" && block.toolName === "new_task", ) if (newTaskIndex !== -1 && newTaskIndex < assistantContent.length - 1) { @@ -37,13 +36,15 @@ describe("new_task Tool Isolation Enforcement", () => { // Pre-inject error tool_results for truncated tools for (const tool of truncatedTools) { - if (tool.type === "tool_use" && tool.id) { + if (tool.type === "tool-call" && tool.toolCallId) { injectedToolResults.push({ - type: "tool_result", - tool_use_id: tool.id, - content: - "This tool was not executed because new_task was called in the same message turn. The new_task tool must be the last tool in a message.", - is_error: true, + type: "tool-result", + toolCallId: tool.toolCallId, + toolName: "", + output: { + type: "error-text" as const, + value: "This tool was not executed because new_task was called in the same message turn. The new_task tool must be the last tool in a message.", + }, }) } } @@ -56,11 +57,11 @@ describe("new_task Tool Isolation Enforcement", () => { describe("new_task as last tool (no truncation needed)", () => { it("should not truncate when new_task is the only tool", () => { - const assistantContent: Anthropic.ToolUseBlockParam[] = [ + const assistantContent: ToolCallPart[] = [ { - type: "tool_use", - id: "toolu_new_task_1", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_1", + toolName: "new_task", input: { mode: "code", message: "Do something" }, }, ] @@ -72,17 +73,17 @@ describe("new_task Tool Isolation Enforcement", () => { }) it("should not truncate when new_task is the last tool", () => { - const assistantContent: Anthropic.ToolUseBlockParam[] = [ + const assistantContent: ToolCallPart[] = [ { - type: "tool_use", - id: "toolu_read_1", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_read_1", + toolName: "read_file", input: { path: "test.txt" }, }, { - type: "tool_use", - id: "toolu_new_task_1", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_1", + toolName: "new_task", input: { mode: "code", message: "Do something" }, }, ] @@ -94,17 +95,17 @@ describe("new_task Tool Isolation Enforcement", () => { }) it("should not truncate when there is no new_task tool", () => { - const assistantContent: Anthropic.ToolUseBlockParam[] = [ + const assistantContent: ToolCallPart[] = [ { - type: "tool_use", - id: "toolu_read_1", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_read_1", + toolName: "read_file", input: { path: "test.txt" }, }, { - type: "tool_use", - id: "toolu_write_1", - name: "write_to_file", + type: "tool-call", + toolCallId: "toolu_write_1", + toolName: "write_to_file", input: { path: "test.txt", content: "hello" }, }, ] @@ -118,17 +119,17 @@ describe("new_task Tool Isolation Enforcement", () => { describe("new_task followed by other tools (truncation required)", () => { it("should truncate tools after new_task", () => { - const assistantContent: Anthropic.ToolUseBlockParam[] = [ + const assistantContent: ToolCallPart[] = [ { - type: "tool_use", - id: "toolu_new_task_1", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_1", + toolName: "new_task", input: { mode: "code", message: "Do something" }, }, { - type: "tool_use", - id: "toolu_read_1", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_read_1", + toolName: "read_file", input: { path: "test.txt" }, }, ] @@ -136,22 +137,22 @@ describe("new_task Tool Isolation Enforcement", () => { const result = enforceNewTaskIsolation(assistantContent) expect(result.truncatedContent).toHaveLength(1) - expect(result.truncatedContent[0].type).toBe("tool_use") - expect((result.truncatedContent[0] as Anthropic.ToolUseBlockParam).name).toBe("new_task") + expect(result.truncatedContent[0].type).toBe("tool-call") + expect((result.truncatedContent[0] as ToolCallPart).toolName).toBe("new_task") }) it("should inject error tool_results for truncated tools", () => { - const assistantContent: Anthropic.ToolUseBlockParam[] = [ + const assistantContent: ToolCallPart[] = [ { - type: "tool_use", - id: "toolu_new_task_1", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_1", + toolName: "new_task", input: { mode: "code", message: "Do something" }, }, { - type: "tool_use", - id: "toolu_read_1", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_read_1", + toolName: "read_file", input: { path: "test.txt" }, }, ] @@ -160,37 +161,37 @@ describe("new_task Tool Isolation Enforcement", () => { expect(result.injectedToolResults).toHaveLength(1) expect(result.injectedToolResults[0]).toMatchObject({ - type: "tool_result", - tool_use_id: "toolu_read_1", - is_error: true, + type: "tool-result", + toolCallId: "toolu_read_1", + toolName: "", }) - expect(result.injectedToolResults[0].content).toContain("new_task was called") + expect((result.injectedToolResults[0].output as { value: string }).value).toContain("new_task was called") }) it("should truncate multiple tools after new_task", () => { - const assistantContent: Anthropic.ToolUseBlockParam[] = [ + const assistantContent: ToolCallPart[] = [ { - type: "tool_use", - id: "toolu_new_task_1", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_1", + toolName: "new_task", input: { mode: "code", message: "Do something" }, }, { - type: "tool_use", - id: "toolu_read_1", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_read_1", + toolName: "read_file", input: { path: "test.txt" }, }, { - type: "tool_use", - id: "toolu_write_1", - name: "write_to_file", + type: "tool-call", + toolCallId: "toolu_write_1", + toolName: "write_to_file", input: { path: "test.txt", content: "hello" }, }, { - type: "tool_use", - id: "toolu_execute_1", - name: "execute_command", + type: "tool-call", + toolCallId: "toolu_execute_1", + toolName: "execute_command", input: { command: "ls" }, }, ] @@ -201,30 +202,30 @@ describe("new_task Tool Isolation Enforcement", () => { expect(result.injectedToolResults).toHaveLength(3) // Verify all truncated tools get error results - const truncatedIds = result.injectedToolResults.map((r) => r.tool_use_id) + const truncatedIds = result.injectedToolResults.map((r) => r.toolCallId) expect(truncatedIds).toContain("toolu_read_1") expect(truncatedIds).toContain("toolu_write_1") expect(truncatedIds).toContain("toolu_execute_1") }) it("should preserve tools before new_task", () => { - const assistantContent: Anthropic.ToolUseBlockParam[] = [ + const assistantContent: ToolCallPart[] = [ { - type: "tool_use", - id: "toolu_read_1", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_read_1", + toolName: "read_file", input: { path: "test.txt" }, }, { - type: "tool_use", - id: "toolu_new_task_1", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_1", + toolName: "new_task", input: { mode: "code", message: "Do something" }, }, { - type: "tool_use", - id: "toolu_write_1", - name: "write_to_file", + type: "tool-call", + toolCallId: "toolu_write_1", + toolName: "write_to_file", input: { path: "test.txt", content: "hello" }, }, ] @@ -233,32 +234,32 @@ describe("new_task Tool Isolation Enforcement", () => { // Should preserve read_file and new_task, truncate write_to_file expect(result.truncatedContent).toHaveLength(2) - expect((result.truncatedContent[0] as Anthropic.ToolUseBlockParam).name).toBe("read_file") - expect((result.truncatedContent[1] as Anthropic.ToolUseBlockParam).name).toBe("new_task") + expect((result.truncatedContent[0] as ToolCallPart).toolName).toBe("read_file") + expect((result.truncatedContent[1] as ToolCallPart).toolName).toBe("new_task") // Should inject error for write_to_file only expect(result.injectedToolResults).toHaveLength(1) - expect(result.injectedToolResults[0].tool_use_id).toBe("toolu_write_1") + expect(result.injectedToolResults[0].toolCallId).toBe("toolu_write_1") }) }) describe("Mixed content (text and tools)", () => { it("should handle text blocks before new_task", () => { - const assistantContent: Array = [ + const assistantContent: Array = [ { type: "text", text: "I will delegate this task.", }, { - type: "tool_use", - id: "toolu_new_task_1", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_1", + toolName: "new_task", input: { mode: "code", message: "Do something" }, }, { - type: "tool_use", - id: "toolu_read_1", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_read_1", + toolName: "read_file", input: { path: "test.txt" }, }, ] @@ -268,10 +269,10 @@ describe("new_task Tool Isolation Enforcement", () => { // Should preserve text and new_task, truncate read_file expect(result.truncatedContent).toHaveLength(2) expect(result.truncatedContent[0].type).toBe("text") - expect((result.truncatedContent[1] as Anthropic.ToolUseBlockParam).name).toBe("new_task") + expect((result.truncatedContent[1] as ToolCallPart).toolName).toBe("new_task") expect(result.injectedToolResults).toHaveLength(1) - expect(result.injectedToolResults[0].tool_use_id).toBe("toolu_read_1") + expect(result.injectedToolResults[0].toolCallId).toBe("toolu_read_1") }) it("should not count text blocks when checking if new_task is last tool", () => { @@ -279,11 +280,11 @@ describe("new_task Tool Isolation Enforcement", () => { // whether that counts as "new_task is last tool". The implementation only // checks array position, so text after new_task means new_task is NOT last. // However, text blocks don't need tool_results, so this is fine. - const assistantContent: Array = [ + const assistantContent: Array = [ { - type: "tool_use", - id: "toolu_new_task_1", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_1", + toolName: "new_task", input: { mode: "code", message: "Do something" }, }, { @@ -302,7 +303,7 @@ describe("new_task Tool Isolation Enforcement", () => { describe("Edge cases", () => { it("should handle empty content array", () => { - const assistantContent: Anthropic.ToolUseBlockParam[] = [] + const assistantContent: ToolCallPart[] = [] const result = enforceNewTaskIsolation(assistantContent) @@ -311,17 +312,17 @@ describe("new_task Tool Isolation Enforcement", () => { }) it("should handle tool without id (should not inject error result)", () => { - const assistantContent: Array = [ + const assistantContent: Array = [ { - type: "tool_use", - id: "toolu_new_task_1", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_1", + toolName: "new_task", input: { mode: "code", message: "Do something" }, }, // Simulating a malformed tool without ID (shouldn't happen, but defensive) { - type: "tool_use", - name: "read_file", + type: "tool-call", + toolName: "read_file", input: { path: "test.txt" }, } as any, ] @@ -334,23 +335,23 @@ describe("new_task Tool Isolation Enforcement", () => { }) it("should only consider the first new_task if multiple exist", () => { - const assistantContent: Anthropic.ToolUseBlockParam[] = [ + const assistantContent: ToolCallPart[] = [ { - type: "tool_use", - id: "toolu_read_1", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_read_1", + toolName: "read_file", input: { path: "test.txt" }, }, { - type: "tool_use", - id: "toolu_new_task_1", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_1", + toolName: "new_task", input: { mode: "code", message: "First task" }, }, { - type: "tool_use", - id: "toolu_new_task_2", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_2", + toolName: "new_task", input: { mode: "debug", message: "Second task" }, }, ] @@ -359,57 +360,57 @@ describe("new_task Tool Isolation Enforcement", () => { // Should find first new_task and truncate everything after it expect(result.truncatedContent).toHaveLength(2) - expect((result.truncatedContent[0] as Anthropic.ToolUseBlockParam).name).toBe("read_file") - expect((result.truncatedContent[1] as Anthropic.ToolUseBlockParam).id).toBe("toolu_new_task_1") + expect((result.truncatedContent[0] as ToolCallPart).toolName).toBe("read_file") + expect((result.truncatedContent[1] as ToolCallPart).toolCallId).toBe("toolu_new_task_1") // Second new_task should get error result expect(result.injectedToolResults).toHaveLength(1) - expect(result.injectedToolResults[0].tool_use_id).toBe("toolu_new_task_2") + expect(result.injectedToolResults[0].toolCallId).toBe("toolu_new_task_2") }) }) describe("Error message content", () => { it("should include descriptive error message", () => { - const assistantContent: Anthropic.ToolUseBlockParam[] = [ + const assistantContent: ToolCallPart[] = [ { - type: "tool_use", - id: "toolu_new_task_1", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_1", + toolName: "new_task", input: { mode: "code", message: "Do something" }, }, { - type: "tool_use", - id: "toolu_read_1", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_read_1", + toolName: "read_file", input: { path: "test.txt" }, }, ] const result = enforceNewTaskIsolation(assistantContent) - expect(result.injectedToolResults[0].content).toContain("new_task was called") - expect(result.injectedToolResults[0].content).toContain("must be the last tool") + expect((result.injectedToolResults[0].output as { value: string }).value).toContain("new_task was called") + expect((result.injectedToolResults[0].output as { value: string }).value).toContain("must be the last tool") }) it("should mark error results with is_error: true", () => { - const assistantContent: Anthropic.ToolUseBlockParam[] = [ + const assistantContent: ToolCallPart[] = [ { - type: "tool_use", - id: "toolu_new_task_1", - name: "new_task", + type: "tool-call", + toolCallId: "toolu_new_task_1", + toolName: "new_task", input: { mode: "code", message: "Do something" }, }, { - type: "tool_use", - id: "toolu_read_1", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_read_1", + toolName: "read_file", input: { path: "test.txt" }, }, ] const result = enforceNewTaskIsolation(assistantContent) - expect(result.injectedToolResults[0].is_error).toBe(true) + expect(result.injectedToolResults[0].output.type).toBe("error-text") }) }) }) diff --git a/src/core/task/__tests__/task-tool-history.spec.ts b/src/core/task/__tests__/task-tool-history.spec.ts index df74393156a..87453a3b8f0 100644 --- a/src/core/task/__tests__/task-tool-history.spec.ts +++ b/src/core/task/__tests__/task-tool-history.spec.ts @@ -1,5 +1,5 @@ +import type { RooMessageParam } from "../../task-persistence/apiMessages" import { describe, it, expect, beforeEach, vi } from "vitest" -import { Anthropic } from "@anthropic-ai/sdk" describe("Task Tool History Handling", () => { describe("resumeTaskFromHistory tool block preservation", () => { @@ -19,9 +19,9 @@ describe("Task Tool History Handling", () => { text: "I'll read that file for you.", }, { - type: "tool_use", - id: "toolu_123", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_123", + toolName: "read_file", input: { path: "config.json" }, }, ], @@ -31,8 +31,9 @@ describe("Task Tool History Handling", () => { role: "user", content: [ { - type: "tool_result", - tool_use_id: "toolu_123", + type: "tool-result", + toolCallId: "toolu_123", + toolName: "", content: '{"setting": "value"}', }, ], @@ -47,9 +48,9 @@ describe("Task Tool History Handling", () => { expect(assistantMessage.content).toEqual( expect.arrayContaining([ expect.objectContaining({ - type: "tool_use", - id: "toolu_123", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_123", + toolName: "read_file", }), ]), ) @@ -57,8 +58,9 @@ describe("Task Tool History Handling", () => { expect(userMessage.content).toEqual( expect.arrayContaining([ expect.objectContaining({ - type: "tool_result", - tool_use_id: "toolu_123", + type: "tool-result", + toolCallId: "toolu_123", + toolName: "", }), ]), ) @@ -67,7 +69,7 @@ describe("Task Tool History Handling", () => { describe("convertToOpenAiMessages format", () => { it("should properly convert tool_use to tool_calls format", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { + const anthropicMessage: RooMessageParam = { role: "assistant", content: [ { @@ -75,22 +77,22 @@ describe("Task Tool History Handling", () => { text: "I'll read that file.", }, { - type: "tool_use", - id: "toolu_123", - name: "read_file", + type: "tool-call", + toolCallId: "toolu_123", + toolName: "read_file", input: { path: "config.json" }, }, ], } // Simulate what convertToOpenAiMessages does - const toolUseBlocks = (anthropicMessage.content as any[]).filter((block) => block.type === "tool_use") + const toolUseBlocks = (anthropicMessage.content as any[]).filter((block) => block.type === "tool-call") const tool_calls = toolUseBlocks.map((toolMessage) => ({ - id: toolMessage.id, + id: toolMessage.toolCallId, type: "function" as const, function: { - name: toolMessage.name, + name: toolMessage.toolName, arguments: JSON.stringify(toolMessage.input), }, })) @@ -107,24 +109,25 @@ describe("Task Tool History Handling", () => { }) it("should properly convert tool_result to tool role messages", () => { - const anthropicMessage: Anthropic.Messages.MessageParam = { + const anthropicMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "toolu_123", - content: '{"setting": "value"}', + type: "tool-result", + toolCallId: "toolu_123", + toolName: "", + output: { type: "text" as const, value: '{"setting": "value"}' }, }, ], } // Simulate what convertToOpenAiMessages does - const toolMessages = (anthropicMessage.content as any[]).filter((block) => block.type === "tool_result") + const toolMessages = (anthropicMessage.content as any[]).filter((block) => block.type === "tool-result") const openAiToolMessages = toolMessages.map((toolMessage) => ({ role: "tool" as const, - tool_call_id: toolMessage.tool_use_id, - content: typeof toolMessage.content === "string" ? toolMessage.content : toolMessage.content[0].text, + tool_call_id: toolMessage.toolCallId, + content: typeof toolMessage.output === "string" ? toolMessage.output : toolMessage.output?.value, })) expect(openAiToolMessages).toHaveLength(1) @@ -233,9 +236,10 @@ describe("Task Tool History Handling", () => { text: "Another message with tags", }, { - type: "tool_result" as const, - tool_use_id: "tool_123", - content: "Tool result", + type: "tool-result" as const, + toolCallId: "tool_123", + toolName: "", + output: { type: "text" as const, value: "Tool result" }, }, ] diff --git a/src/core/task/__tests__/validateToolResultIds.spec.ts b/src/core/task/__tests__/validateToolResultIds.spec.ts index 0926e899aad..352c698a50d 100644 --- a/src/core/task/__tests__/validateToolResultIds.spec.ts +++ b/src/core/task/__tests__/validateToolResultIds.spec.ts @@ -1,4 +1,5 @@ -import { Anthropic } from "@anthropic-ai/sdk" +import type { TextPart, ToolResultPart } from "ai" +import type { RooMessageParam } from "../../task-persistence/apiMessages" import { TelemetryService } from "@roo-code/telemetry" import { validateAndFixToolResultIds, @@ -23,13 +24,14 @@ describe("validateAndFixToolResultIds", () => { describe("when there is no previous assistant message", () => { it("should return the user message unchanged", () => { - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "tool-123", - content: "Result", + type: "tool-result", + toolCallId: "tool-123", + toolName: "", + output: { type: "text" as const, value: "Result" }, }, ], } @@ -42,25 +44,26 @@ describe("validateAndFixToolResultIds", () => { describe("when tool_result IDs match tool_use IDs", () => { it("should return the user message unchanged for single tool", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-123", - name: "read_file", + type: "tool-call", + toolCallId: "tool-123", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "tool-123", - content: "File content", + type: "tool-result", + toolCallId: "tool-123", + toolName: "", + output: { type: "text" as const, value: "File content" }, }, ], } @@ -71,36 +74,38 @@ describe("validateAndFixToolResultIds", () => { }) it("should return the user message unchanged for multiple tools", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-1", - name: "read_file", + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", input: { path: "a.txt" }, }, { - type: "tool_use", - id: "tool-2", - name: "read_file", + type: "tool-call", + toolCallId: "tool-2", + toolName: "read_file", input: { path: "b.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "tool-1", - content: "Content A", + type: "tool-result", + toolCallId: "tool-1", + toolName: "", + output: { type: "text" as const, value: "Content A" }, }, { - type: "tool_result", - tool_use_id: "tool-2", - content: "Content B", + type: "tool-result", + toolCallId: "tool-2", + toolName: "", + output: { type: "text" as const, value: "Content B" }, }, ], } @@ -113,25 +118,26 @@ describe("validateAndFixToolResultIds", () => { describe("when tool_result IDs do not match tool_use IDs", () => { it("should fix single mismatched tool_use_id by position", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "correct-id-123", - name: "read_file", + type: "tool-call", + toolCallId: "correct-id-123", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "wrong-id-456", - content: "File content", + type: "tool-result", + toolCallId: "wrong-id-456", + toolName: "", + output: { type: "text" as const, value: "File content" }, }, ], } @@ -139,42 +145,44 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Anthropic.ToolResultBlockParam[] - expect(resultContent[0].tool_use_id).toBe("correct-id-123") - expect(resultContent[0].content).toBe("File content") + const resultContent = result.content as ToolResultPart[] + expect(resultContent[0].toolCallId).toBe("correct-id-123") + expect((resultContent[0].output as { value: string }).value).toBe("File content") }) it("should fix multiple mismatched tool_use_ids by position", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "correct-1", - name: "read_file", + type: "tool-call", + toolCallId: "correct-1", + toolName: "read_file", input: { path: "a.txt" }, }, { - type: "tool_use", - id: "correct-2", - name: "read_file", + type: "tool-call", + toolCallId: "correct-2", + toolName: "read_file", input: { path: "b.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "wrong-1", - content: "Content A", + type: "tool-result", + toolCallId: "wrong-1", + toolName: "", + output: { type: "text" as const, value: "Content A" }, }, { - type: "tool_result", - tool_use_id: "wrong-2", - content: "Content B", + type: "tool-result", + toolCallId: "wrong-2", + toolName: "", + output: { type: "text" as const, value: "Content B" }, }, ], } @@ -182,42 +190,44 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Anthropic.ToolResultBlockParam[] - expect(resultContent[0].tool_use_id).toBe("correct-1") - expect(resultContent[1].tool_use_id).toBe("correct-2") + const resultContent = result.content as ToolResultPart[] + expect(resultContent[0].toolCallId).toBe("correct-1") + expect(resultContent[1].toolCallId).toBe("correct-2") }) it("should partially fix when some IDs match and some don't", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "id-1", - name: "read_file", + type: "tool-call", + toolCallId: "id-1", + toolName: "read_file", input: { path: "a.txt" }, }, { - type: "tool_use", - id: "id-2", - name: "read_file", + type: "tool-call", + toolCallId: "id-2", + toolName: "read_file", input: { path: "b.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "id-1", // Correct - content: "Content A", + type: "tool-result", + toolCallId: "id-1", // Correct + toolName: "", + output: { type: "text" as const, value: "Content A" }, }, { - type: "tool_result", - tool_use_id: "wrong-id", // Wrong - content: "Content B", + type: "tool-result", + toolCallId: "wrong-id", // Wrong + toolName: "", + output: { type: "text" as const, value: "Content B" }, }, ], } @@ -225,33 +235,34 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Anthropic.ToolResultBlockParam[] - expect(resultContent[0].tool_use_id).toBe("id-1") - expect(resultContent[1].tool_use_id).toBe("id-2") + const resultContent = result.content as ToolResultPart[] + expect(resultContent[0].toolCallId).toBe("id-1") + expect(resultContent[1].toolCallId).toBe("id-2") }) }) describe("when user message has non-tool_result content", () => { it("should preserve text blocks alongside tool_result blocks", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-123", - name: "read_file", + type: "tool-call", + toolCallId: "tool-123", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "wrong-id", - content: "File content", + type: "tool-result", + toolCallId: "wrong-id", + toolName: "", + output: { type: "text" as const, value: "File content" }, }, { type: "text", @@ -263,17 +274,17 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Array - expect(resultContent[0].type).toBe("tool_result") - expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-123") + const resultContent = result.content as Array + expect(resultContent[0].type).toBe("tool-result") + expect((resultContent[0] as ToolResultPart).toolCallId).toBe("tool-123") expect(resultContent[1].type).toBe("text") - expect((resultContent[1] as Anthropic.TextBlockParam).text).toBe("Additional context") + expect((resultContent[1] as TextPart).text).toBe("Additional context") }) }) describe("when assistant message has non-tool_use content", () => { it("should only consider tool_use blocks for matching", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { @@ -281,21 +292,22 @@ describe("validateAndFixToolResultIds", () => { text: "Let me read that file for you.", }, { - type: "tool_use", - id: "tool-123", - name: "read_file", + type: "tool-call", + toolCallId: "tool-123", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "wrong-id", - content: "File content", + type: "tool-result", + toolCallId: "wrong-id", + toolName: "", + output: { type: "text" as const, value: "File content" }, }, ], } @@ -303,26 +315,26 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Anthropic.ToolResultBlockParam[] - expect(resultContent[0].tool_use_id).toBe("tool-123") + const resultContent = result.content as ToolResultPart[] + expect(resultContent[0].toolCallId).toBe("tool-123") }) }) describe("when user message content is a string", () => { it("should return the message unchanged", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-123", - name: "read_file", + type: "tool-call", + toolCallId: "tool-123", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: "Just a plain text message", } @@ -335,18 +347,19 @@ describe("validateAndFixToolResultIds", () => { describe("when assistant message content is a string", () => { it("should return the user message unchanged", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: "Just some text, no tool use", } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "tool-123", - content: "Result", + type: "tool-result", + toolCallId: "tool-123", + toolName: "", + output: { type: "text" as const, value: "Result" }, }, ], } @@ -359,30 +372,32 @@ describe("validateAndFixToolResultIds", () => { describe("when there are more tool_results than tool_uses", () => { it("should filter out orphaned tool_results with invalid IDs", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-1", - name: "read_file", + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "wrong-1", - content: "Content 1", + type: "tool-result", + toolCallId: "wrong-1", + toolName: "", + output: { type: "text" as const, value: "Content 1" }, }, { - type: "tool_result", - tool_use_id: "extra-id", - content: "Content 2", + type: "tool-result", + toolCallId: "extra-id", + toolName: "", + output: { type: "text" as const, value: "Content 2" }, }, ], } @@ -390,39 +405,41 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Anthropic.ToolResultBlockParam[] + const resultContent = result.content as ToolResultPart[] // Only one tool_result should remain - the first one gets fixed to tool-1 expect(resultContent.length).toBe(1) - expect(resultContent[0].tool_use_id).toBe("tool-1") + expect(resultContent[0].toolCallId).toBe("tool-1") }) it("should filter out duplicate tool_results when one already has a valid ID", () => { // This is the exact scenario from the PostHog error: // 2 tool_results (call_08230257, call_55577629), 1 tool_use (call_55577629) - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "call_55577629", - name: "read_file", + type: "tool-call", + toolCallId: "call_55577629", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "call_08230257", // Invalid ID - content: "Content from first result", + type: "tool-result", + toolCallId: "call_08230257", // Invalid ID + toolName: "", + output: { type: "text" as const, value: "Content from first result" }, }, { - type: "tool_result", - tool_use_id: "call_55577629", // Valid ID - content: "Content from second result", + type: "tool-result", + toolCallId: "call_55577629", // Valid ID + toolName: "", + output: { type: "text" as const, value: "Content from second result" }, }, ], } @@ -430,43 +447,45 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Anthropic.ToolResultBlockParam[] + const resultContent = result.content as ToolResultPart[] // Should only keep one tool_result since there's only one tool_use // The first invalid one gets fixed to the valid ID, then the second one // (which already has that ID) becomes a duplicate and is filtered out expect(resultContent.length).toBe(1) - expect(resultContent[0].tool_use_id).toBe("call_55577629") + expect(resultContent[0].toolCallId).toBe("call_55577629") }) it("should preserve text blocks while filtering orphaned tool_results", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-1", - name: "read_file", + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "wrong-1", - content: "Content 1", + type: "tool-result", + toolCallId: "wrong-1", + toolName: "", + output: { type: "text" as const, value: "Content 1" }, }, { type: "text", text: "Some additional context", }, { - type: "tool_result", - tool_use_id: "extra-id", - content: "Content 2", + type: "tool-result", + toolCallId: "extra-id", + toolName: "", + output: { type: "text" as const, value: "Content 2" }, }, ], } @@ -474,43 +493,48 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Array + const resultContent = result.content as Array // Should have tool_result + text block, orphaned tool_result filtered out expect(resultContent.length).toBe(2) - expect(resultContent[0].type).toBe("tool_result") - expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-1") + expect(resultContent[0].type).toBe("tool-result") + expect((resultContent[0] as ToolResultPart).toolCallId).toBe("tool-1") expect(resultContent[1].type).toBe("text") - expect((resultContent[1] as Anthropic.TextBlockParam).text).toBe("Some additional context") + expect((resultContent[1] as TextPart).text).toBe("Some additional context") }) // Verifies fix for GitHub #10465: Terminal fallback race condition can generate // duplicate tool_results with the same valid tool_use_id, causing API protocol violations. it("should filter out duplicate tool_results with identical valid tool_use_ids (terminal fallback scenario)", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tooluse_QZ-pU8v2QKO8L8fHoJRI2g", - name: "execute_command", + type: "tool-call", + toolCallId: "tooluse_QZ-pU8v2QKO8L8fHoJRI2g", + toolName: "execute_command", input: { command: "ps aux | grep test", cwd: "/path/to/project" }, }, ], } // Two tool_results with the SAME valid tool_use_id from terminal fallback race condition - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "tooluse_QZ-pU8v2QKO8L8fHoJRI2g", // First result from command execution - content: "No test processes found", + type: "tool-result", + toolCallId: "tooluse_QZ-pU8v2QKO8L8fHoJRI2g", // First result from command execution + toolName: "", + output: { type: "text" as const, value: "No test processes found" }, }, { - type: "tool_result", - tool_use_id: "tooluse_QZ-pU8v2QKO8L8fHoJRI2g", // Duplicate from user approval during fallback - content: '{"status":"approved","message":"The user approved this operation"}', + type: "tool-result", + toolCallId: "tooluse_QZ-pU8v2QKO8L8fHoJRI2g", // Duplicate from user approval during fallback + toolName: "", + output: { + type: "text" as const, + value: '{"status":"approved","message":"The user approved this operation"}', + }, }, ], } @@ -518,43 +542,45 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Anthropic.ToolResultBlockParam[] + const resultContent = result.content as ToolResultPart[] // Only ONE tool_result should remain to prevent API protocol violation expect(resultContent.length).toBe(1) - expect(resultContent[0].tool_use_id).toBe("tooluse_QZ-pU8v2QKO8L8fHoJRI2g") - expect(resultContent[0].content).toBe("No test processes found") + expect(resultContent[0].toolCallId).toBe("tooluse_QZ-pU8v2QKO8L8fHoJRI2g") + expect((resultContent[0].output as { value: string }).value).toBe("No test processes found") }) it("should preserve text blocks while deduplicating tool_results with same valid ID", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-123", - name: "read_file", + type: "tool-call", + toolCallId: "tool-123", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "tool-123", - content: "First result", + type: "tool-result", + toolCallId: "tool-123", + toolName: "", + output: { type: "text" as const, value: "First result" }, }, { type: "text", text: "Environment details here", }, { - type: "tool_result", - tool_use_id: "tool-123", // Duplicate with same valid ID - content: "Duplicate result from fallback", + type: "tool-result", + toolCallId: "tool-123", // Duplicate with same valid ID + toolName: "", + output: { type: "text" as const, value: "Duplicate result from fallback" }, }, ], } @@ -562,45 +588,46 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Array + const resultContent = result.content as Array // Should have: 1 tool_result + 1 text block (duplicate filtered out) expect(resultContent.length).toBe(2) - expect(resultContent[0].type).toBe("tool_result") - expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-123") - expect((resultContent[0] as Anthropic.ToolResultBlockParam).content).toBe("First result") + expect(resultContent[0].type).toBe("tool-result") + expect((resultContent[0] as ToolResultPart).toolCallId).toBe("tool-123") + expect((resultContent[0] as ToolResultPart).output).toEqual({ type: "text", value: "First result" }) expect(resultContent[1].type).toBe("text") - expect((resultContent[1] as Anthropic.TextBlockParam).text).toBe("Environment details here") + expect((resultContent[1] as TextPart).text).toBe("Environment details here") }) }) describe("when there are more tool_uses than tool_results", () => { it("should fix the available tool_results and add missing ones", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-1", - name: "read_file", + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", input: { path: "a.txt" }, }, { - type: "tool_use", - id: "tool-2", - name: "read_file", + type: "tool-call", + toolCallId: "tool-2", + toolName: "read_file", input: { path: "b.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "wrong-1", - content: "Content 1", + type: "tool-result", + toolCallId: "wrong-1", + toolName: "", + output: { type: "text" as const, value: "Content 1" }, }, ], } @@ -608,32 +635,34 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Anthropic.ToolResultBlockParam[] + const resultContent = result.content as ToolResultPart[] // Should now have 2 tool_results: one fixed and one added for the missing tool_use expect(resultContent.length).toBe(2) // The missing tool_result is prepended - expect(resultContent[0].tool_use_id).toBe("tool-2") - expect(resultContent[0].content).toBe("Tool execution was interrupted before completion.") + expect(resultContent[0].toolCallId).toBe("tool-2") + expect((resultContent[0].output as { value: string }).value).toBe( + "Tool execution was interrupted before completion.", + ) // The original is fixed - expect(resultContent[1].tool_use_id).toBe("tool-1") + expect(resultContent[1].toolCallId).toBe("tool-1") }) }) describe("when tool_results are completely missing", () => { it("should add missing tool_result for single tool_use", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-123", - name: "read_file", + type: "tool-call", + toolCallId: "tool-123", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { @@ -646,38 +675,39 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Array + const resultContent = result.content as Array expect(resultContent.length).toBe(2) // Missing tool_result should be prepended - expect(resultContent[0].type).toBe("tool_result") - expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-123") - expect((resultContent[0] as Anthropic.ToolResultBlockParam).content).toBe( - "Tool execution was interrupted before completion.", - ) + expect(resultContent[0].type).toBe("tool-result") + expect((resultContent[0] as ToolResultPart).toolCallId).toBe("tool-123") + expect((resultContent[0] as ToolResultPart).output).toEqual({ + type: "text", + value: "Tool execution was interrupted before completion.", + }) // Original text block should be preserved expect(resultContent[1].type).toBe("text") }) it("should add missing tool_results for multiple tool_uses", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-1", - name: "read_file", + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", input: { path: "a.txt" }, }, { - type: "tool_use", - id: "tool-2", - name: "write_to_file", + type: "tool-call", + toolCallId: "tool-2", + toolName: "write_to_file", input: { path: "b.txt", content: "test" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { @@ -690,43 +720,44 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Array + const resultContent = result.content as Array expect(resultContent.length).toBe(3) // Both missing tool_results should be prepended - expect(resultContent[0].type).toBe("tool_result") - expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-1") - expect(resultContent[1].type).toBe("tool_result") - expect((resultContent[1] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-2") + expect(resultContent[0].type).toBe("tool-result") + expect((resultContent[0] as ToolResultPart).toolCallId).toBe("tool-1") + expect(resultContent[1].type).toBe("tool-result") + expect((resultContent[1] as ToolResultPart).toolCallId).toBe("tool-2") // Original text should be preserved expect(resultContent[2].type).toBe("text") }) it("should add only the missing tool_results when some exist", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-1", - name: "read_file", + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", input: { path: "a.txt" }, }, { - type: "tool_use", - id: "tool-2", - name: "write_to_file", + type: "tool-call", + toolCallId: "tool-2", + toolName: "write_to_file", input: { path: "b.txt", content: "test" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "tool-1", - content: "Content for tool 1", + type: "tool-result", + toolCallId: "tool-1", + toolName: "", + output: { type: "text" as const, value: "Content for tool 1" }, }, ], } @@ -734,30 +765,32 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Anthropic.ToolResultBlockParam[] + const resultContent = result.content as ToolResultPart[] expect(resultContent.length).toBe(2) // Missing tool_result for tool-2 should be prepended - expect(resultContent[0].tool_use_id).toBe("tool-2") - expect(resultContent[0].content).toBe("Tool execution was interrupted before completion.") + expect(resultContent[0].toolCallId).toBe("tool-2") + expect((resultContent[0].output as { value: string }).value).toBe( + "Tool execution was interrupted before completion.", + ) // Existing tool_result should be preserved - expect(resultContent[1].tool_use_id).toBe("tool-1") - expect(resultContent[1].content).toBe("Content for tool 1") + expect(resultContent[1].toolCallId).toBe("tool-1") + expect((resultContent[1].output as { value: string }).value).toBe("Content for tool 1") }) it("should handle empty user content array by adding all missing tool_results", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-1", - name: "read_file", + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [], } @@ -765,35 +798,38 @@ describe("validateAndFixToolResultIds", () => { const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) expect(Array.isArray(result.content)).toBe(true) - const resultContent = result.content as Anthropic.ToolResultBlockParam[] + const resultContent = result.content as ToolResultPart[] expect(resultContent.length).toBe(1) - expect(resultContent[0].type).toBe("tool_result") - expect(resultContent[0].tool_use_id).toBe("tool-1") - expect(resultContent[0].content).toBe("Tool execution was interrupted before completion.") + expect(resultContent[0].type).toBe("tool-result") + expect(resultContent[0].toolCallId).toBe("tool-1") + expect((resultContent[0].output as { value: string }).value).toBe( + "Tool execution was interrupted before completion.", + ) }) }) describe("telemetry", () => { it("should call captureException for both missing and mismatch when there is a mismatch", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "correct-id", - name: "read_file", + type: "tool-call", + toolCallId: "correct-id", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "wrong-id", - content: "Content", + type: "tool-result", + toolCallId: "wrong-id", + toolName: "", + output: { type: "text" as const, value: "Content" }, }, ], } @@ -823,25 +859,26 @@ describe("validateAndFixToolResultIds", () => { }) it("should not call captureException when IDs match", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-123", - name: "read_file", + type: "tool-call", + toolCallId: "tool-123", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "tool-123", - content: "Content", + type: "tool-result", + toolCallId: "tool-123", + toolName: "", + output: { type: "text" as const, value: "Content" }, }, ], } @@ -884,19 +921,19 @@ describe("validateAndFixToolResultIds", () => { describe("telemetry for missing tool_results", () => { it("should call captureException when tool_results are missing", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-123", - name: "read_file", + type: "tool-call", + toolCallId: "tool-123", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { @@ -921,31 +958,32 @@ describe("validateAndFixToolResultIds", () => { }) it("should call captureException twice when both mismatch and missing occur", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-1", - name: "read_file", + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", input: { path: "a.txt" }, }, { - type: "tool_use", - id: "tool-2", - name: "read_file", + type: "tool-call", + toolCallId: "tool-2", + toolName: "read_file", input: { path: "b.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "wrong-id", // Wrong ID (mismatch) - content: "Content", + type: "tool-result", + toolCallId: "wrong-id", // Wrong ID (mismatch) + toolName: "", + output: { type: "text" as const, value: "Content" }, }, // Missing tool_result for tool-2 ], @@ -966,25 +1004,26 @@ describe("validateAndFixToolResultIds", () => { }) it("should not call captureException for missing when all tool_results exist", () => { - const assistantMessage: Anthropic.MessageParam = { + const assistantMessage: RooMessageParam = { role: "assistant", content: [ { - type: "tool_use", - id: "tool-123", - name: "read_file", + type: "tool-call", + toolCallId: "tool-123", + toolName: "read_file", input: { path: "test.txt" }, }, ], } - const userMessage: Anthropic.MessageParam = { + const userMessage: RooMessageParam = { role: "user", content: [ { - type: "tool_result", - tool_use_id: "tool-123", - content: "Content", + type: "tool-result", + toolCallId: "tool-123", + toolName: "", + output: { type: "text" as const, value: "Content" }, }, ], } diff --git a/src/core/task/mergeConsecutiveApiMessages.ts b/src/core/task/mergeConsecutiveApiMessages.ts index d46d681a94c..0fc288bc9f9 100644 --- a/src/core/task/mergeConsecutiveApiMessages.ts +++ b/src/core/task/mergeConsecutiveApiMessages.ts @@ -1,12 +1,10 @@ -import { Anthropic } from "@anthropic-ai/sdk" - -import type { ApiMessage } from "../task-persistence" +import type { ApiMessage, NeutralContentBlock } from "../task-persistence" type Role = ApiMessage["role"] -function normalizeContentToBlocks(content: ApiMessage["content"]): Anthropic.Messages.ContentBlockParam[] { +function normalizeContentToBlocks(content: ApiMessage["content"]): NeutralContentBlock[] { if (Array.isArray(content)) { - return content as Anthropic.Messages.ContentBlockParam[] + return content as NeutralContentBlock[] } if (content === undefined || content === null) { return [] diff --git a/src/core/task/validateToolResultIds.ts b/src/core/task/validateToolResultIds.ts index a966d429ed5..b7450cd530a 100644 --- a/src/core/task/validateToolResultIds.ts +++ b/src/core/task/validateToolResultIds.ts @@ -1,6 +1,11 @@ -import { Anthropic } from "@anthropic-ai/sdk" import { TelemetryService } from "@roo-code/telemetry" import { findLastIndex } from "../../shared/array" +import type { + NeutralMessageParam, + NeutralContentBlock, + NeutralToolUseBlock, + NeutralToolResultBlock, +} from "../task-persistence" /** * Custom error class for tool result ID mismatches. @@ -48,9 +53,9 @@ export class MissingToolResultError extends Error { * @returns The validated user message with corrected tool_use_ids and any missing tool_results added */ export function validateAndFixToolResultIds( - userMessage: Anthropic.MessageParam, - apiConversationHistory: Anthropic.MessageParam[], -): Anthropic.MessageParam { + userMessage: NeutralMessageParam, + apiConversationHistory: NeutralMessageParam[], +): NeutralMessageParam { // Only process user messages with array content if (userMessage.role !== "user" || !Array.isArray(userMessage.content)) { return userMessage @@ -70,7 +75,7 @@ export function validateAndFixToolResultIds( return userMessage } - const toolUseBlocks = assistantContent.filter((block): block is Anthropic.ToolUseBlock => block.type === "tool_use") + const toolUseBlocks = assistantContent.filter((block): block is NeutralToolUseBlock => block.type === "tool-call") // No tool_use blocks to match against - no validation needed if (toolUseBlocks.length === 0) { @@ -79,7 +84,7 @@ export function validateAndFixToolResultIds( // Find tool_result blocks in the user message let toolResults = userMessage.content.filter( - (block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result", + (block): block is NeutralToolResultBlock => block.type === "tool-result", ) // Deduplicate tool_result blocks to prevent API protocol violations (GitHub #10465) @@ -89,13 +94,13 @@ export function validateAndFixToolResultIds( // deduplication remains as a defensive measure for unknown edge cases. const seenToolResultIds = new Set() const deduplicatedContent = userMessage.content.filter((block) => { - if (block.type !== "tool_result") { + if (block.type !== "tool-result") { return true } - if (seenToolResultIds.has(block.tool_use_id)) { + if (seenToolResultIds.has(block.toolCallId)) { return false // Duplicate - filter out } - seenToolResultIds.add(block.tool_use_id) + seenToolResultIds.add(block.toolCallId) return true }) @@ -104,23 +109,21 @@ export function validateAndFixToolResultIds( content: deduplicatedContent, } - toolResults = deduplicatedContent.filter( - (block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result", - ) + toolResults = deduplicatedContent.filter((block): block is NeutralToolResultBlock => block.type === "tool-result") // Build a set of valid tool_use IDs - const validToolUseIds = new Set(toolUseBlocks.map((block) => block.id)) + const validToolUseIds = new Set(toolUseBlocks.map((block) => block.toolCallId)) // Build a set of existing tool_result IDs - const existingToolResultIds = new Set(toolResults.map((r) => r.tool_use_id)) + const existingToolResultIds = new Set(toolResults.map((r) => r.toolCallId)) // Check for missing tool_results (tool_use IDs that don't have corresponding tool_results) const missingToolUseIds = toolUseBlocks - .filter((toolUse) => !existingToolResultIds.has(toolUse.id)) - .map((toolUse) => toolUse.id) + .filter((toolUse) => !existingToolResultIds.has(toolUse.toolCallId)) + .map((toolUse) => toolUse.toolCallId) // Check if any tool_result has an invalid ID - const hasInvalidIds = toolResults.some((result) => !validToolUseIds.has(result.tool_use_id)) + const hasInvalidIds = toolResults.some((result) => !validToolUseIds.has(result.toolCallId)) // If no missing tool_results and no invalid IDs, no changes needed if (missingToolUseIds.length === 0 && !hasInvalidIds) { @@ -128,8 +131,8 @@ export function validateAndFixToolResultIds( } // We have issues - need to fix them - const toolResultIdList = toolResults.map((r) => r.tool_use_id) - const toolUseIdList = toolUseBlocks.map((b) => b.id) + const toolResultIdList = toolResults.map((r) => r.toolCallId) + const toolUseIdList = toolUseBlocks.map((b) => b.toolCallId) // Report missing tool_results to PostHog error tracking if (missingToolUseIds.length > 0 && TelemetryService.hasInstance()) { @@ -167,34 +170,34 @@ export function validateAndFixToolResultIds( // Match tool_results to tool_uses by position and fix incorrect IDs const usedToolUseIds = new Set() - const contentArray = userMessage.content as Anthropic.Messages.ContentBlockParam[] + const contentArray = userMessage.content as NeutralContentBlock[] const correctedContent = contentArray - .map((block: Anthropic.Messages.ContentBlockParam) => { - if (block.type !== "tool_result") { + .map((block: NeutralContentBlock) => { + if (block.type !== "tool-result") { return block } // If the ID is already valid and not yet used, keep it - if (validToolUseIds.has(block.tool_use_id) && !usedToolUseIds.has(block.tool_use_id)) { - usedToolUseIds.add(block.tool_use_id) + if (validToolUseIds.has(block.toolCallId) && !usedToolUseIds.has(block.toolCallId)) { + usedToolUseIds.add(block.toolCallId) return block } // Find which tool_result index this block is by comparing references. - // This correctly handles duplicate tool_use_ids - we find the actual block's + // This correctly handles duplicate toolCallIds - we find the actual block's // position among all tool_results, not the first block with a matching ID. - const toolResultIndex = toolResults.indexOf(block as Anthropic.ToolResultBlockParam) + const toolResultIndex = toolResults.indexOf(block as NeutralToolResultBlock) // Try to match by position - only fix if there's a corresponding tool_use if (toolResultIndex !== -1 && toolResultIndex < toolUseBlocks.length) { - const correctId = toolUseBlocks[toolResultIndex].id + const correctId = toolUseBlocks[toolResultIndex].toolCallId // Only use this ID if it hasn't been used yet if (!usedToolUseIds.has(correctId)) { usedToolUseIds.add(correctId) return { ...block, - tool_use_id: correctId, + toolCallId: correctId, } } } @@ -207,20 +210,18 @@ export function validateAndFixToolResultIds( // Add missing tool_result blocks for any tool_use that doesn't have one const coveredToolUseIds = new Set( correctedContent - .filter( - (b: Anthropic.Messages.ContentBlockParam): b is Anthropic.ToolResultBlockParam => - b.type === "tool_result", - ) - .map((r: Anthropic.ToolResultBlockParam) => r.tool_use_id), + .filter((b: NeutralContentBlock): b is NeutralToolResultBlock => b.type === "tool-result") + .map((r: NeutralToolResultBlock) => r.toolCallId), ) - const stillMissingToolUseIds = toolUseBlocks.filter((toolUse) => !coveredToolUseIds.has(toolUse.id)) + const stillMissingToolUseIds = toolUseBlocks.filter((toolUse) => !coveredToolUseIds.has(toolUse.toolCallId)) // Build final content: add missing tool_results at the beginning if any - const missingToolResults: Anthropic.ToolResultBlockParam[] = stillMissingToolUseIds.map((toolUse) => ({ - type: "tool_result" as const, - tool_use_id: toolUse.id, - content: "Tool execution was interrupted before completion.", + const missingToolResults: NeutralToolResultBlock[] = stillMissingToolUseIds.map((toolUse) => ({ + type: "tool-result" as const, + toolCallId: toolUse.toolCallId, + toolName: toolUse.toolName, + output: { type: "text" as const, value: "Tool execution was interrupted before completion." }, })) // Insert missing tool_results at the beginning of the content array diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 5ca7002ff2d..b811fa8b04f 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -157,13 +157,14 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { let toolProgressStatus if (task.diffStrategy && task.diffStrategy.getProgressStatus) { - const block: ToolUse<"apply_diff"> = { - type: "tool_use", - name: "apply_diff", - params: { path: relPath, diff: diffContent }, + const progressBlock: ToolUse = { + type: "tool-call", + toolCallId: "", + toolName: "apply_diff", + input: { path: relPath, diff: diffContent }, partial: false, } - toolProgressStatus = task.diffStrategy.getProgressStatus(block, diffResult) + toolProgressStatus = task.diffStrategy.getProgressStatus(progressBlock, diffResult) } const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) @@ -201,13 +202,14 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { let toolProgressStatus if (task.diffStrategy && task.diffStrategy.getProgressStatus) { - const block: ToolUse<"apply_diff"> = { - type: "tool_use", - name: "apply_diff", - params: { path: relPath, diff: diffContent }, + const progressBlock: ToolUse = { + type: "tool-call", + toolCallId: "", + toolName: "apply_diff", + input: { path: relPath, diff: diffContent }, partial: false, } - toolProgressStatus = task.diffStrategy.getProgressStatus(block, diffResult) + toolProgressStatus = task.diffStrategy.getProgressStatus(progressBlock, diffResult) } const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) @@ -267,9 +269,9 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } } - override async handlePartial(task: Task, block: ToolUse<"apply_diff">): Promise { - const relPath: string | undefined = block.params.path - const diffContent: string | undefined = block.params.diff + override async handlePartial(task: Task, block: ToolUse): Promise { + const relPath: string | undefined = block.input.path + const diffContent: string | undefined = block.input.diff // Wait for path to stabilize before showing UI (prevents truncated paths) if (!this.hasPathStabilized(relPath)) { diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 0c3a1765f22..ff075dacc64 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -420,8 +420,8 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task.processQueuedMessages() } - override async handlePartial(task: Task, block: ToolUse<"apply_patch">): Promise { - const patch: string | undefined = block.params.patch + override async handlePartial(task: Task, block: ToolUse): Promise { + const patch: string | undefined = block.input.patch let patchPreview: string | undefined if (patch) { diff --git a/src/core/tools/AskFollowupQuestionTool.ts b/src/core/tools/AskFollowupQuestionTool.ts index 010a6240f1e..45fa0dd3cd5 100644 --- a/src/core/tools/AskFollowupQuestionTool.ts +++ b/src/core/tools/AskFollowupQuestionTool.ts @@ -45,8 +45,8 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { } } - override async handlePartial(task: Task, block: ToolUse<"ask_followup_question">): Promise { - const question: string | undefined = block.nativeArgs?.question ?? block.params.question + override async handlePartial(task: Task, block: ToolUse): Promise { + const question: string | undefined = block.nativeArgs?.question ?? block.input.question // During partial streaming, only show the question to avoid displaying raw JSON // The full JSON with suggestions will be sent when the tool call is complete (!block.partial) diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index a406a15c8b4..b39fa93e7f4 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -179,9 +179,9 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { return true } - override async handlePartial(task: Task, block: ToolUse<"attempt_completion">): Promise { - const result: string | undefined = block.params.result - const command: string | undefined = block.params.command + override async handlePartial(task: Task, block: ToolUse): Promise { + const result: string | undefined = block.input.result + const command: string | undefined = block.input.command const lastMessage = task.clineMessages.at(-1) diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 7d574068a97..95a7d5fa2b9 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -58,7 +58,7 @@ export abstract class BaseTool { * @param task - Task instance * @param block - Partial ToolUse block */ - async handlePartial(task: Task, block: ToolUse): Promise { + async handlePartial(task: Task, block: ToolUse): Promise { // Default: no-op for partial messages // Tools can override to show streaming UI updates } @@ -110,7 +110,7 @@ export abstract class BaseTool { * @param block - ToolUse block from assistant message * @param callbacks - Tool execution callbacks */ - async handle(task: Task, block: ToolUse, callbacks: ToolCallbacks): Promise { + async handle(task: Task, block: ToolUse, callbacks: ToolCallbacks): Promise { // Handle partial messages if (block.partial) { try { @@ -135,7 +135,7 @@ export abstract class BaseTool { // If legacy/XML markup was provided via params, surface a clear error. const paramsText = (() => { try { - return JSON.stringify(block.params ?? {}) + return JSON.stringify(block.input ?? {}) } catch { return "" } diff --git a/src/core/tools/BrowserActionTool.ts b/src/core/tools/BrowserActionTool.ts index 3bd584e0cb4..0c7f4153c6b 100644 --- a/src/core/tools/BrowserActionTool.ts +++ b/src/core/tools/BrowserActionTool.ts @@ -1,4 +1,4 @@ -import { Anthropic } from "@anthropic-ai/sdk" +import { NeutralTextBlock } from "../task-persistence" import { BrowserAction, BrowserActionResult, browserActions, ClineSayBrowserAction } from "@roo-code/types" @@ -15,12 +15,12 @@ export async function browserActionTool( handleError: HandleError, pushToolResult: PushToolResult, ) { - const action: BrowserAction | undefined = block.params.action as BrowserAction - const url: string | undefined = block.params.url - const coordinate: string | undefined = block.params.coordinate - const text: string | undefined = block.params.text - const size: string | undefined = block.params.size - const filePath: string | undefined = block.params.path + const action: BrowserAction | undefined = block.input.action as BrowserAction + const url: string | undefined = block.input.url + const coordinate: string | undefined = block.input.coordinate + const text: string | undefined = block.input.text + const size: string | undefined = block.input.size + const filePath: string | undefined = block.input.path if (!action || !browserActions.includes(action)) { // checking for action to ensure it is complete and valid @@ -251,7 +251,7 @@ export async function browserActionTool( if (images.length > 0) { const blocks = [ ...formatResponse.imageBlocks(images), - { type: "text", text: messageText } as Anthropic.TextBlockParam, + { type: "text", text: messageText } as NeutralTextBlock, ] pushToolResult(blocks) } else { diff --git a/src/core/tools/CodebaseSearchTool.ts b/src/core/tools/CodebaseSearchTool.ts index f0d906fabd8..51f62de0892 100644 --- a/src/core/tools/CodebaseSearchTool.ts +++ b/src/core/tools/CodebaseSearchTool.ts @@ -128,9 +128,9 @@ Code Chunk: ${result.codeChunk} } } - override async handlePartial(task: Task, block: ToolUse<"codebase_search">): Promise { - const query: string | undefined = block.params.query - const directoryPrefix: string | undefined = block.params.path + override async handlePartial(task: Task, block: ToolUse): Promise { + const query: string | undefined = block.input.query + const directoryPrefix: string | undefined = block.input.path const sharedMessageProps = { tool: "codebaseSearch", diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 2495a372bc5..f61633f220b 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -484,9 +484,9 @@ export class EditFileTool extends BaseTool<"edit_file"> { } } - override async handlePartial(task: Task, block: ToolUse<"edit_file">): Promise { - const filePath: string | undefined = block.params.file_path - const oldString: string | undefined = block.params.old_string + override async handlePartial(task: Task, block: ToolUse): Promise { + const filePath: string | undefined = block.input.file_path + const oldString: string | undefined = block.input.old_string // Wait for path to stabilize before showing UI (prevents truncated paths) if (!this.hasPathStabilized(filePath)) { diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index fca3cf7a313..0153a6570cc 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -131,8 +131,8 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } } - override async handlePartial(task: Task, block: ToolUse<"execute_command">): Promise { - const command = block.params.command + override async handlePartial(task: Task, block: ToolUse): Promise { + const command = block.input.command await task.ask("command", command ?? "", block.partial).catch(() => {}) } } diff --git a/src/core/tools/GenerateImageTool.ts b/src/core/tools/GenerateImageTool.ts index 3eaa2d84c2d..01bd6f56af9 100644 --- a/src/core/tools/GenerateImageTool.ts +++ b/src/core/tools/GenerateImageTool.ts @@ -262,7 +262,7 @@ export class GenerateImageTool extends BaseTool<"generate_image"> { } } - override async handlePartial(task: Task, block: ToolUse<"generate_image">): Promise { + override async handlePartial(task: Task, block: ToolUse): Promise { return } } diff --git a/src/core/tools/ListFilesTool.ts b/src/core/tools/ListFilesTool.ts index 716d7ed7848..91d486727b5 100644 --- a/src/core/tools/ListFilesTool.ts +++ b/src/core/tools/ListFilesTool.ts @@ -68,9 +68,9 @@ export class ListFilesTool extends BaseTool<"list_files"> { } } - override async handlePartial(task: Task, block: ToolUse<"list_files">): Promise { - const relDirPath: string | undefined = block.params.path - const recursiveRaw: string | undefined = block.params.recursive + override async handlePartial(task: Task, block: ToolUse): Promise { + const relDirPath: string | undefined = block.input.path + const recursiveRaw: string | undefined = block.input.recursive const recursive = recursiveRaw?.toLowerCase() === "true" const absolutePath = relDirPath ? path.resolve(task.cwd, relDirPath) : task.cwd diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index f36d8e1e379..cfbaf68d2fd 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -126,10 +126,10 @@ export class NewTaskTool extends BaseTool<"new_task"> { } } - override async handlePartial(task: Task, block: ToolUse<"new_task">): Promise { - const mode: string | undefined = block.params.mode - const message: string | undefined = block.params.message - const todos: string | undefined = block.params.todos + override async handlePartial(task: Task, block: ToolUse): Promise { + const mode: string | undefined = block.input.mode + const message: string | undefined = block.input.message + const todos: string | undefined = block.input.todos const partialMessage = JSON.stringify({ tool: "newTask", diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 8ad6a3b33d1..b926653f867 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -635,7 +635,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { return `[${blockName} with missing path]` } - override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise { + override async handlePartial(task: Task, block: ToolUse): Promise { // Handle both legacy and new format for partial display let filePath = "" if (block.nativeArgs) { diff --git a/src/core/tools/RunSlashCommandTool.ts b/src/core/tools/RunSlashCommandTool.ts index 0bcf970226f..60e9ca8d23b 100644 --- a/src/core/tools/RunSlashCommandTool.ts +++ b/src/core/tools/RunSlashCommandTool.ts @@ -115,9 +115,9 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { } } - override async handlePartial(task: Task, block: ToolUse<"run_slash_command">): Promise { - const commandName: string | undefined = block.params.command - const args: string | undefined = block.params.args + override async handlePartial(task: Task, block: ToolUse): Promise { + const commandName: string | undefined = block.input.command + const args: string | undefined = block.input.args const partialMessage = JSON.stringify({ tool: "runSlashCommand", diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts index 93c3b4533b7..27349f6ab93 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -254,15 +254,15 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { } } - override async handlePartial(task: Task, block: ToolUse<"search_and_replace">): Promise { - const relPath: string | undefined = block.params.path + override async handlePartial(task: Task, block: ToolUse): Promise { + const relPath: string | undefined = block.input.path // Wait for path to stabilize before showing UI (prevents truncated paths) if (!this.hasPathStabilized(relPath)) { return } - const operationsStr: string | undefined = block.params.operations + const operationsStr: string | undefined = block.input.operations let operationsPreview: string | undefined if (operationsStr) { diff --git a/src/core/tools/SearchFilesTool.ts b/src/core/tools/SearchFilesTool.ts index 3230c043e04..a24f480a8b5 100644 --- a/src/core/tools/SearchFilesTool.ts +++ b/src/core/tools/SearchFilesTool.ts @@ -71,10 +71,10 @@ export class SearchFilesTool extends BaseTool<"search_files"> { } } - override async handlePartial(task: Task, block: ToolUse<"search_files">): Promise { - const relDirPath = block.params.path - const regex = block.params.regex - const filePattern = block.params.file_pattern + override async handlePartial(task: Task, block: ToolUse): Promise { + const relDirPath = block.input.path + const regex = block.input.regex + const filePattern = block.input.file_pattern const absolutePath = relDirPath ? path.resolve(task.cwd, relDirPath) : task.cwd const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index 2d8817364ff..f74948a9ca2 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -239,9 +239,9 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { } } - override async handlePartial(task: Task, block: ToolUse<"search_replace">): Promise { - const filePath: string | undefined = block.params.file_path - const oldString: string | undefined = block.params.old_string + override async handlePartial(task: Task, block: ToolUse): Promise { + const filePath: string | undefined = block.input.file_path + const oldString: string | undefined = block.input.old_string // Wait for path to stabilize before showing UI (prevents truncated paths) if (!this.hasPathStabilized(filePath)) { diff --git a/src/core/tools/SkillTool.ts b/src/core/tools/SkillTool.ts index e346f9924c3..c5848ae6bc5 100644 --- a/src/core/tools/SkillTool.ts +++ b/src/core/tools/SkillTool.ts @@ -95,9 +95,9 @@ export class SkillTool extends BaseTool<"skill"> { } } - override async handlePartial(task: Task, block: ToolUse<"skill">): Promise { - const skillName: string | undefined = block.params.skill - const args: string | undefined = block.params.args + override async handlePartial(task: Task, block: ToolUse): Promise { + const skillName: string | undefined = block.input.skill + const args: string | undefined = block.input.args const partialMessage = JSON.stringify({ tool: "skill", diff --git a/src/core/tools/SwitchModeTool.ts b/src/core/tools/SwitchModeTool.ts index a60ce63bded..e96312384fe 100644 --- a/src/core/tools/SwitchModeTool.ts +++ b/src/core/tools/SwitchModeTool.ts @@ -70,9 +70,9 @@ export class SwitchModeTool extends BaseTool<"switch_mode"> { } } - override async handlePartial(task: Task, block: ToolUse<"switch_mode">): Promise { - const mode_slug: string | undefined = block.params.mode_slug - const reason: string | undefined = block.params.reason + override async handlePartial(task: Task, block: ToolUse): Promise { + const mode_slug: string | undefined = block.input.mode_slug + const reason: string | undefined = block.input.reason const partialMessage = JSON.stringify({ tool: "switchMode", diff --git a/src/core/tools/ToolRepetitionDetector.ts b/src/core/tools/ToolRepetitionDetector.ts index 9e70bb41a00..618e6b1f983 100644 --- a/src/core/tools/ToolRepetitionDetector.ts +++ b/src/core/tools/ToolRepetitionDetector.ts @@ -65,7 +65,7 @@ export class ToolRepetitionDetector { allowExecution: false, askUser: { messageKey: "mistake_limit_reached", - messageDetail: t("tools:toolRepetitionLimitReached", { toolName: currentToolCallBlock.name }), + messageDetail: t("tools:toolRepetitionLimitReached", { toolName: currentToolCallBlock.toolName }), }, } } @@ -81,11 +81,11 @@ export class ToolRepetitionDetector { * @returns true if the tool is a browser_action with scroll_down or scroll_up action */ private isBrowserScrollAction(toolUse: ToolUse): boolean { - if (toolUse.name !== "browser_action") { + if (toolUse.toolName !== "browser_action") { return false } - const action = toolUse.params.action as string + const action = toolUse.input.action as string return action === "scroll_down" || action === "scroll_up" } @@ -97,8 +97,8 @@ export class ToolRepetitionDetector { */ private serializeToolUse(toolUse: ToolUse): string { const toolObject: Record = { - name: toolUse.name, - params: toolUse.params, + name: toolUse.toolName, + params: toolUse.input, } // Only include nativeArgs if it has content diff --git a/src/core/tools/UpdateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts index 7414b713cf4..2122d036a30 100644 --- a/src/core/tools/UpdateTodoListTool.ts +++ b/src/core/tools/UpdateTodoListTool.ts @@ -86,8 +86,8 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> { } } - override async handlePartial(task: Task, block: ToolUse<"update_todo_list">): Promise { - const todosRaw = block.params.todos + override async handlePartial(task: Task, block: ToolUse): Promise { + const todosRaw = block.input.todos // Parse the markdown checklist to maintain consistent format with execute() let todos: TodoItem[] diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts index 7cbc09bfd7b..6d1b63f9698 100644 --- a/src/core/tools/UseMcpToolTool.ts +++ b/src/core/tools/UseMcpToolTool.ts @@ -80,8 +80,8 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { } } - override async handlePartial(task: Task, block: ToolUse<"use_mcp_tool">): Promise { - const params = block.params + override async handlePartial(task: Task, block: ToolUse): Promise { + const params = block.input const partialMessage = JSON.stringify({ type: "use_mcp_tool", serverName: params.server_name ?? "", diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index c8455ef3d97..6b0d286870e 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -193,9 +193,9 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } } - override async handlePartial(task: Task, block: ToolUse<"write_to_file">): Promise { - const relPath: string | undefined = block.params.path - let newContent: string | undefined = block.params.content + override async handlePartial(task: Task, block: ToolUse): Promise { + const relPath: string | undefined = block.input.path + let newContent: string | undefined = block.input.content // Wait for path to stabilize before showing UI (prevents truncated paths) if (!this.hasPathStabilized(relPath) || newContent === undefined) { diff --git a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts index bda80d711f5..ba1ecc3320b 100644 --- a/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts +++ b/src/core/tools/__tests__/ToolRepetitionDetector.spec.ts @@ -16,11 +16,12 @@ vitest.mock("../../../i18n", () => ({ }), })) -function createToolUse(name: string, displayName?: string, params: Record = {}): ToolUse { +function createToolUse(name: string, displayName?: string, input: Record = {}): ToolUse { return { - type: "tool_use", - name: (displayName || name) as ToolName, - params, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: (displayName || name) as ToolName, + input, partial: false, } } @@ -245,7 +246,7 @@ describe("ToolRepetitionDetector", () => { const originalSerialize = (detector as any).serializeToolUse ;(detector as any).serializeToolUse = (tool: ToolUse) => { // Use string comparison for the name since it's technically an enum - if (String(tool.name) === "tool-name-2") { + if (String(tool.toolName) === "tool-name-2") { return originalSerialize.call(detector, toolUse1) // Return the same JSON as toolUse1 } return originalSerialize.call(detector, tool) @@ -410,9 +411,10 @@ describe("ToolRepetitionDetector", () => { // Create browser_action tool use with scroll_down const scrollDownTool: ToolUse = { - type: "tool_use", - name: "browser_action" as ToolName, - params: { action: "scroll_down" }, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "browser_action" as ToolName, + input: { action: "scroll_down" }, partial: false, } @@ -429,9 +431,10 @@ describe("ToolRepetitionDetector", () => { // Create browser_action tool use with scroll_up const scrollUpTool: ToolUse = { - type: "tool_use", - name: "browser_action" as ToolName, - params: { action: "scroll_up" }, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "browser_action" as ToolName, + input: { action: "scroll_up" }, partial: false, } @@ -447,16 +450,18 @@ describe("ToolRepetitionDetector", () => { const detector = new ToolRepetitionDetector(2) const scrollDownTool: ToolUse = { - type: "tool_use", - name: "browser_action" as ToolName, - params: { action: "scroll_down" }, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "browser_action" as ToolName, + input: { action: "scroll_down" }, partial: false, } const scrollUpTool: ToolUse = { - type: "tool_use", - name: "browser_action" as ToolName, - params: { action: "scroll_up" }, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "browser_action" as ToolName, + input: { action: "scroll_up" }, partial: false, } @@ -477,9 +482,10 @@ describe("ToolRepetitionDetector", () => { // Create browser_action tool use with click action const clickTool: ToolUse = { - type: "tool_use", - name: "browser_action" as ToolName, - params: { action: "click", coordinate: "[100, 200]" }, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "browser_action" as ToolName, + input: { action: "click", coordinate: "[100, 200]" }, partial: false, } @@ -516,9 +522,10 @@ describe("ToolRepetitionDetector", () => { const detector = new ToolRepetitionDetector(2) const scrollTool: ToolUse = { - type: "tool_use", - name: "browser_action" as ToolName, - params: { action: "scroll_down" }, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "browser_action" as ToolName, + input: { action: "scroll_down" }, partial: false, } @@ -548,9 +555,10 @@ describe("ToolRepetitionDetector", () => { // Browser action without action parameter const noActionTool: ToolUse = { - type: "tool_use", - name: "browser_action" as ToolName, - params: {}, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "browser_action" as ToolName, + input: {}, partial: false, } @@ -570,9 +578,10 @@ describe("ToolRepetitionDetector", () => { // Create read_file tool use with nativeArgs (like native protocol does) const readFile1: ToolUse = { - type: "tool_use", - name: "read_file" as ToolName, - params: {}, // Empty for native protocol + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "read_file" as ToolName, + input: {}, // Empty for native protocol partial: false, nativeArgs: { path: "file1.ts", @@ -580,9 +589,10 @@ describe("ToolRepetitionDetector", () => { } const readFile2: ToolUse = { - type: "tool_use", - name: "read_file" as ToolName, - params: {}, // Empty for native protocol + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "read_file" as ToolName, + input: {}, // Empty for native protocol partial: false, nativeArgs: { path: "file2.ts", @@ -604,9 +614,10 @@ describe("ToolRepetitionDetector", () => { // Create identical read_file tool uses const readFile: ToolUse = { - type: "tool_use", - name: "read_file" as ToolName, - params: {}, // Empty for native protocol + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "read_file" as ToolName, + input: {}, // Empty for native protocol partial: false, nativeArgs: { path: "same-file.ts", @@ -629,9 +640,10 @@ describe("ToolRepetitionDetector", () => { const detector = new ToolRepetitionDetector(2) const readFile1: ToolUse = { - type: "tool_use", - name: "read_file" as ToolName, - params: {}, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "read_file" as ToolName, + input: {}, partial: false, nativeArgs: { path: "a.ts", @@ -641,9 +653,10 @@ describe("ToolRepetitionDetector", () => { } const readFile2: ToolUse = { - type: "tool_use", - name: "read_file" as ToolName, - params: {}, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "read_file" as ToolName, + input: {}, partial: false, nativeArgs: { path: "a.ts", @@ -661,9 +674,10 @@ describe("ToolRepetitionDetector", () => { const detector = new ToolRepetitionDetector(2) const tool1: ToolUse = { - type: "tool_use", - name: "execute_command" as ToolName, - params: { command: "ls" }, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "execute_command" as ToolName, + input: { command: "ls" }, partial: false, nativeArgs: { command: "ls", @@ -672,9 +686,10 @@ describe("ToolRepetitionDetector", () => { } const tool2: ToolUse = { - type: "tool_use", - name: "execute_command" as ToolName, - params: { command: "ls" }, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "execute_command" as ToolName, + input: { command: "ls" }, partial: false, nativeArgs: { command: "ls", diff --git a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts index e13f639ba00..46594db5a49 100644 --- a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts +++ b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts @@ -23,9 +23,10 @@ describe("askFollowupQuestionTool", () => { it("should parse suggestions without mode attributes", async () => { const block: ToolUse = { - type: "tool_use", - name: "ask_followup_question", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "ask_followup_question", + input: { question: "What would you like to do?", }, nativeArgs: { @@ -35,7 +36,7 @@ describe("askFollowupQuestionTool", () => { partial: false, } - await askFollowupQuestionTool.handle(mockCline, block as ToolUse<"ask_followup_question">, { + await askFollowupQuestionTool.handle(mockCline, block as ToolUse, { askApproval: vi.fn(), handleError: vi.fn(), pushToolResult: mockPushToolResult, @@ -50,9 +51,10 @@ describe("askFollowupQuestionTool", () => { it("should parse suggestions with mode attributes", async () => { const block: ToolUse = { - type: "tool_use", - name: "ask_followup_question", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "ask_followup_question", + input: { question: "What would you like to do?", }, nativeArgs: { @@ -65,7 +67,7 @@ describe("askFollowupQuestionTool", () => { partial: false, } - await askFollowupQuestionTool.handle(mockCline, block as ToolUse<"ask_followup_question">, { + await askFollowupQuestionTool.handle(mockCline, block as ToolUse, { askApproval: vi.fn(), handleError: vi.fn(), pushToolResult: mockPushToolResult, @@ -82,9 +84,10 @@ describe("askFollowupQuestionTool", () => { it("should handle mixed suggestions with and without mode attributes", async () => { const block: ToolUse = { - type: "tool_use", - name: "ask_followup_question", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "ask_followup_question", + input: { question: "What would you like to do?", }, nativeArgs: { @@ -94,7 +97,7 @@ describe("askFollowupQuestionTool", () => { partial: false, } - await askFollowupQuestionTool.handle(mockCline, block as ToolUse<"ask_followup_question">, { + await askFollowupQuestionTool.handle(mockCline, block as ToolUse, { askApproval: vi.fn(), handleError: vi.fn(), pushToolResult: mockPushToolResult, @@ -111,10 +114,11 @@ describe("askFollowupQuestionTool", () => { describe("handlePartial with native protocol", () => { it("should only send question during partial streaming to avoid raw JSON display", async () => { - const block: ToolUse<"ask_followup_question"> = { - type: "tool_use", - name: "ask_followup_question", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "ask_followup_question", + input: { question: "What would you like to do?", }, partial: true, @@ -135,10 +139,11 @@ describe("askFollowupQuestionTool", () => { }) it("should handle partial with question from params", async () => { - const block: ToolUse<"ask_followup_question"> = { - type: "tool_use", - name: "ask_followup_question", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "ask_followup_question", + input: { question: "Choose wisely", }, partial: true, @@ -169,8 +174,8 @@ describe("askFollowupQuestionTool", () => { const result1 = NativeToolCallParser.processStreamingChunk("call_123", chunk1) expect(result1).not.toBeNull() - expect(result1?.name).toBe("ask_followup_question") - expect(result1?.params.question).toBe("What would you like?") + expect(result1?.toolName).toBe("ask_followup_question") + expect(result1?.input.question).toBe("What would you like?") expect(result1?.nativeArgs).toBeDefined() // Use type assertion to access the specific fields const nativeArgs = result1?.nativeArgs as { @@ -193,11 +198,11 @@ describe("askFollowupQuestionTool", () => { const result = NativeToolCallParser.finalizeStreamingToolCall("call_456") expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - expect(result?.name).toBe("ask_followup_question") + expect(result?.type).toBe("tool-call") + expect(result?.toolName).toBe("ask_followup_question") expect(result?.partial).toBe(false) // Type guard: regular tools have type 'tool_use', MCP tools have type 'mcp_tool_use' - if (result?.type === "tool_use") { + if (result?.type === "tool-call") { expect(result.nativeArgs).toEqual({ question: "Choose an option", follow_up: [ diff --git a/src/core/tools/__tests__/attemptCompletionTool.spec.ts b/src/core/tools/__tests__/attemptCompletionTool.spec.ts index 9aac6296c6c..7962829fc01 100644 --- a/src/core/tools/__tests__/attemptCompletionTool.spec.ts +++ b/src/core/tools/__tests__/attemptCompletionTool.spec.ts @@ -75,8 +75,11 @@ describe("attemptCompletionTool", () => { describe("todo list validation", () => { it("should allow completion when there is no todo list", async () => { const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "attempt_completion", + name: "attempt_completion" as const, + input: { result: "Task completed successfully" }, params: { result: "Task completed successfully" }, nativeArgs: { result: "Task completed successfully" }, partial: false, @@ -100,8 +103,11 @@ describe("attemptCompletionTool", () => { it("should allow completion when todo list is empty", async () => { const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "attempt_completion", + name: "attempt_completion" as const, + input: { result: "Task completed successfully" }, params: { result: "Task completed successfully" }, nativeArgs: { result: "Task completed successfully" }, partial: false, @@ -124,8 +130,11 @@ describe("attemptCompletionTool", () => { it("should allow completion when all todos are completed", async () => { const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "attempt_completion", + name: "attempt_completion" as const, + input: { result: "Task completed successfully" }, params: { result: "Task completed successfully" }, nativeArgs: { result: "Task completed successfully" }, partial: false, @@ -153,8 +162,11 @@ describe("attemptCompletionTool", () => { it("should prevent completion when there are pending todos", async () => { const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "attempt_completion", + name: "attempt_completion" as const, + input: { result: "Task completed successfully" }, params: { result: "Task completed successfully" }, nativeArgs: { result: "Task completed successfully" }, partial: false, @@ -195,8 +207,11 @@ describe("attemptCompletionTool", () => { it("should prevent completion when there are in-progress todos", async () => { const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "attempt_completion", + name: "attempt_completion" as const, + input: { result: "Task completed successfully" }, params: { result: "Task completed successfully" }, nativeArgs: { result: "Task completed successfully" }, partial: false, @@ -237,8 +252,11 @@ describe("attemptCompletionTool", () => { it("should prevent completion when there are mixed incomplete todos", async () => { const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "attempt_completion", + name: "attempt_completion" as const, + input: { result: "Task completed successfully" }, params: { result: "Task completed successfully" }, nativeArgs: { result: "Task completed successfully" }, partial: false, @@ -280,8 +298,11 @@ describe("attemptCompletionTool", () => { it("should allow completion when setting is disabled even with incomplete todos", async () => { const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "attempt_completion", + name: "attempt_completion" as const, + input: { result: "Task completed successfully" }, params: { result: "Task completed successfully" }, nativeArgs: { result: "Task completed successfully" }, partial: false, @@ -323,8 +344,11 @@ describe("attemptCompletionTool", () => { it("should prevent completion when setting is enabled with incomplete todos", async () => { const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "attempt_completion", + name: "attempt_completion" as const, + input: { result: "Task completed successfully" }, params: { result: "Task completed successfully" }, nativeArgs: { result: "Task completed successfully" }, partial: false, @@ -366,8 +390,11 @@ describe("attemptCompletionTool", () => { it("should allow completion when setting is enabled but all todos are completed", async () => { const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "attempt_completion", + name: "attempt_completion" as const, + input: { result: "Task completed successfully" }, params: { result: "Task completed successfully" }, nativeArgs: { result: "Task completed successfully" }, partial: false, @@ -410,8 +437,11 @@ describe("attemptCompletionTool", () => { describe("tool failure guardrail", () => { it("should prevent completion when a previous tool failed in the current turn", async () => { const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "attempt_completion", + name: "attempt_completion" as const, + input: { result: "Task completed successfully" }, params: { result: "Task completed successfully" }, nativeArgs: { result: "Task completed successfully" }, partial: false, @@ -444,8 +474,11 @@ describe("attemptCompletionTool", () => { it("should allow completion when no tools failed", async () => { const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "attempt_completion", + name: "attempt_completion" as const, + input: { result: "Task completed successfully" }, params: { result: "Task completed successfully" }, nativeArgs: { result: "Task completed successfully" }, partial: false, diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 80d431edab2..ff4b3e902cd 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -160,7 +160,7 @@ describe("editFileTool", () => { * Helper function to execute the edit_file tool with different parameters */ async function executeEditFileTool( - params: Partial = {}, + params: Partial = {}, options: { fileExists?: boolean fileContent?: string @@ -191,9 +191,10 @@ describe("editFileTool", () => { } const toolUse: ToolUse = { - type: "tool_use", - name: "edit_file", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "edit_file", + input: { file_path: testFilePath, old_string: testOldString, new_string: testNewString, @@ -207,7 +208,7 @@ describe("editFileTool", () => { toolResult = result }) - await editFileTool.handle(mockTask, toolUse as ToolUse<"edit_file">, { + await editFileTool.handle(mockTask, toolUse as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -272,9 +273,10 @@ describe("editFileTool", () => { mockTask.rooIgnoreController.validateAccess.mockReturnValue(true) const toolUse: ToolUse = { - type: "tool_use", - name: "edit_file", - params: {}, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "edit_file", + input: {}, partial: false, nativeArgs: nativeArgs as any, } @@ -284,7 +286,7 @@ describe("editFileTool", () => { capturedResult = result }) - await editFileTool.handle(mockTask, toolUse as ToolUse<"edit_file">, { + await editFileTool.handle(mockTask, toolUse as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: localPushToolResult, @@ -633,9 +635,10 @@ describe("editFileTool", () => { mockedFsReadFile.mockRejectedValueOnce(new Error("Read failed")) const toolUse: ToolUse = { - type: "tool_use", - name: "edit_file", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "edit_file", + input: { file_path: testFilePath, old_string: testOldString, new_string: testNewString, @@ -653,7 +656,7 @@ describe("editFileTool", () => { capturedResult = result }) - await editFileTool.handle(mockTask, toolUse as ToolUse<"edit_file">, { + await editFileTool.handle(mockTask, toolUse as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: localPushToolResult, diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 89b2575288b..ac3a553bd21 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -47,7 +47,7 @@ describe("executeCommandTool", () => { let mockAskApproval: any let mockHandleError: any let mockPushToolResult: any - let mockToolUse: ToolUse<"execute_command"> + let mockToolUse: ToolUse beforeEach(() => { // Reset mocks @@ -94,9 +94,10 @@ describe("executeCommandTool", () => { // Create a mock tool use object mockToolUse = { - type: "tool_use", - name: "execute_command", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "execute_command", + input: { command: "echo test", }, nativeArgs: { @@ -140,7 +141,7 @@ describe("executeCommandTool", () => { describe("Basic functionality", () => { it("should execute a command normally", async () => { // Setup - mockToolUse.params.command = "echo test" + mockToolUse.input.command = "echo test" mockToolUse.nativeArgs = { command: "echo test" } // Execute using the class-based handle method @@ -160,8 +161,8 @@ describe("executeCommandTool", () => { it("should pass along custom working directory if provided", async () => { // Setup - mockToolUse.params.command = "echo test" - mockToolUse.params.cwd = "/custom/path" + mockToolUse.input.command = "echo test" + mockToolUse.input.cwd = "/custom/path" mockToolUse.nativeArgs = { command: "echo test", cwd: "/custom/path" } // Execute @@ -183,7 +184,7 @@ describe("executeCommandTool", () => { describe("Error handling", () => { it("should handle missing command parameter", async () => { // Setup - mockToolUse.params.command = undefined + delete (mockToolUse.input as Record).command // Native tool calls must still supply a value; simulate a missing value with an empty string. mockToolUse.nativeArgs = { command: "" } @@ -204,7 +205,7 @@ describe("executeCommandTool", () => { it("should handle command rejection", async () => { // Setup - mockToolUse.params.command = "echo test" + mockToolUse.input.command = "echo test" mockAskApproval.mockResolvedValue(false) mockToolUse.nativeArgs = { command: "echo test" } @@ -223,7 +224,7 @@ describe("executeCommandTool", () => { it("should handle rooignore validation failures", async () => { // Setup - mockToolUse.params.command = "cat .env" + mockToolUse.input.command = "cat .env" mockToolUse.nativeArgs = { command: "cat .env" } // Override the validateCommand mock to return a filename const validateCommandMock = vitest.fn().mockReturnValue(".env") diff --git a/src/core/tools/__tests__/generateImageTool.test.ts b/src/core/tools/__tests__/generateImageTool.test.ts index 9acd654537f..08c3885f7d4 100644 --- a/src/core/tools/__tests__/generateImageTool.test.ts +++ b/src/core/tools/__tests__/generateImageTool.test.ts @@ -71,9 +71,10 @@ describe("generateImageTool", () => { describe("partial block handling", () => { it("should return early when block is partial", async () => { const partialBlock: ToolUse = { - type: "tool_use", - name: "generate_image", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "generate_image", + input: { prompt: "Generate a test image", path: "test-image.png", }, @@ -84,7 +85,7 @@ describe("generateImageTool", () => { partial: true, } - await generateImageTool.handle(mockCline as Task, partialBlock as ToolUse<"generate_image">, { + await generateImageTool.handle(mockCline as Task, partialBlock as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -98,9 +99,10 @@ describe("generateImageTool", () => { it("should return early when block is partial even with image parameter", async () => { const partialBlock: ToolUse = { - type: "tool_use", - name: "generate_image", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "generate_image", + input: { prompt: "Upscale this image", path: "upscaled-image.png", image: "source-image.png", @@ -113,7 +115,7 @@ describe("generateImageTool", () => { partial: true, } - await generateImageTool.handle(mockCline as Task, partialBlock as ToolUse<"generate_image">, { + await generateImageTool.handle(mockCline as Task, partialBlock as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -128,9 +130,10 @@ describe("generateImageTool", () => { it("should process when block is not partial", async () => { const completeBlock: ToolUse = { - type: "tool_use", - name: "generate_image", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "generate_image", + input: { prompt: "Generate a test image", path: "test-image.png", }, @@ -154,7 +157,7 @@ describe("generateImageTool", () => { }) as any, ) - await generateImageTool.handle(mockCline as Task, completeBlock as ToolUse<"generate_image">, { + await generateImageTool.handle(mockCline as Task, completeBlock as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -168,9 +171,10 @@ describe("generateImageTool", () => { it("should add cache-busting parameter to image URI", async () => { const completeBlock: ToolUse = { - type: "tool_use", - name: "generate_image", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "generate_image", + input: { prompt: "Generate a test image", path: "test-image.png", }, @@ -198,7 +202,7 @@ describe("generateImageTool", () => { }) as any, ) - await generateImageTool.handle(mockCline as Task, completeBlock as ToolUse<"generate_image">, { + await generateImageTool.handle(mockCline as Task, completeBlock as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -225,9 +229,10 @@ describe("generateImageTool", () => { describe("missing parameters", () => { it("should handle missing prompt parameter", async () => { const block: ToolUse = { - type: "tool_use", - name: "generate_image", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "generate_image", + input: { path: "test-image.png", }, nativeArgs: { @@ -236,7 +241,7 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + await generateImageTool.handle(mockCline as Task, block as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -250,9 +255,10 @@ describe("generateImageTool", () => { it("should handle missing path parameter", async () => { const block: ToolUse = { - type: "tool_use", - name: "generate_image", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "generate_image", + input: { prompt: "Generate a test image", }, nativeArgs: { @@ -261,7 +267,7 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + await generateImageTool.handle(mockCline as Task, block as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -284,9 +290,10 @@ describe("generateImageTool", () => { }) const block: ToolUse = { - type: "tool_use", - name: "generate_image", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "generate_image", + input: { prompt: "Generate a test image", path: "test-image.png", }, @@ -297,7 +304,7 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + await generateImageTool.handle(mockCline as Task, block as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -316,9 +323,10 @@ describe("generateImageTool", () => { vi.mocked(fileUtils.fileExistsAtPath).mockResolvedValue(false) const block: ToolUse = { - type: "tool_use", - name: "generate_image", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "generate_image", + input: { prompt: "Upscale this image", path: "upscaled.png", image: "non-existent.png", @@ -331,7 +339,7 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + await generateImageTool.handle(mockCline as Task, block as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -343,9 +351,10 @@ describe("generateImageTool", () => { it("should handle unsupported image format", async () => { const block: ToolUse = { - type: "tool_use", - name: "generate_image", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "generate_image", + input: { prompt: "Upscale this image", path: "upscaled.png", image: "test.bmp", // Unsupported format @@ -358,7 +367,7 @@ describe("generateImageTool", () => { partial: false, } - await generateImageTool.handle(mockCline as Task, block as ToolUse<"generate_image">, { + await generateImageTool.handle(mockCline as Task, block as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index fc383c13eef..1b57e374fe4 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -111,15 +111,15 @@ import { newTaskTool } from "../NewTaskTool" import { getModeBySlug } from "../../../shared/modes" import * as vscode from "vscode" -const withNativeArgs = (block: ToolUse<"new_task">): ToolUse<"new_task"> => ({ +const withNativeArgs = (block: ToolUse): ToolUse => ({ ...block, // Native tool calling: `nativeArgs` is the source of truth for tool execution. // These tests intentionally exercise missing-param behavior, so we allow undefined // values and let the tool's runtime validation handle it. nativeArgs: { - mode: block.params.mode, - message: block.params.message, - todos: block.params.todos, + mode: block.input.mode, + message: block.input.message, + todos: block.input.todos, } as unknown as NativeToolArgs["new_task"], }) @@ -144,10 +144,11 @@ describe("newTaskTool", () => { }) it("should correctly un-escape \\\\@ to \\@ in the message passed to the new task", async () => { - const block: ToolUse<"new_task"> = { - type: "tool_use", // Add required 'type' property - name: "new_task", // Correct property name - params: { + const block: ToolUse = { + type: "tool-call", // Add required 'type' property + toolCallId: "test-tool-call-id", + toolName: "new_task", // Correct property name + input: { mode: "code", message: "Review this: \\\\@file1.txt and also \\\\\\\\@file2.txt", // Input with \\@ and \\\\@ todos: "[ ] First task\n[ ] Second task", @@ -179,10 +180,11 @@ describe("newTaskTool", () => { }) it("should not un-escape single escaped \@", async () => { - const block: ToolUse<"new_task"> = { - type: "tool_use", // Add required 'type' property - name: "new_task", // Correct property name - params: { + const block: ToolUse = { + type: "tool-call", // Add required 'type' property + toolCallId: "test-tool-call-id", + toolName: "new_task", // Correct property name + input: { mode: "code", message: "This is already unescaped: \\@file1.txt", todos: "[ ] Test todo", @@ -204,10 +206,11 @@ describe("newTaskTool", () => { }) it("should not un-escape non-escaped @", async () => { - const block: ToolUse<"new_task"> = { - type: "tool_use", // Add required 'type' property - name: "new_task", // Correct property name - params: { + const block: ToolUse = { + type: "tool-call", // Add required 'type' property + toolCallId: "test-tool-call-id", + toolName: "new_task", // Correct property name + input: { mode: "code", message: "A normal mention @file1.txt", todos: "[ ] Test todo", @@ -229,10 +232,11 @@ describe("newTaskTool", () => { }) it("should handle mixed escaping scenarios", async () => { - const block: ToolUse<"new_task"> = { - type: "tool_use", // Add required 'type' property - name: "new_task", // Correct property name - params: { + const block: ToolUse = { + type: "tool-call", // Add required 'type' property + toolCallId: "test-tool-call-id", + toolName: "new_task", // Correct property name + input: { mode: "code", message: "Mix: @file0.txt, \\@file1.txt, \\\\@file2.txt, \\\\\\\\@file3.txt", todos: "[ ] Test todo", @@ -254,10 +258,11 @@ describe("newTaskTool", () => { }) it("should handle missing todos parameter gracefully (backward compatibility)", async () => { - const block: ToolUse<"new_task"> = { - type: "tool_use", - name: "new_task", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "new_task", + input: { mode: "code", message: "Test message", // todos missing - should work for backward compatibility @@ -284,10 +289,11 @@ describe("newTaskTool", () => { }) it("should work with todos parameter when provided", async () => { - const block: ToolUse<"new_task"> = { - type: "tool_use", - name: "new_task", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "new_task", + input: { mode: "code", message: "Test message with todos", todos: "[ ] First task\n[ ] Second task", @@ -315,10 +321,11 @@ describe("newTaskTool", () => { }) it("should error when mode parameter is missing", async () => { - const block: ToolUse<"new_task"> = { - type: "tool_use", - name: "new_task", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "new_task", + input: { // mode missing message: "Test message", todos: "[ ] Test todo", @@ -338,10 +345,11 @@ describe("newTaskTool", () => { }) it("should error when message parameter is missing", async () => { - const block: ToolUse<"new_task"> = { - type: "tool_use", - name: "new_task", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "new_task", + input: { mode: "code", // message missing todos: "[ ] Test todo", @@ -361,10 +369,11 @@ describe("newTaskTool", () => { }) it("should parse todos with different statuses correctly", async () => { - const block: ToolUse<"new_task"> = { - type: "tool_use", - name: "new_task", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "new_task", + input: { mode: "code", message: "Test message", todos: "[ ] Pending task\n[x] Completed task\n[-] In progress task", @@ -397,10 +406,11 @@ describe("newTaskTool", () => { get: mockGet, } as any) - const block: ToolUse<"new_task"> = { - type: "tool_use", - name: "new_task", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "new_task", + input: { mode: "code", message: "Test message", // todos missing - should work when setting is disabled @@ -433,10 +443,11 @@ describe("newTaskTool", () => { get: mockGet, } as any) - const block: ToolUse<"new_task"> = { - type: "tool_use", - name: "new_task", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "new_task", + input: { mode: "code", message: "Test message", // todos missing - should error when setting is enabled @@ -469,10 +480,11 @@ describe("newTaskTool", () => { get: mockGet, } as any) - const block: ToolUse<"new_task"> = { - type: "tool_use", - name: "new_task", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "new_task", + input: { mode: "code", message: "Test message", todos: "[ ] First task\n[ ] Second task", @@ -511,10 +523,11 @@ describe("newTaskTool", () => { get: mockGet, } as any) - const block: ToolUse<"new_task"> = { - type: "tool_use", - name: "new_task", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "new_task", + input: { mode: "code", message: "Test message", todos: "", // Empty string should be accepted @@ -546,10 +559,11 @@ describe("newTaskTool", () => { } as any) vi.mocked(vscode.workspace.getConfiguration).mockImplementation(mockGetConfiguration) - const block: ToolUse<"new_task"> = { - type: "tool_use", - name: "new_task", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "new_task", + input: { mode: "code", message: "Test message", }, @@ -579,10 +593,11 @@ describe("newTaskTool", () => { const pkg = await import("../../../shared/package") ;(pkg.Package as any).name = "roo-code-nightly" - const block: ToolUse<"new_task"> = { - type: "tool_use", - name: "new_task", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "new_task", + input: { mode: "code", message: "Test message", }, @@ -636,10 +651,11 @@ describe("newTaskTool delegation flow", () => { }, } - const block: ToolUse<"new_task"> = { - type: "tool_use", - name: "new_task", - params: { + const block: ToolUse = { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "new_task", + input: { mode: "code", message: "Do something", // no todos -> should default to [] diff --git a/src/core/tools/__tests__/runSlashCommandTool.spec.ts b/src/core/tools/__tests__/runSlashCommandTool.spec.ts index 9aa7970b99b..d09bb48c39a 100644 --- a/src/core/tools/__tests__/runSlashCommandTool.spec.ts +++ b/src/core/tools/__tests__/runSlashCommandTool.spec.ts @@ -43,10 +43,11 @@ describe("runSlashCommandTool", () => { }) it("should handle missing command parameter", async () => { - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: {}, partial: false, nativeArgs: { command: "", @@ -62,10 +63,11 @@ describe("runSlashCommandTool", () => { }) it("should handle command not found", async () => { - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: {}, partial: false, nativeArgs: { command: "nonexistent", @@ -84,10 +86,11 @@ describe("runSlashCommandTool", () => { }) it("should handle user rejection", async () => { - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: {}, partial: false, nativeArgs: { command: "init", @@ -112,10 +115,11 @@ describe("runSlashCommandTool", () => { }) it("should successfully execute built-in command", async () => { - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: {}, partial: false, nativeArgs: { command: "init", @@ -157,10 +161,11 @@ Initialize project content here`, }) it("should successfully execute command with arguments", async () => { - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: {}, partial: false, nativeArgs: { command: "test", @@ -195,10 +200,11 @@ Run tests with specific focus`, }) it("should handle global command", async () => { - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: {}, partial: false, nativeArgs: { command: "deploy", @@ -227,10 +233,11 @@ Deploy application to production`, }) it("should handle partial block", async () => { - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: { + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: { command: "init", args: "", }, @@ -253,10 +260,11 @@ Deploy application to production`, }) it("should handle errors during execution", async () => { - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: {}, partial: false, nativeArgs: { command: "init", @@ -272,10 +280,11 @@ Deploy application to production`, }) it("should handle empty available commands list", async () => { - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: {}, partial: false, nativeArgs: { command: "nonexistent", @@ -293,10 +302,11 @@ Deploy application to production`, }) it("should reset consecutive mistake count on valid command", async () => { - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: {}, partial: false, nativeArgs: { command: "init", @@ -321,10 +331,11 @@ Deploy application to production`, it("should switch mode when mode is specified in command", async () => { const mockHandleModeSwitch = vi.fn() - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: {}, partial: false, nativeArgs: { command: "debug-app", @@ -369,10 +380,11 @@ Start debugging the application`, it("should not switch mode when mode is not specified in command", async () => { const mockHandleModeSwitch = vi.fn() - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: {}, partial: false, nativeArgs: { command: "test", @@ -405,10 +417,11 @@ Start debugging the application`, }) it("should include mode in askApproval message when mode is specified", async () => { - const block: ToolUse<"run_slash_command"> = { - type: "tool_use" as const, - name: "run_slash_command" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "run_slash_command" as const, + input: {}, partial: false, nativeArgs: { command: "debug-app", diff --git a/src/core/tools/__tests__/searchAndReplaceTool.spec.ts b/src/core/tools/__tests__/searchAndReplaceTool.spec.ts index 241d7b67b0d..a06f24a6803 100644 --- a/src/core/tools/__tests__/searchAndReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchAndReplaceTool.spec.ts @@ -156,7 +156,7 @@ describe("searchAndReplaceTool", () => { * Helper function to execute the search and replace tool with different parameters */ async function executeSearchAndReplaceTool( - params: Partial = {}, + params: Partial = {}, options: { fileExists?: boolean fileContent?: string @@ -185,9 +185,10 @@ describe("searchAndReplaceTool", () => { } const toolUse: ToolUse = { - type: "tool_use", - name: "search_and_replace", - params: fullParams as any, + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "search_and_replace", + input: fullParams as any, nativeArgs: nativeArgs as any, partial: isPartial, } @@ -196,7 +197,7 @@ describe("searchAndReplaceTool", () => { toolResult = result }) - await searchAndReplaceTool.handle(mockTask, toolUse as ToolUse<"search_and_replace">, { + await searchAndReplaceTool.handle(mockTask, toolUse as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -365,9 +366,10 @@ describe("searchAndReplaceTool", () => { mockedFsReadFile.mockRejectedValueOnce(new Error("Read failed")) const toolUse: ToolUse = { - type: "tool_use", - name: "search_and_replace", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "search_and_replace", + input: { path: testFilePath, operations: JSON.stringify([{ search: "Line 2", replace: "Modified" }]), }, @@ -383,7 +385,7 @@ describe("searchAndReplaceTool", () => { capturedResult = result }) - await searchAndReplaceTool.handle(mockTask, toolUse as ToolUse<"search_and_replace">, { + await searchAndReplaceTool.handle(mockTask, toolUse as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: localPushToolResult, diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 1b1f78a1288..47d31465845 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -158,7 +158,7 @@ describe("searchReplaceTool", () => { * Helper function to execute the search replace tool with different parameters */ async function executeSearchReplaceTool( - params: Partial = {}, + params: Partial = {}, options: { fileExists?: boolean fileContent?: string @@ -185,9 +185,10 @@ describe("searchReplaceTool", () => { } const toolUse: ToolUse = { - type: "tool_use", - name: "search_replace", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "search_replace", + input: { file_path: testFilePath, old_string: testOldString, new_string: testNewString, @@ -201,7 +202,7 @@ describe("searchReplaceTool", () => { toolResult = result }) - await searchReplaceTool.handle(mockCline, toolUse as ToolUse<"search_replace">, { + await searchReplaceTool.handle(mockCline, toolUse as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, @@ -343,9 +344,10 @@ describe("searchReplaceTool", () => { mockedFsReadFile.mockRejectedValueOnce(new Error("Read failed")) const toolUse: ToolUse = { - type: "tool_use", - name: "search_replace", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "search_replace", + input: { file_path: testFilePath, old_string: testOldString, new_string: testNewString, @@ -363,7 +365,7 @@ describe("searchReplaceTool", () => { capturedResult = result }) - await searchReplaceTool.handle(mockCline, toolUse as ToolUse<"search_replace">, { + await searchReplaceTool.handle(mockCline, toolUse as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: localPushToolResult, diff --git a/src/core/tools/__tests__/skillTool.spec.ts b/src/core/tools/__tests__/skillTool.spec.ts index fc1b3396e50..15488e9fd42 100644 --- a/src/core/tools/__tests__/skillTool.spec.ts +++ b/src/core/tools/__tests__/skillTool.spec.ts @@ -39,10 +39,11 @@ describe("skillTool", () => { }) it("should handle missing skill parameter", async () => { - const block: ToolUse<"skill"> = { - type: "tool_use" as const, - name: "skill" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "skill" as const, + input: {}, partial: false, nativeArgs: { skill: "", @@ -58,10 +59,11 @@ describe("skillTool", () => { }) it("should handle skill not found", async () => { - const block: ToolUse<"skill"> = { - type: "tool_use" as const, - name: "skill" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "skill" as const, + input: {}, partial: false, nativeArgs: { skill: "non-existent", @@ -79,10 +81,11 @@ describe("skillTool", () => { }) it("should handle empty available skills list", async () => { - const block: ToolUse<"skill"> = { - type: "tool_use" as const, - name: "skill" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "skill" as const, + input: {}, partial: false, nativeArgs: { skill: "non-existent", @@ -100,10 +103,11 @@ describe("skillTool", () => { }) it("should successfully load built-in skill", async () => { - const block: ToolUse<"skill"> = { - type: "tool_use" as const, - name: "skill" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "skill" as const, + input: {}, partial: false, nativeArgs: { skill: "create-mcp-server", @@ -144,10 +148,11 @@ Step 1: Create the server...`, }) it("should successfully load skill with arguments", async () => { - const block: ToolUse<"skill"> = { - type: "tool_use" as const, - name: "skill" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "skill" as const, + input: {}, partial: false, nativeArgs: { skill: "create-mcp-server", @@ -179,10 +184,11 @@ Step 1: Create the server...`, }) it("should handle user rejection", async () => { - const block: ToolUse<"skill"> = { - type: "tool_use" as const, - name: "skill" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "skill" as const, + input: {}, partial: false, nativeArgs: { skill: "create-mcp-server", @@ -204,10 +210,11 @@ Step 1: Create the server...`, }) it("should handle partial block", async () => { - const block: ToolUse<"skill"> = { - type: "tool_use" as const, - name: "skill" as const, - params: { + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "skill" as const, + input: { skill: "create-mcp-server", args: "", }, @@ -230,10 +237,11 @@ Step 1: Create the server...`, }) it("should handle errors during execution", async () => { - const block: ToolUse<"skill"> = { - type: "tool_use" as const, - name: "skill" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "skill" as const, + input: {}, partial: false, nativeArgs: { skill: "create-mcp-server", @@ -249,10 +257,11 @@ Step 1: Create the server...`, }) it("should reset consecutive mistake count on valid skill", async () => { - const block: ToolUse<"skill"> = { - type: "tool_use" as const, - name: "skill" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "skill" as const, + input: {}, partial: false, nativeArgs: { skill: "create-mcp-server", @@ -276,10 +285,11 @@ Step 1: Create the server...`, }) it("should handle Skills Manager not available", async () => { - const block: ToolUse<"skill"> = { - type: "tool_use" as const, - name: "skill" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "skill" as const, + input: {}, partial: false, nativeArgs: { skill: "create-mcp-server", @@ -300,10 +310,11 @@ Step 1: Create the server...`, }) it("should load project skill", async () => { - const block: ToolUse<"skill"> = { - type: "tool_use" as const, - name: "skill" as const, - params: {}, + const block: ToolUse = { + type: "tool-call" as const, + toolCallId: "test-tool-call-id", + toolName: "skill" as const, + input: {}, partial: false, nativeArgs: { skill: "my-project-skill", diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 5ee826774f4..a7317e8ff8a 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -79,9 +79,10 @@ describe("useMcpToolTool", () => { describe("parameter validation", () => { it("should handle missing server_name", async () => { const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { tool_name: "test_tool", arguments: "{}", }, @@ -109,9 +110,10 @@ describe("useMcpToolTool", () => { it("should handle missing tool_name", async () => { const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "test_server", arguments: "{}", }, @@ -139,9 +141,10 @@ describe("useMcpToolTool", () => { it("should handle invalid arguments type", async () => { const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "test_server", tool_name: "test_tool", arguments: "invalid json", @@ -187,9 +190,10 @@ describe("useMcpToolTool", () => { describe("partial requests", () => { it("should handle partial requests", async () => { const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "test_server", tool_name: "test_tool", arguments: "{}", @@ -212,9 +216,10 @@ describe("useMcpToolTool", () => { describe("successful execution", () => { it("should execute tool successfully with valid parameters", async () => { const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "test_server", tool_name: "test_tool", arguments: '{"param": "value"}', @@ -231,7 +236,6 @@ describe("useMcpToolTool", () => { const mockToolResult = { content: [{ type: "text", text: "Tool executed successfully" }], - isError: false, } mockProviderRef.deref.mockReturnValue({ @@ -256,9 +260,10 @@ describe("useMcpToolTool", () => { it("should handle user rejection", async () => { const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "test_server", tool_name: "test_tool", arguments: "{}", @@ -301,9 +306,10 @@ describe("useMcpToolTool", () => { describe("error handling", () => { it("should handle unexpected errors", async () => { const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "test_server", tool_name: "test_tool", }, @@ -362,9 +368,10 @@ describe("useMcpToolTool", () => { }) const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "test-server", tool_name: "non-existing-tool", arguments: JSON.stringify({ test: "data" }), @@ -411,9 +418,10 @@ describe("useMcpToolTool", () => { }) const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "test-server", tool_name: "any-tool", arguments: JSON.stringify({ test: "data" }), @@ -462,9 +470,10 @@ describe("useMcpToolTool", () => { }) const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "test-server", tool_name: "valid-tool", arguments: JSON.stringify({ test: "data" }), @@ -507,9 +516,10 @@ describe("useMcpToolTool", () => { }) const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "unknown", tool_name: "any-tool", arguments: "{}", @@ -552,9 +562,10 @@ describe("useMcpToolTool", () => { }) const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "unknown", tool_name: "any-tool", arguments: "{}", @@ -609,9 +620,10 @@ describe("useMcpToolTool", () => { // Model sends the mangled version with underscores const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "test-server", tool_name: "get_user_profile", // Model mangled hyphens to underscores arguments: "{}", @@ -645,9 +657,10 @@ describe("useMcpToolTool", () => { describe("image handling", () => { it("should handle tool response with image content", async () => { const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "figma-server", tool_name: "get_screenshot", arguments: '{"nodeId": "123"}', @@ -670,7 +683,6 @@ describe("useMcpToolTool", () => { data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ", }, ], - isError: false, } mockProviderRef.deref.mockReturnValue({ @@ -701,9 +713,10 @@ describe("useMcpToolTool", () => { it("should handle tool response with both text and image content", async () => { const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "figma-server", tool_name: "get_node_info", arguments: '{"nodeId": "123"}', @@ -727,7 +740,6 @@ describe("useMcpToolTool", () => { data: "base64imagedata", }, ], - isError: false, } mockProviderRef.deref.mockReturnValue({ @@ -757,9 +769,10 @@ describe("useMcpToolTool", () => { it("should handle image with data URL already formatted", async () => { const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "figma-server", tool_name: "get_screenshot", arguments: '{"nodeId": "123"}', @@ -782,7 +795,6 @@ describe("useMcpToolTool", () => { data: "data:image/jpeg;base64,/9j/4AAQSkZJRg==", }, ], - isError: false, } mockProviderRef.deref.mockReturnValue({ @@ -812,9 +824,10 @@ describe("useMcpToolTool", () => { it("should handle multiple images in response", async () => { const block: ToolUse = { - type: "tool_use", - name: "use_mcp_tool", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "use_mcp_tool", + input: { server_name: "figma-server", tool_name: "get_screenshots", arguments: '{"nodeIds": ["1", "2"]}', @@ -842,7 +855,6 @@ describe("useMcpToolTool", () => { data: "image2data", }, ], - isError: false, } mockProviderRef.deref.mockReturnValue({ diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 6c63387ee10..7c6cd387bd1 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -191,7 +191,7 @@ describe("writeToFileTool", () => { * Helper function to execute the write file tool with different parameters */ async function executeWriteFileTool( - params: Partial = {}, + params: Partial = {}, options: { fileExists?: boolean isPartial?: boolean @@ -208,9 +208,10 @@ describe("writeToFileTool", () => { // Create a tool use object const toolUse: ToolUse = { - type: "tool_use", - name: "write_to_file", - params: { + type: "tool-call", + toolCallId: "test-tool-call-id", + toolName: "write_to_file", + input: { path: testFilePath, content: testContent, ...params, @@ -226,7 +227,7 @@ describe("writeToFileTool", () => { toolResult = result }) - await writeToFileTool.handle(mockCline, toolUse as ToolUse<"write_to_file">, { + await writeToFileTool.handle(mockCline, toolUse as ToolUse, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, diff --git a/src/core/tools/accessMcpResourceTool.ts b/src/core/tools/accessMcpResourceTool.ts index 9df3b2256c5..001e736402f 100644 --- a/src/core/tools/accessMcpResourceTool.ts +++ b/src/core/tools/accessMcpResourceTool.ts @@ -83,9 +83,9 @@ export class AccessMcpResourceTool extends BaseTool<"access_mcp_resource"> { } } - override async handlePartial(task: Task, block: ToolUse<"access_mcp_resource">): Promise { - const server_name = block.params.server_name ?? "" - const uri = block.params.uri ?? "" + override async handlePartial(task: Task, block: ToolUse): Promise { + const server_name = block.input.server_name ?? "" + const uri = block.input.uri ?? "" const partialMessage = JSON.stringify({ type: "access_mcp_resource", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 94b3122eed5..aa736d5853f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3,7 +3,6 @@ import * as path from "path" import fs from "fs/promises" import EventEmitter from "events" -import { Anthropic } from "@anthropic-ai/sdk" import delay from "delay" import axios from "axios" import pWaitFor from "p-wait-for" @@ -98,7 +97,7 @@ import { getSystemPromptFilePath } from "../prompts/sections/custom-system-promp import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem } from "@roo-code/types" -import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" +import { readApiMessages, saveApiMessages, saveTaskMessages, type NeutralMessageParam } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" import { getUri } from "./getUri" @@ -1680,7 +1679,7 @@ export class ClineProvider taskDirPath: string apiConversationHistoryFilePath: string uiMessagesFilePath: string - apiConversationHistory: Anthropic.MessageParam[] + apiConversationHistory: NeutralMessageParam[] }> { const history = this.getGlobalState("taskHistory") ?? [] const historyItem = history.find((item) => item.id === id) @@ -3398,8 +3397,8 @@ export class ClineProvider const msg = parentApiMessages[i] if (msg.role === "assistant" && Array.isArray(msg.content)) { for (const block of msg.content) { - if (block.type === "tool_use" && block.name === "new_task") { - toolUseId = block.id + if (block.type === "tool-call" && block.toolName === "new_task") { + toolUseId = block.toolCallId break } } @@ -3417,9 +3416,12 @@ export class ClineProvider let alreadyHasToolResult = false if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { for (const block of lastMsg.content) { - if (block.type === "tool_result" && block.tool_use_id === toolUseId) { - // Update the existing tool_result content - block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + if (block.type === "tool-result" && block.toolCallId === toolUseId) { + // Update the existing tool_result output + ;(block as any).output = { + type: "text" as const, + value: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + } alreadyHasToolResult = true break } @@ -3432,9 +3434,13 @@ export class ClineProvider role: "user", content: [ { - type: "tool_result" as const, - tool_use_id: toolUseId, - content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + type: "tool-result" as const, + toolCallId: toolUseId, + toolName: "new_task", + output: { + type: "text" as const, + value: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + }, }, ], ts, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 5a57fa96788..dc42099fada 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1,6 +1,6 @@ // pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.spec.ts -import Anthropic from "@anthropic-ai/sdk" +import type { NeutralMessageParam } from "../../task-persistence/apiMessages" import * as vscode from "vscode" import axios from "axios" @@ -1215,7 +1215,7 @@ describe("ClineProvider", () => { { ts: 4000 }, { ts: 5000 }, { ts: 6000 }, - ] as (Anthropic.MessageParam & { ts?: number })[] + ] as (NeutralMessageParam & { ts?: number })[] // Setup Task instance with auto-mock from the top of the file const mockCline = new Task(defaultTaskOptions) // Create a new mocked instance @@ -1303,7 +1303,7 @@ describe("ClineProvider", () => { { ts: 4000 }, { ts: 5000 }, { ts: 6000 }, - ] as (Anthropic.MessageParam & { ts?: number })[] + ] as (NeutralMessageParam & { ts?: number })[] // Setup Task instance with auto-mock from the top of the file const mockCline = new Task(defaultTaskOptions) // Create a new mocked instance diff --git a/src/integrations/misc/__tests__/export-markdown.spec.ts b/src/integrations/misc/__tests__/export-markdown.spec.ts index fd4c30c3d25..74585d70bd1 100644 --- a/src/integrations/misc/__tests__/export-markdown.spec.ts +++ b/src/integrations/misc/__tests__/export-markdown.spec.ts @@ -11,49 +11,64 @@ describe("export-markdown", () => { it("should format image blocks", () => { const block = { type: "image", - source: { type: "base64", media_type: "image/png", data: "data" }, + image: "data", + mediaType: "image/png", } as ExtendedContentBlock expect(formatContentBlockToMarkdown(block)).toBe("[Image]") }) it("should format tool_use blocks with string input", () => { - const block = { type: "tool_use", name: "read_file", id: "123", input: "file.txt" } as ExtendedContentBlock + const block = { + type: "tool-call", + toolCallId: "123", + toolName: "read_file", + input: "file.txt", + } as ExtendedContentBlock expect(formatContentBlockToMarkdown(block)).toBe("[Tool Use: read_file]\nfile.txt") }) it("should format tool_use blocks with object input", () => { const block = { - type: "tool_use", - name: "read_file", - id: "123", + type: "tool-call", + toolCallId: "123", + toolName: "read_file", input: { path: "file.txt", line_count: 10 }, } as ExtendedContentBlock expect(formatContentBlockToMarkdown(block)).toBe("[Tool Use: read_file]\nPath: file.txt\nLine_count: 10") }) it("should format tool_result blocks with string content", () => { - const block = { type: "tool_result", tool_use_id: "123", content: "File content" } as ExtendedContentBlock + const block = { + type: "tool-result", + toolCallId: "123", + toolName: "", + output: { type: "text" as const, value: "File content" }, + } as ExtendedContentBlock expect(formatContentBlockToMarkdown(block)).toBe("[Tool]\nFile content") }) it("should format tool_result blocks with error", () => { const block = { - type: "tool_result", - tool_use_id: "123", - content: "Error message", - is_error: true, + type: "tool-result", + toolCallId: "123", + toolName: "", + output: { type: "text" as const, value: "Error message" }, } as ExtendedContentBlock - expect(formatContentBlockToMarkdown(block)).toBe("[Tool (Error)]\nError message") + expect(formatContentBlockToMarkdown(block)).toBe("[Tool]\nError message") }) it("should format tool_result blocks with array content", () => { const block = { - type: "tool_result", - tool_use_id: "123", - content: [ - { type: "text", text: "Line 1" }, - { type: "text", text: "Line 2" }, - ], + type: "tool-result", + toolCallId: "123", + toolName: "", + output: { + type: "content" as const, + value: [ + { type: "text", text: "Line 1" }, + { type: "text", text: "Line 2" }, + ], + }, } as ExtendedContentBlock expect(formatContentBlockToMarkdown(block)).toBe("[Tool]\nLine 1\nLine 2") }) diff --git a/src/integrations/misc/export-markdown.ts b/src/integrations/misc/export-markdown.ts index d65bb3200e4..63a82b27cb0 100644 --- a/src/integrations/misc/export-markdown.ts +++ b/src/integrations/misc/export-markdown.ts @@ -1,19 +1,16 @@ -import { Anthropic } from "@anthropic-ai/sdk" import os from "os" import * as path from "path" import * as vscode from "vscode" +import type { NeutralContentBlock, NeutralMessageParam, ReasoningPart } from "../../core/task-persistence" + // Extended content block types to support new Anthropic API features -interface ReasoningBlock { - type: "reasoning" - text: string -} interface ThoughtSignatureBlock { type: "thoughtSignature" } -export type ExtendedContentBlock = Anthropic.Messages.ContentBlockParam | ReasoningBlock | ThoughtSignatureBlock +export type ExtendedContentBlock = NeutralContentBlock | ReasoningPart | ThoughtSignatureBlock export function getTaskFileName(dateTs: number): string { const date = new Date(dateTs) @@ -31,7 +28,7 @@ export function getTaskFileName(dateTs: number): string { export async function downloadTask( dateTs: number, - conversationHistory: Anthropic.MessageParam[], + conversationHistory: NeutralMessageParam[], defaultUri: vscode.Uri, ): Promise { // File name @@ -69,10 +66,10 @@ export function formatContentBlockToMarkdown(block: ExtendedContentBlock): strin return block.text case "image": return `[Image]` - case "tool_use": { - let input: string + case "tool-call": { + let inputStr: string if (typeof block.input === "object" && block.input !== null) { - input = Object.entries(block.input) + inputStr = Object.entries(block.input as Record) .map(([key, value]) => { const formattedKey = key.charAt(0).toUpperCase() + key.slice(1) // Handle nested objects/arrays by JSON stringifying them @@ -82,22 +79,22 @@ export function formatContentBlockToMarkdown(block: ExtendedContentBlock): strin }) .join("\n") } else { - input = String(block.input) + inputStr = String(block.input) } - return `[Tool Use: ${block.name}]\n${input}` + return `[Tool Use: ${block.toolName}]\n${inputStr}` } - case "tool_result": { - // For now we're not doing tool name lookup since we don't use tools anymore - // const toolName = findToolName(block.tool_use_id, messages) - const toolName = "Tool" - if (typeof block.content === "string") { - return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content}` - } else if (Array.isArray(block.content)) { - return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content - .map((contentBlock) => formatContentBlockToMarkdown(contentBlock)) + case "tool-result": { + const toolName = block.toolName || "Tool" + const isError = block.output?.type === "error-text" || block.output?.type === "error-json" + const errorSuffix = isError ? " (Error)" : "" + if (block.output?.type === "text" || block.output?.type === "error-text") { + return `[${toolName}${errorSuffix}]\n${block.output.value}` + } else if (block.output?.type === "content") { + return `[${toolName}${errorSuffix}]\n${(block.output.value as Array) + .map((contentBlock: any) => formatContentBlockToMarkdown(contentBlock)) .join("\n")}` } else { - return `[${toolName}${block.is_error ? " (Error)" : ""}]` + return `[${toolName}${errorSuffix}]` } } case "reasoning": @@ -110,12 +107,12 @@ export function formatContentBlockToMarkdown(block: ExtendedContentBlock): strin } } -export function findToolName(toolCallId: string, messages: Anthropic.MessageParam[]): string { +export function findToolName(toolCallId: string, messages: NeutralMessageParam[]): string { for (const message of messages) { if (Array.isArray(message.content)) { for (const block of message.content) { - if (block.type === "tool_use" && block.id === toolCallId) { - return block.name + if (block.type === "tool-call" && block.toolCallId === toolCallId) { + return block.toolName } } } diff --git a/src/integrations/misc/line-counter.ts b/src/integrations/misc/line-counter.ts index d066d565e88..1819856fa88 100644 --- a/src/integrations/misc/line-counter.ts +++ b/src/integrations/misc/line-counter.ts @@ -1,7 +1,7 @@ import fs, { createReadStream } from "fs" import { createInterface } from "readline" import { countTokens } from "../../utils/countTokens" -import { Anthropic } from "@anthropic-ai/sdk" +import type { NeutralContentBlock } from "../../core/task-persistence" /** * Efficiently counts lines in a file using streams without loading the entire file into memory @@ -102,7 +102,7 @@ export async function countFileLinesAndTokens( lineBuffer = [] // Clear buffer before processing try { - const contentBlocks: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: bufferText }] + const contentBlocks: NeutralContentBlock[] = [{ type: "text", text: bufferText }] const chunkTokens = await countTokens(contentBlocks) tokenEstimate += chunkTokens } catch (error) { diff --git a/src/package.json b/src/package.json index 72fb3b5df99..49a8047d733 100644 --- a/src/package.json +++ b/src/package.json @@ -451,6 +451,7 @@ }, "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.51", + "@ai-sdk/provider": "^3.0.8", "@ai-sdk/anthropic": "^3.0.38", "@ai-sdk/azure": "^2.0.6", "@ai-sdk/baseten": "^1.0.31", diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 570f55c4f2f..7f7ba9367d5 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -1,5 +1,3 @@ -import { Anthropic } from "@anthropic-ai/sdk" - import type { ClineAsk, ToolProgressStatus, @@ -9,7 +7,9 @@ import type { GenerateImageParams, } from "@roo-code/types" -export type ToolResponse = string | Array +import type { NeutralTextBlock, NeutralImageBlock } from "../core/task-persistence" + +export type ToolResponse = string | Array export type AskApproval = ( type: ClineAsk, @@ -129,26 +129,15 @@ export type NativeToolArgs = { * * @template TName - The specific tool name, which determines the nativeArgs type */ -export interface ToolUse { - type: "tool_use" - id?: string // Optional ID to track tool calls - name: TName - /** - * The original tool name as called by the model (e.g. an alias like "edit_file"), - * if it differs from the canonical tool name used for execution. - * Used to preserve tool names in API conversation history. - */ - originalName?: string - // params is a partial record, allowing only some or none of the possible parameters to be used - params: Partial> - partial: boolean - // nativeArgs is properly typed based on TName if it's in NativeToolArgs, otherwise never - nativeArgs?: TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never - /** - * Flag indicating whether the tool call used a legacy/deprecated format. - * Used for telemetry tracking to monitor migration from old formats. - */ +export interface ToolUse { + type: "tool-call" + toolCallId: string + toolName: string + input: Record + partial?: boolean + nativeArgs?: any usedLegacyFormat?: boolean + originalName?: string } /** @@ -171,13 +160,13 @@ export interface McpToolUse { partial: boolean } -export interface ExecuteCommandToolUse extends ToolUse<"execute_command"> { +export interface ExecuteCommandToolUse extends ToolUse { name: "execute_command" // Pick, "command"> makes "command" required, but Partial<> makes it optional params: Partial, "command" | "cwd">> } -export interface ReadFileToolUse extends ToolUse<"read_file"> { +export interface ReadFileToolUse extends ToolUse { name: "read_file" params: Partial< Pick< @@ -198,72 +187,72 @@ export interface ReadFileToolUse extends ToolUse<"read_file"> { > } -export interface WriteToFileToolUse extends ToolUse<"write_to_file"> { +export interface WriteToFileToolUse extends ToolUse { name: "write_to_file" params: Partial, "path" | "content">> } -export interface CodebaseSearchToolUse extends ToolUse<"codebase_search"> { +export interface CodebaseSearchToolUse extends ToolUse { name: "codebase_search" params: Partial, "query" | "path">> } -export interface SearchFilesToolUse extends ToolUse<"search_files"> { +export interface SearchFilesToolUse extends ToolUse { name: "search_files" params: Partial, "path" | "regex" | "file_pattern">> } -export interface ListFilesToolUse extends ToolUse<"list_files"> { +export interface ListFilesToolUse extends ToolUse { name: "list_files" params: Partial, "path" | "recursive">> } -export interface BrowserActionToolUse extends ToolUse<"browser_action"> { +export interface BrowserActionToolUse extends ToolUse { name: "browser_action" params: Partial, "action" | "url" | "coordinate" | "text" | "size" | "path">> } -export interface UseMcpToolToolUse extends ToolUse<"use_mcp_tool"> { +export interface UseMcpToolToolUse extends ToolUse { name: "use_mcp_tool" params: Partial, "server_name" | "tool_name" | "arguments">> } -export interface AccessMcpResourceToolUse extends ToolUse<"access_mcp_resource"> { +export interface AccessMcpResourceToolUse extends ToolUse { name: "access_mcp_resource" params: Partial, "server_name" | "uri">> } -export interface AskFollowupQuestionToolUse extends ToolUse<"ask_followup_question"> { +export interface AskFollowupQuestionToolUse extends ToolUse { name: "ask_followup_question" params: Partial, "question" | "follow_up">> } -export interface AttemptCompletionToolUse extends ToolUse<"attempt_completion"> { +export interface AttemptCompletionToolUse extends ToolUse { name: "attempt_completion" params: Partial, "result">> } -export interface SwitchModeToolUse extends ToolUse<"switch_mode"> { +export interface SwitchModeToolUse extends ToolUse { name: "switch_mode" params: Partial, "mode_slug" | "reason">> } -export interface NewTaskToolUse extends ToolUse<"new_task"> { +export interface NewTaskToolUse extends ToolUse { name: "new_task" params: Partial, "mode" | "message" | "todos">> } -export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { +export interface RunSlashCommandToolUse extends ToolUse { name: "run_slash_command" params: Partial, "command" | "args">> } -export interface SkillToolUse extends ToolUse<"skill"> { +export interface SkillToolUse extends ToolUse { name: "skill" params: Partial, "skill" | "args">> } -export interface GenerateImageToolUse extends ToolUse<"generate_image"> { +export interface GenerateImageToolUse extends ToolUse { name: "generate_image" params: Partial, "prompt" | "path" | "image">> } diff --git a/src/utils/__tests__/tiktoken.spec.ts b/src/utils/__tests__/tiktoken.spec.ts index bae81adcf2a..933fb31580c 100644 --- a/src/utils/__tests__/tiktoken.spec.ts +++ b/src/utils/__tests__/tiktoken.spec.ts @@ -1,7 +1,7 @@ // npx vitest utils/__tests__/tiktoken.spec.ts +import type { RooContentBlock } from "../../core/task-persistence/apiMessages" import { tiktoken } from "../tiktoken" -import { Anthropic } from "@anthropic-ai/sdk" describe("tiktoken", () => { it("should return 0 for empty content array", async () => { @@ -10,7 +10,7 @@ describe("tiktoken", () => { }) it("should correctly count tokens for text content", async () => { - const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "Hello world" }] + const content: RooContentBlock[] = [{ type: "text", text: "Hello world" }] const result = await tiktoken(content) // We can't predict the exact token count without mocking, @@ -19,16 +19,14 @@ describe("tiktoken", () => { }) it("should handle empty text content", async () => { - const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "" }] + const content: RooContentBlock[] = [{ type: "text", text: "" }] const result = await tiktoken(content) expect(result).toBe(0) }) it("should not throw on text content with special tokens", async () => { - const content: Anthropic.Messages.ContentBlockParam[] = [ - { type: "text", text: "something<|endoftext|>something" }, - ] + const content: RooContentBlock[] = [{ type: "text", text: "something<|endoftext|>something" }] const result = await tiktoken(content) expect(result).toBeGreaterThan(0) @@ -37,7 +35,7 @@ describe("tiktoken", () => { it("should handle missing text content", async () => { // Using 'as any' to bypass TypeScript's type checking for this test case // since we're specifically testing how the function handles undefined text - const content = [{ type: "text" }] as any as Anthropic.Messages.ContentBlockParam[] + const content = [{ type: "text" }] as any as RooContentBlock[] const result = await tiktoken(content) expect(result).toBe(0) @@ -46,14 +44,11 @@ describe("tiktoken", () => { it("should correctly count tokens for image content with data", async () => { const base64Data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" - const content: Anthropic.Messages.ContentBlockParam[] = [ + const content: RooContentBlock[] = [ { type: "image", - source: { - type: "base64", - media_type: "image/png", - data: base64Data, - }, + image: base64Data, + mediaType: "image/png", }, ] @@ -76,7 +71,7 @@ describe("tiktoken", () => { // data is intentionally missing to test fallback }, }, - ] as any as Anthropic.Messages.ContentBlockParam[] + ] as any as RooContentBlock[] const result = await tiktoken(content) // Conservative estimate is 300 tokens, plus the fudge factor @@ -87,15 +82,12 @@ describe("tiktoken", () => { it("should correctly count tokens for mixed content", async () => { const base64Data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" - const content: Anthropic.Messages.ContentBlockParam[] = [ + const content: RooContentBlock[] = [ { type: "text", text: "Hello world" }, { type: "image", - source: { - type: "base64", - media_type: "image/png", - data: base64Data, - }, + image: base64Data, + mediaType: "image/png", }, { type: "text", text: "Goodbye world" }, ] @@ -107,7 +99,7 @@ describe("tiktoken", () => { it("should apply a fudge factor to the token count", async () => { // We can test the fudge factor by comparing the token count with a rough estimate - const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "Test" }] + const content: RooContentBlock[] = [{ type: "text", text: "Test" }] const result = await tiktoken(content) @@ -126,7 +118,7 @@ describe("tiktoken", () => { // but we can test that multiple calls with the same content return the same result // which indirectly verifies the encoder is working consistently - const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "Hello world" }] + const content: RooContentBlock[] = [{ type: "text", text: "Hello world" }] const result1 = await tiktoken(content) const result2 = await tiktoken(content) @@ -139,12 +131,12 @@ describe("tiktoken", () => { it("should count tokens for tool_use blocks with simple arguments", async () => { const content = [ { - type: "tool_use", - id: "tool_123", - name: "read_file", + type: "tool-call", + toolCallId: "tool_123", + toolName: "read_file", input: { path: "/src/main.ts" }, }, - ] as Anthropic.Messages.ContentBlockParam[] + ] as RooContentBlock[] const result = await tiktoken(content) // Should return a positive token count for the serialized tool call @@ -154,16 +146,16 @@ describe("tiktoken", () => { it("should count tokens for tool_use blocks with complex arguments", async () => { const content = [ { - type: "tool_use", - id: "tool_456", - name: "write_to_file", + type: "tool-call", + toolCallId: "tool_456", + toolName: "write_to_file", input: { path: "/src/components/Button.tsx", content: "import React from 'react';\n\nexport const Button = ({ children, onClick }) => {\n return ;\n};", }, }, - ] as Anthropic.Messages.ContentBlockParam[] + ] as RooContentBlock[] const result = await tiktoken(content) // Should return a token count reflecting the larger content @@ -173,12 +165,12 @@ describe("tiktoken", () => { it("should handle tool_use blocks with empty input", async () => { const content = [ { - type: "tool_use", - id: "tool_789", - name: "list_files", + type: "tool-call", + toolCallId: "tool_789", + toolName: "list_files", input: {}, }, - ] as Anthropic.Messages.ContentBlockParam[] + ] as RooContentBlock[] const result = await tiktoken(content) // Should still count the tool name (and empty args) @@ -190,11 +182,12 @@ describe("tiktoken", () => { it("should count tokens for tool_result blocks with string content", async () => { const content = [ { - type: "tool_result", - tool_use_id: "tool_123", - content: "File content: export const foo = 'bar';", + type: "tool-result", + toolCallId: "tool_123", + toolName: "", + output: { type: "text" as const, value: "File content: export const foo = 'bar';" }, }, - ] as Anthropic.Messages.ContentBlockParam[] + ] as RooContentBlock[] const result = await tiktoken(content) // Should return a positive token count @@ -204,14 +197,18 @@ describe("tiktoken", () => { it("should count tokens for tool_result blocks with array content", async () => { const content = [ { - type: "tool_result", - tool_use_id: "tool_456", - content: [ - { type: "text", text: "First part of the result" }, - { type: "text", text: "Second part of the result" }, - ], + type: "tool-result" as const, + toolCallId: "tool_456", + toolName: "", + output: { + type: "content" as const, + value: [ + { type: "text" as const, text: "First part of the result" }, + { type: "text" as const, text: "Second part of the result" }, + ], + }, }, - ] as Anthropic.Messages.ContentBlockParam[] + ] as RooContentBlock[] const result = await tiktoken(content) // Should count tokens from all text parts @@ -221,12 +218,12 @@ describe("tiktoken", () => { it("should count tokens for tool_result blocks with error flag", async () => { const content = [ { - type: "tool_result", - tool_use_id: "tool_789", - is_error: true, - content: "Error: File not found", + type: "tool-result", + toolCallId: "tool_789", + toolName: "", + output: { type: "text" as const, value: "Error: File not found" }, }, - ] as Anthropic.Messages.ContentBlockParam[] + ] as RooContentBlock[] const result = await tiktoken(content) // Should include the error indicator and content @@ -236,14 +233,18 @@ describe("tiktoken", () => { it("should handle tool_result blocks with image content in array", async () => { const content = [ { - type: "tool_result", - tool_use_id: "tool_abc", - content: [ - { type: "text", text: "Screenshot captured" }, - { type: "image", source: { type: "base64", media_type: "image/png", data: "abc123" } }, - ], + type: "tool-result" as const, + toolCallId: "tool_abc", + toolName: "", + output: { + type: "content" as const, + value: [ + { type: "text" as const, text: "Screenshot captured" }, + { type: "image-data" as const, data: "abc123", mediaType: "image/png" }, + ], + }, }, - ] as Anthropic.Messages.ContentBlockParam[] + ] as RooContentBlock[] const result = await tiktoken(content) // Should count text and include placeholder for images @@ -256,12 +257,12 @@ describe("tiktoken", () => { const content = [ { type: "text", text: "Let me read that file for you." }, { - type: "tool_use", - id: "tool_123", - name: "read_file", + type: "tool-call", + toolCallId: "tool_123", + toolName: "read_file", input: { path: "/src/index.ts" }, }, - ] as Anthropic.Messages.ContentBlockParam[] + ] as RooContentBlock[] const result = await tiktoken(content) // Should count both text and tool_use tokens @@ -271,20 +272,24 @@ describe("tiktoken", () => { it("should produce larger count for tool_result with large content vs small content", async () => { const smallContent = [ { - type: "tool_result", - tool_use_id: "tool_1", - content: "OK", + type: "tool-result", + toolCallId: "tool_1", + toolName: "", + output: { type: "text" as const, value: "OK" }, }, - ] as Anthropic.Messages.ContentBlockParam[] + ] as RooContentBlock[] const largeContent = [ { - type: "tool_result", - tool_use_id: "tool_2", - content: - "This is a much longer result that contains a lot more text and should therefore have a significantly higher token count than the small content.", + type: "tool-result", + toolCallId: "tool_2", + toolName: "", + output: { + type: "text" as const, + value: "This is a much longer result that contains a lot more text and should therefore have a significantly higher token count than the small content.", + }, }, - ] as Anthropic.Messages.ContentBlockParam[] + ] as RooContentBlock[] const smallResult = await tiktoken(smallContent) const largeResult = await tiktoken(largeContent) diff --git a/src/utils/countTokens.ts b/src/utils/countTokens.ts index 7ab4b3bdf2d..ad3d412c50d 100644 --- a/src/utils/countTokens.ts +++ b/src/utils/countTokens.ts @@ -1,4 +1,3 @@ -import { Anthropic } from "@anthropic-ai/sdk" import workerpool from "workerpool" import { countTokensResultSchema } from "../workers/types" @@ -10,8 +9,10 @@ export type CountTokensOptions = { useWorker?: boolean } +import type { NeutralContentBlock } from "../core/task-persistence" + export async function countTokens( - content: Anthropic.Messages.ContentBlockParam[], + content: NeutralContentBlock[], { useWorker = true }: CountTokensOptions = {}, ): Promise { // Lazily create the worker pool if it doesn't exist. diff --git a/src/utils/tiktoken.ts b/src/utils/tiktoken.ts index b543873fc63..a2493dae161 100644 --- a/src/utils/tiktoken.ts +++ b/src/utils/tiktoken.ts @@ -1,7 +1,8 @@ -import { Anthropic } from "@anthropic-ai/sdk" import { Tiktoken } from "tiktoken/lite" import o200kBase from "tiktoken/encoders/o200k_base" +import type { NeutralContentBlock, NeutralToolUseBlock, NeutralToolResultBlock } from "../core/task-persistence" + const TOKEN_FUDGE_FACTOR = 1.5 let encoder: Tiktoken | null = null @@ -10,8 +11,8 @@ let encoder: Tiktoken | null = null * Serializes a tool_use block to text for token counting. * Approximates how the API sees the tool call. */ -function serializeToolUse(block: Anthropic.Messages.ToolUseBlockParam): string { - const parts = [`Tool: ${block.name}`] +function serializeToolUse(block: NeutralToolUseBlock): string { + const parts = [`Tool: ${block.toolName}`] if (block.input !== undefined) { try { parts.push(`Arguments: ${JSON.stringify(block.input)}`) @@ -26,19 +27,20 @@ function serializeToolUse(block: Anthropic.Messages.ToolUseBlockParam): string { * Serializes a tool_result block to text for token counting. * Handles both string content and array content. */ -function serializeToolResult(block: Anthropic.Messages.ToolResultBlockParam): string { - const parts = [`Tool Result (${block.tool_use_id})`] +function serializeToolResult(block: NeutralToolResultBlock): string { + const parts = [`Tool Result (${block.toolCallId})`] - if (block.is_error) { + const isError = block.output?.type === "error-text" || block.output?.type === "error-json" + if (isError) { parts.push(`[Error]`) } - const content = block.content - if (typeof content === "string") { - parts.push(content) - } else if (Array.isArray(content)) { + const output = block.output + if (output?.type === "text" || output?.type === "error-text") { + parts.push(output.value) + } else if (output?.type === "content") { // Handle array of content blocks recursively - for (const item of content) { + for (const item of output.value as Array) { if (item.type === "text") { parts.push(item.text || "") } else if (item.type === "image") { @@ -47,12 +49,14 @@ function serializeToolResult(block: Anthropic.Messages.ToolResultBlockParam): st parts.push(`[Unsupported content block: ${String((item as { type?: unknown }).type)}]`) } } + } else if (output?.type === "json" || output?.type === "error-json") { + parts.push(JSON.stringify(output.value)) } return parts.join("\n") } -export async function tiktoken(content: Anthropic.Messages.ContentBlockParam[]): Promise { +export async function tiktoken(content: NeutralContentBlock[]): Promise { if (content.length === 0) { return 0 } @@ -75,24 +79,23 @@ export async function tiktoken(content: Anthropic.Messages.ContentBlockParam[]): } } else if (block.type === "image") { // For images, calculate based on data size. - const imageSource = block.source + const imageData = block.image - if (imageSource && typeof imageSource === "object" && "data" in imageSource) { - const base64Data = imageSource.data as string - totalTokens += Math.ceil(Math.sqrt(base64Data.length)) + if (imageData && typeof imageData === "string") { + totalTokens += Math.ceil(Math.sqrt(imageData.length)) } else { totalTokens += 300 // Conservative estimate for unknown images } - } else if (block.type === "tool_use") { - // Serialize tool_use block to text and count tokens - const serialized = serializeToolUse(block as Anthropic.Messages.ToolUseBlockParam) + } else if (block.type === "tool-call") { + // Serialize tool-call block to text and count tokens + const serialized = serializeToolUse(block as NeutralToolUseBlock) if (serialized.length > 0) { const tokens = encoder.encode(serialized, undefined, []) totalTokens += tokens.length } - } else if (block.type === "tool_result") { - // Serialize tool_result block to text and count tokens - const serialized = serializeToolResult(block as Anthropic.Messages.ToolResultBlockParam) + } else if (block.type === "tool-result") { + // Serialize tool-result block to text and count tokens + const serialized = serializeToolResult(block as NeutralToolResultBlock) if (serialized.length > 0) { const tokens = encoder.encode(serialized, undefined, []) totalTokens += tokens.length diff --git a/src/workers/countTokens.ts b/src/workers/countTokens.ts index 9e1b0034a36..e3cfddf3641 100644 --- a/src/workers/countTokens.ts +++ b/src/workers/countTokens.ts @@ -1,12 +1,12 @@ import workerpool from "workerpool" -import { Anthropic } from "@anthropic-ai/sdk" +import type { NeutralContentBlock } from "../core/task-persistence" import { tiktoken } from "../utils/tiktoken" import { type CountTokensResult } from "./types" -async function countTokens(content: Anthropic.Messages.ContentBlockParam[]): Promise { +async function countTokens(content: NeutralContentBlock[]): Promise { try { const count = await tiktoken(content) return { success: true, count }