diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f01b8654ad..304c654ef7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -782,9 +782,6 @@ importers: '@anthropic-ai/sdk': specifier: ^0.37.0 version: 0.37.0 - '@anthropic-ai/vertex-sdk': - specifier: ^0.7.0 - version: 0.7.0 '@aws-sdk/client-bedrock-runtime': specifier: ^3.922.0 version: 3.922.0 @@ -872,9 +869,6 @@ importers: global-agent: specifier: ^3.0.0 version: 3.0.0 - google-auth-library: - specifier: ^9.15.1 - version: 9.15.1 gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -1567,9 +1561,6 @@ packages: '@anthropic-ai/sdk@0.37.0': resolution: {integrity: sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==} - '@anthropic-ai/vertex-sdk@0.7.0': - resolution: {integrity: sha512-zNm3hUXgYmYDTyveIxOyxbcnh5VXFkrLo4bSnG6LAfGzW7k3k2iCNDSVKtR9qZrK2BCid7JtVu7jsEKaZ/9dSw==} - '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -6815,18 +6806,10 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} deprecated: This package is no longer supported. - gaxios@6.7.1: - resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} - engines: {node: '>=14'} - gaxios@7.1.3: resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} engines: {node: '>=18'} - gcp-metadata@6.1.1: - resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} - engines: {node: '>=14'} - gcp-metadata@8.1.2: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} @@ -6947,14 +6930,6 @@ packages: resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} engines: {node: '>=18'} - google-auth-library@9.15.1: - resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} - engines: {node: '>=14'} - - google-logging-utils@0.0.2: - resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} - engines: {node: '>=14'} - google-logging-utils@1.1.3: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} @@ -6973,10 +6948,6 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - gtoken@7.1.0: - resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} - engines: {node: '>=14.0.0'} - gtoken@8.0.0: resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} engines: {node: '>=18'} @@ -10539,10 +10510,6 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - hasBin: true - v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -11232,14 +11199,6 @@ snapshots: transitivePeerDependencies: - encoding - '@anthropic-ai/vertex-sdk@0.7.0': - dependencies: - '@anthropic-ai/sdk': 0.37.0 - google-auth-library: 9.15.1 - transitivePeerDependencies: - - encoding - - supports-color - '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -17221,17 +17180,6 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 - gaxios@6.7.1: - dependencies: - extend: 3.0.2 - https-proxy-agent: 7.0.6 - is-stream: 2.0.1 - node-fetch: 2.7.0 - uuid: 9.0.1 - transitivePeerDependencies: - - encoding - - supports-color - gaxios@7.1.3: dependencies: extend: 3.0.2 @@ -17241,15 +17189,6 @@ snapshots: transitivePeerDependencies: - supports-color - gcp-metadata@6.1.1: - dependencies: - gaxios: 6.7.1 - google-logging-utils: 0.0.2 - json-bigint: 1.0.0 - transitivePeerDependencies: - - encoding - - supports-color - gcp-metadata@8.1.2: dependencies: gaxios: 7.1.3 @@ -17398,20 +17337,6 @@ snapshots: transitivePeerDependencies: - supports-color - google-auth-library@9.15.1: - dependencies: - base64-js: 1.5.1 - ecdsa-sig-formatter: 1.0.11 - gaxios: 6.7.1 - gcp-metadata: 6.1.1 - gtoken: 7.1.0 - jws: 4.0.0 - transitivePeerDependencies: - - encoding - - supports-color - - google-logging-utils@0.0.2: {} - google-logging-utils@1.1.3: {} gopd@1.2.0: {} @@ -17427,14 +17352,6 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - gtoken@7.1.0: - dependencies: - gaxios: 6.7.1 - jws: 4.0.0 - transitivePeerDependencies: - - encoding - - supports-color - gtoken@8.0.0: dependencies: gaxios: 7.1.3 @@ -21641,8 +21558,6 @@ snapshots: uuid@8.3.2: {} - uuid@9.0.1: {} - v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.25 diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 3d9798fde9..3341a0f584 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -1,57 +1,97 @@ // npx vitest run src/api/providers/__tests__/anthropic-vertex.spec.ts -import { Anthropic } from "@anthropic-ai/sdk" -import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" +import { AnthropicVertexHandler } from "../anthropic-vertex" +import { ApiHandlerOptions } from "../../../shared/api" import { VERTEX_1M_CONTEXT_MODEL_IDS } from "@roo-code/types" import { ApiStreamChunk } from "../../transform/stream" -import { AnthropicVertexHandler } from "../anthropic-vertex" - -vitest.mock("@anthropic-ai/vertex-sdk", () => ({ - AnthropicVertex: vitest.fn().mockImplementation(() => ({ - messages: { - create: vitest.fn().mockImplementation(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - content: [{ type: "text", text: "Test response" }], - role: "assistant", - model: options.model, - usage: { - input_tokens: 10, - output_tokens: 5, - }, - } - } - return { - async *[Symbol.asyncIterator]() { - yield { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 5, - }, - }, - } - yield { - type: "content_block_start", - content_block: { - type: "text", - text: "Test response", - }, - } - }, - } - }), +// Mock TelemetryService +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vitest.fn(), }, - })), + }, })) -describe("VertexHandler", () => { +// 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() + +vitest.mock("@ai-sdk/google-vertex/anthropic", () => ({ + createVertexAnthropic: (...args: any[]) => mockCreateVertexAnthropic(...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 } + } + }), + 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, +) { + return { + fullStream: (async function* () { + for (const part of parts) { + yield part + } + })(), + usage: Promise.resolve(usage ?? { inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve(providerMetadata ?? {}), + } +} + +describe("AnthropicVertexHandler", () => { let handler: AnthropicVertexHandler + let mockProviderFn: ReturnType + + beforeEach(() => { + mockProviderFn = createMockProviderFn() + mockCreateVertexAnthropic.mockReturnValue(mockProviderFn) + vitest.clearAllMocks() + }) describe("constructor", () => { it("should initialize with provided config for Claude", () => { @@ -61,10 +101,85 @@ describe("VertexHandler", () => { vertexRegion: "us-central1", }) - expect(AnthropicVertex).toHaveBeenCalledWith({ - projectId: "test-project", - region: "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 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() }) }) @@ -82,57 +197,21 @@ describe("VertexHandler", () => { const systemPrompt = "You are a helpful assistant" - it("should handle streaming responses correctly for Claude", async () => { + beforeEach(() => { handler = new AnthropicVertexHandler({ apiModelId: "claude-3-5-sonnet-v2@20241022", vertexProjectId: "test-project", vertexRegion: "us-central1", }) + }) - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "text", - text: "Hello", - }, - }, - { - type: "content_block_delta", - delta: { - type: "text_delta", - text: " world!", - }, - }, - { - type: "message_delta", - usage: { - output_tokens: 5, - }, - }, + it("should handle streaming responses correctly for Claude", async () => { + const streamParts = [ + { type: "text-delta", text: "Hello" }, + { type: "text-delta", text: " world!" }, ] - // Setup async iterator for mock stream - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate + mockStreamText.mockReturnValue(createMockStreamResult(streamParts, { inputTokens: 10, outputTokens: 5 })) const stream = handler.createMessage(systemPrompt, mockMessages) const chunks: ApiStreamChunk[] = [] @@ -141,318 +220,131 @@ describe("VertexHandler", () => { chunks.push(chunk) } - expect(chunks.length).toBe(4) - expect(chunks[0]).toEqual({ + // 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!" }) + + // Usage chunk at the end + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 10, - outputTokens: 0, - }) - expect(chunks[1]).toEqual({ - type: "text", - text: "Hello", - }) - expect(chunks[2]).toEqual({ - type: "text", - text: " world!", - }) - expect(chunks[3]).toEqual({ - type: "usage", - inputTokens: 0, outputTokens: 5, }) - expect(mockCreate).toHaveBeenCalledWith( + // Verify streamText was called with correct params + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - model: "claude-3-5-sonnet-v2@20241022", - max_tokens: 8192, - temperature: 0, - thinking: undefined, - system: [ - { - type: "text", - text: "You are a helpful assistant", - cache_control: { type: "ephemeral" }, - }, - ], - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: "Hello", - cache_control: { type: "ephemeral" }, - }, - ], - }, - { - role: "assistant", - content: "Hi there!", - }, - ], - stream: true, - // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) - tools: expect.any(Array), - tool_choice: expect.any(Object), + model: "mock-model", + system: systemPrompt, }), - undefined, ) }) - it("should handle multiple content blocks with line breaks for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) + it("should call convertToAiSdkMessages with the messages", async () => { + mockStreamText.mockReturnValue(createMockStreamResult([])) - const mockStream = [ + 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: "content_block_start", - index: 0, - content_block: { - type: "text", - text: "First line", - }, - }, - { - type: "content_block_start", - index: 1, - content_block: { - type: "text", - text: "Second line", + type: "function" as const, + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { + type: "object", + properties: { location: { type: "string" } }, + required: ["location"], + }, }, }, ] - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, + const stream = handler.createMessage(systemPrompt, mockMessages, { + taskId: "test-task", + tools: mockTools, + }) + + for await (const _chunk of stream) { + // consume } - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, mockMessages) - const chunks: ApiStreamChunk[] = [] - - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks.length).toBe(3) - expect(chunks[0]).toEqual({ - type: "text", - text: "First line", - }) - expect(chunks[1]).toEqual({ - type: "text", - text: "\n", - }) - expect(chunks[2]).toEqual({ - type: "text", - text: "Second line", - }) + expect(convertToolsForAiSdk).toHaveBeenCalled() }) it("should handle API errors for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - const mockError = new Error("Vertex API error") - const mockCreate = vitest.fn().mockRejectedValue(mockError) - ;(handler["client"].messages as any).create = mockCreate + mockStreamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", text: "" } + throw mockError + })(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) const stream = handler.createMessage(systemPrompt, mockMessages) await expect(async () => { for await (const _chunk of stream) { - // Should throw before yielding any chunks + // Should throw before yielding meaningful chunks } - }).rejects.toThrow("Vertex API error") + }).rejects.toThrow() }) - it("should handle prompt caching for supported models for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - cache_creation_input_tokens: 3, - cache_read_input_tokens: 2, + 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, }, }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "text", - text: "Hello", - }, - }, - { - type: "content_block_delta", - delta: { - type: "text_delta", - text: " world!", - }, - }, - { - type: "message_delta", - usage: { - output_tokens: 5, - }, - }, - ] - - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, [ - { - role: "user", - content: "First message", - }, - { - role: "assistant", - content: "Response", - }, - { - role: "user", - content: "Second message", - }, - ]) + ), + ) + const stream = handler.createMessage(systemPrompt, mockMessages) const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { chunks.push(chunk) } - // Verify usage information - const usageChunks = chunks.filter((chunk) => chunk.type === "usage") - expect(usageChunks).toHaveLength(2) - expect(usageChunks[0]).toEqual({ + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 10, - outputTokens: 0, + outputTokens: 5, cacheWriteTokens: 3, cacheReadTokens: 2, }) - expect(usageChunks[1]).toEqual({ - type: "usage", - inputTokens: 0, - outputTokens: 5, - }) - - // 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 cache control was added correctly - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - system: [ - { - type: "text", - text: "You are a helpful assistant", - cache_control: { type: "ephemeral" }, - }, - ], - messages: [ - expect.objectContaining({ - role: "user", - content: [ - { - type: "text", - text: "First message", - cache_control: { type: "ephemeral" }, - }, - ], - }), - expect.objectContaining({ - role: "assistant", - content: "Response", - }), - expect.objectContaining({ - role: "user", - content: [ - { - type: "text", - text: "Second message", - cache_control: { type: "ephemeral" }, - }, - ], - }), - ], - }), - undefined, - ) }) - it("should handle cache-related usage metrics for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - cache_creation_input_tokens: 5, - cache_read_input_tokens: 3, - }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "text", - text: "Hello", - }, - }, + 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:" }, ] - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate + mockStreamText.mockReturnValue(createMockStreamResult(streamParts)) const stream = handler.createMessage(systemPrompt, mockMessages) const chunks: ApiStreamChunk[] = [] @@ -461,368 +353,126 @@ describe("VertexHandler", () => { chunks.push(chunk) } - // Check for cache-related metrics in usage chunk - const usageChunks = chunks.filter((chunk) => chunk.type === "usage") - expect(usageChunks.length).toBeGreaterThan(0) - expect(usageChunks[0]).toHaveProperty("cacheWriteTokens", 5) - expect(usageChunks[0]).toHaveProperty("cacheReadTokens", 3) - }) - }) - - describe("thinking functionality", () => { - const mockMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - ] - - const systemPrompt = "You are a helpful assistant" - - it("should handle thinking content blocks and deltas for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "thinking", - thinking: "Let me think about this...", - }, - }, - { - type: "content_block_delta", - delta: { - type: "thinking_delta", - thinking: " I need to consider all options.", - }, - }, - { - type: "content_block_start", - index: 1, - content_block: { - type: "text", - text: "Here's my answer:", - }, - }, - ] - - // Setup async iterator for mock stream - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, mockMessages) - const chunks: ApiStreamChunk[] = [] - - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Verify thinking content is processed correctly - const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + 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.") - // Verify text content is processed correctly - const textChunks = chunks.filter((chunk) => chunk.type === "text") - expect(textChunks).toHaveLength(2) // One for the text block, one for the newline - expect(textChunks[0].text).toBe("\n") - expect(textChunks[1].text).toBe("Here's my answer:") + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Here's my answer:") }) - it("should handle multiple thinking blocks with line breaks for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ + it("should capture thought signature from stream events", async () => { + const streamParts = [ { - type: "content_block_start", - index: 0, - content_block: { - type: "thinking", - thinking: "First thinking block", - }, - }, - { - type: "content_block_start", - index: 1, - content_block: { - type: "thinking", - thinking: "Second thinking block", + type: "reasoning-delta", + text: "thinking...", + providerMetadata: { + anthropic: { signature: "test-signature-abc123" }, }, }, + { type: "text-delta", text: "answer" }, ] - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate + mockStreamText.mockReturnValue(createMockStreamResult(streamParts)) const stream = handler.createMessage(systemPrompt, mockMessages) - const chunks: ApiStreamChunk[] = [] - - for await (const chunk of stream) { - chunks.push(chunk) + for await (const _chunk of stream) { + // consume } - expect(chunks.length).toBe(3) - expect(chunks[0]).toEqual({ - type: "reasoning", - text: "First thinking block", - }) - expect(chunks[1]).toEqual({ - type: "reasoning", - text: "\n", - }) - expect(chunks[2]).toEqual({ - type: "reasoning", - text: "Second thinking block", + expect(handler.getThoughtSignature()).toBe("test-signature-abc123") + }) + + it("should capture redacted thinking blocks from stream events", async () => { + const streamParts = [ + { + type: "reasoning-delta", + text: "", + providerMetadata: { + anthropic: { redactedData: "encrypted-redacted-data" }, + }, + }, + { type: "text-delta", text: "answer" }, + ] + + mockStreamText.mockReturnValue(createMockStreamResult(streamParts)) + + const stream = handler.createMessage(systemPrompt, mockMessages) + 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", }) }) - it("should filter out internal reasoning blocks before sending to API", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", + 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, }) - const mockCreate = vitest.fn().mockImplementation(async (options) => { - return { - async *[Symbol.asyncIterator]() { - yield { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - } - yield { - type: "content_block_start", - index: 0, - content_block: { - type: "text", - text: "Response", - }, - } - }, - } - }) - ;(handler["client"].messages as any).create = mockCreate + mockStreamText.mockReturnValue(createMockStreamResult([])) - // Messages with internal reasoning blocks (from stored conversation history) - const messagesWithReasoning: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: [ - { - type: "reasoning" as any, - text: "This is internal reasoning that should be filtered", - }, - { - type: "text", - text: "This is the response", - }, - ], - }, - { - role: "user", - content: "Continue", - }, - ] - - const stream = handler.createMessage(systemPrompt, messagesWithReasoning) - const chunks: ApiStreamChunk[] = [] - - for await (const chunk of stream) { - chunks.push(chunk) + const stream = thinkingHandler.createMessage(systemPrompt, [{ role: "user", content: "Hello" }]) + for await (const _chunk of stream) { + // consume } - // Verify the API was called with filtered messages (no reasoning blocks) - const calledMessages = mockCreate.mock.calls[0][0].messages - expect(calledMessages).toHaveLength(3) - - // Check user message 1 - expect(calledMessages[0]).toMatchObject({ - role: "user", - }) - - // Check assistant message - should have reasoning block filtered out - const assistantMessage = calledMessages.find((m: any) => m.role === "assistant") - expect(assistantMessage).toBeDefined() - expect(assistantMessage.content).toEqual([{ type: "text", text: "This is the response" }]) - - // Verify reasoning blocks were NOT sent to the API - expect(assistantMessage.content).not.toContainEqual(expect.objectContaining({ type: "reasoning" })) - }) - - it("should filter empty messages after removing all reasoning blocks", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockCreate = vitest.fn().mockImplementation(async (options) => { - return { - async *[Symbol.asyncIterator]() { - yield { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + anthropic: expect.objectContaining({ + thinking: { + type: "enabled", + budgetTokens: 4096, }, - } - }, - } - }) - ;(handler["client"].messages as any).create = mockCreate - - // Message with only reasoning content (should be completely filtered) - const messagesWithOnlyReasoning: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: [ - { - type: "reasoning" as any, - text: "Only reasoning, no actual text", - }, - ], - }, - { - role: "user", - content: "Continue", - }, - ] - - const stream = handler.createMessage(systemPrompt, messagesWithOnlyReasoning) - const chunks: ApiStreamChunk[] = [] - - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Verify empty message was filtered out - const calledMessages = mockCreate.mock.calls[0][0].messages - expect(calledMessages).toHaveLength(2) // Only the two user messages - expect(calledMessages.every((m: any) => m.role === "user")).toBe(true) + }), + }), + }), + ) }) }) describe("completePrompt", () => { - it("should complete prompt successfully for Claude", async () => { + 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", + }) const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") - expect(handler["client"].messages.create).toHaveBeenCalledWith({ - model: "claude-3-5-sonnet-v2@20241022", - max_tokens: 8192, - temperature: 0, - messages: [ - { - role: "user", - content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }], - }, - ], - stream: false, - }) - }) - it("should handle API errors for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockError = new Error("Vertex API error") - const mockCreate = vitest.fn().mockRejectedValue(mockError) - ;(handler["client"].messages as any).create = mockCreate - - await expect(handler.completePrompt("Test prompt")).rejects.toThrow( - "Vertex completion error: Vertex API error", + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: "mock-model", + prompt: "Test prompt", + }), ) }) - it("should handle non-text content for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) + it("should handle API errors for Claude", async () => { + const mockError = new Error("Vertex API error") + mockGenerateText.mockRejectedValue(mockError) - const mockCreate = vitest.fn().mockResolvedValue({ - content: [{ type: "image" }], - }) - ;(handler["client"].messages as any).create = mockCreate - - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") - }) - - it("should handle empty response for Claude", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockCreate = vitest.fn().mockResolvedValue({ - content: [{ type: "text", text: "" }], - }) - ;(handler["client"].messages as any).create = mockCreate - - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") + await expect(handler.completePrompt("Test prompt")).rejects.toThrow() }) }) @@ -928,104 +578,6 @@ describe("VertexHandler", () => { }) }) - describe("1M context beta header", () => { - const mockMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - ] - - const systemPrompt = "You are a helpful assistant" - - 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, - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - }, - ] - - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, mockMessages) - - for await (const _chunk of stream) { - // Just consume - } - - // Verify the API was called with the beta header - expect(mockCreate).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: { "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, - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - }, - ] - - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, mockMessages) - - for await (const _chunk of stream) { - // Just consume - } - - // Verify the API was called without the beta header - expect(mockCreate).toHaveBeenCalledWith(expect.anything(), undefined) - }) - }) - describe("thinking model configuration", () => { it("should configure thinking for models with :thinking suffix", () => { const thinkingHandler = new AnthropicVertexHandler({ @@ -1040,7 +592,7 @@ describe("VertexHandler", () => { expect(modelInfo.id).toBe("claude-3-7-sonnet@20250219") expect(modelInfo.reasoningBudget).toBe(4096) - expect(modelInfo.temperature).toBe(1.0) // Thinking requires temperature 1.0. + expect(modelInfo.temperature).toBe(1.0) }) it("should calculate thinking budget correctly", () => { @@ -1076,7 +628,7 @@ describe("VertexHandler", () => { expect(handlerWithSmallMaxTokens.getModel().reasoningBudget).toBe(1024) }) - it("should pass thinking configuration to API", async () => { + it("should pass thinking configuration to API via providerOptions", async () => { const thinkingHandler = new AnthropicVertexHandler({ apiModelId: "claude-3-7-sonnet@20250219:thinking", vertexProjectId: "test-project", @@ -1085,336 +637,87 @@ describe("VertexHandler", () => { modelMaxThinkingTokens: 4096, }) - const mockCreate = vitest.fn().mockImplementation(async (options) => { - if (!options.stream) { - return { - id: "test-completion", - content: [{ type: "text", text: "Test response" }], - role: "assistant", - model: options.model, - usage: { input_tokens: 10, output_tokens: 5 }, - } - } - return { - async *[Symbol.asyncIterator]() { - yield { type: "message_start", message: { usage: { input_tokens: 10, output_tokens: 5 } } } - }, - } - }) - ;(thinkingHandler["client"].messages as any).create = mockCreate + mockStreamText.mockReturnValue(createMockStreamResult([])) - await thinkingHandler - .createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }]) - .next() + const stream = thinkingHandler.createMessage("You are a helpful assistant", [ + { role: "user", content: "Hello" }, + ]) - expect(mockCreate).toHaveBeenCalledWith( + for await (const _chunk of stream) { + // consume + } + + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - thinking: { type: "enabled", budget_tokens: 4096 }, - temperature: 1.0, // Thinking requires temperature 1.0 + temperature: 1.0, + providerOptions: expect.objectContaining({ + anthropic: expect.objectContaining({ + thinking: { + type: "enabled", + budgetTokens: 4096, + }, + }), + }), }), - undefined, ) }) }) - describe("native tool calling", () => { - const systemPrompt = "You are a helpful assistant" - const messages: Anthropic.Messages.MessageParam[] = [ - { - 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 request when native protocol is used", async () => { + describe("isAiSdkProvider", () => { + it("should return true", () => { handler = new AnthropicVertexHandler({ apiModelId: "claude-3-5-sonnet-v2@20241022", vertexProjectId: "test-project", vertexRegion: "us-central1", }) - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - }, - ] + expect(handler.isAiSdkProvider()).toBe(true) + }) + }) - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, + describe("thought signature and redacted thinking", () => { + beforeEach(() => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", }) + }) - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - // Just consume - } + it("should return undefined for thought signature before any request", () => { + expect(handler.getThoughtSignature()).toBeUndefined() + }) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - name: "get_weather", - description: "Get the current weather", - input_schema: expect.objectContaining({ - type: "object", - properties: expect.objectContaining({ - location: { type: "string" }, - }), - }), - }), - ]), - tool_choice: { type: "auto", disable_parallel_tool_use: false }, - }), - undefined, + it("should return undefined for redacted thinking blocks before any request", () => { + expect(handler.getRedactedThinkingBlocks()).toBeUndefined() + }) + + 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 include tools when tools are provided", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 0, - }, - }, - }, - ] - - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, + const stream1 = handler.createMessage("test", [{ role: "user", content: "Hello" }]) + for await (const _chunk of stream1) { + // consume } + expect(handler.getThoughtSignature()).toBe("sig-1") - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate + // Second call without signature + mockStreamText.mockReturnValue(createMockStreamResult([{ type: "text-delta", text: "just text" }])) - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - }) - - // Consume the stream to trigger the API call - for await (const _chunk of stream) { - // Just consume + const stream2 = handler.createMessage("test", [{ role: "user", content: "Hello again" }]) + for await (const _chunk of stream2) { + // consume } - - // Tool calling is request-driven: if tools are provided, we should include them. - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - name: "get_weather", - }), - ]), - }), - undefined, - ) - }) - - it("should handle tool_use blocks in stream and emit tool_call_partial", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "tool_use", - id: "toolu_123", - name: "get_weather", - }, - }, - ] - - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - }) - - const chunks: ApiStreamChunk[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Find the tool_call_partial chunk - const toolCallChunk = chunks.find((chunk) => chunk.type === "tool_call_partial") - expect(toolCallChunk).toBeDefined() - expect(toolCallChunk).toEqual({ - type: "tool_call_partial", - index: 0, - id: "toolu_123", - name: "get_weather", - arguments: undefined, - }) - }) - - it("should handle input_json_delta in stream and emit tool_call_partial arguments", async () => { - handler = new AnthropicVertexHandler({ - apiModelId: "claude-3-5-sonnet-v2@20241022", - vertexProjectId: "test-project", - vertexRegion: "us-central1", - }) - - const mockStream = [ - { - type: "message_start", - message: { - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "tool_use", - id: "toolu_123", - name: "get_weather", - }, - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: '{"location":', - }, - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: '"London"}', - }, - }, - { - type: "content_block_stop", - index: 0, - }, - ] - - const asyncIterator = { - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk - } - }, - } - - const mockCreate = vitest.fn().mockResolvedValue(asyncIterator) - ;(handler["client"].messages as any).create = mockCreate - - const stream = handler.createMessage(systemPrompt, messages, { - taskId: "test-task", - tools: mockTools, - }) - - const chunks: ApiStreamChunk[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Find the tool_call_partial chunks - const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") - expect(toolCallChunks).toHaveLength(3) - - // First chunk has id and name - expect(toolCallChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "toolu_123", - name: "get_weather", - arguments: undefined, - }) - - // Subsequent chunks have arguments - expect(toolCallChunks[1]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '{"location":', - }) - - expect(toolCallChunks[2]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '"London"}', - }) + expect(handler.getThoughtSignature()).toBeUndefined() }) }) }) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 3ed5dd45cc..685c8628b0 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -1,6 +1,6 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" -import { GoogleAuth, JWTInput } from "google-auth-library" +import type { Anthropic } from "@anthropic-ai/sdk" +import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic" +import { streamText, generateText, ToolSet } from "ai" import { type ModelInfo, @@ -9,58 +9,78 @@ import { vertexModels, ANTHROPIC_DEFAULT_MAX_TOKENS, VERTEX_1M_CONTEXT_MODEL_IDS, + ApiProviderError, } from "@roo-code/types" -import { safeJsonParse } from "@roo-code/core" +import { TelemetryService } from "@roo-code/telemetry" -import { ApiHandlerOptions } from "../../shared/api" +import type { ApiHandlerOptions } from "../../shared/api" +import { shouldUseReasoningBudget } from "../../shared/api" -import { ApiStream } from "../transform/stream" -import { addCacheBreakpoints } from "../transform/caching/vertex" +import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" import { - convertOpenAIToolsToAnthropic, - convertOpenAIToolChoiceToAnthropic, -} from "../../core/prompts/tools/native-tools/converters" + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { calculateApiCostAnthropic } from "../../shared/cost" +import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" // https://docs.anthropic.com/en/api/claude-on-vertex-ai export class AnthropicVertexHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: AnthropicVertex + private provider: ReturnType + private readonly providerName = "Vertex (Anthropic)" + private lastThoughtSignature: string | undefined + private lastRedactedThinkingBlocks: Array<{ type: "redacted_thinking"; data: string }> = [] constructor(options: ApiHandlerOptions) { super() - this.options = options // https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions const projectId = this.options.vertexProjectId ?? "not-provided" const region = this.options.vertexRegion ?? "us-east5" - if (this.options.vertexJsonCredentials) { - this.client = new AnthropicVertex({ - projectId, - region, - googleAuth: new GoogleAuth({ - scopes: ["https://www.googleapis.com/auth/cloud-platform"], - credentials: safeJsonParse(this.options.vertexJsonCredentials, undefined), - }), - }) - } else if (this.options.vertexKeyFile) { - this.client = new AnthropicVertex({ - projectId, - region, - googleAuth: new GoogleAuth({ - scopes: ["https://www.googleapis.com/auth/cloud-platform"], - keyFile: this.options.vertexKeyFile, - }), - }) - } else { - this.client = new AnthropicVertex({ projectId, region }) + // Build googleAuthOptions based on provided credentials + let googleAuthOptions: { credentials?: object; keyFile?: string } | undefined + if (options.vertexJsonCredentials) { + try { + googleAuthOptions = { credentials: JSON.parse(options.vertexJsonCredentials) } + } catch { + // If JSON parsing fails, ignore and try other auth methods + } + } else if (options.vertexKeyFile) { + googleAuthOptions = { keyFile: 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({ + project: projectId, + location: region, + googleAuthOptions, + headers: { + ...DEFAULT_HEADERS, + ...(betas.length > 0 ? { "anthropic-beta": betas.join(",") } : {}), + }, + }) } override async *createMessage( @@ -68,16 +88,39 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - let { id, info, temperature, maxTokens, reasoning: thinking, betas } = this.getModel() + const modelConfig = this.getModel() - const { supportsPromptCache } = info + // Reset thinking state for this request + this.lastThoughtSignature = undefined + this.lastRedactedThinkingBlocks = [] - // Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API - const sanitizedMessages = filterNonAnthropicBlocks(messages) + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(messages) - const nativeToolParams = { - tools: convertOpenAIToolsToAnthropic(metadata?.tools ?? []), - tool_choice: convertOpenAIToolChoiceToAnthropic(metadata?.tool_choice, metadata?.parallelToolCalls), + // 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 } /** @@ -93,114 +136,178 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple * This ensures we stay under the 4-block limit while maintaining effective caching * for the most relevant context. */ - const params: Anthropic.Messages.MessageCreateParamsStreaming = { - model: id, - max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, - temperature, - thinking, - // Cache the system prompt if caching is enabled. - system: supportsPromptCache - ? [{ text: systemPrompt, type: "text" as const, cache_control: { type: "ephemeral" } }] - : systemPrompt, - messages: supportsPromptCache ? addCacheBreakpoints(sanitizedMessages) : sanitizedMessages, - stream: true, - ...nativeToolParams, + 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) } - // and prompt caching - const requestOptions = betas?.length ? { headers: { "anthropic-beta": betas.join(",") } } : undefined + // 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, + }), + } - const stream = await this.client.messages.create(params, requestOptions) + try { + const result = streamText(requestOptions) - for await (const chunk of stream) { - switch (chunk.type) { - case "message_start": { - const usage = chunk.message!.usage - - yield { - type: "usage", - inputTokens: usage.input_tokens || 0, - outputTokens: usage.output_tokens || 0, - cacheWriteTokens: usage.cache_creation_input_tokens || undefined, - cacheReadTokens: usage.cache_read_input_tokens || undefined, - } - - break + 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 } - case "message_delta": { - yield { - type: "usage", - inputTokens: 0, - outputTokens: chunk.usage!.output_tokens || 0, - } - break + // Capture redacted thinking blocks from stream events + if (partAny.providerMetadata?.anthropic?.redactedData) { + this.lastRedactedThinkingBlocks.push({ + type: "redacted_thinking", + data: partAny.providerMetadata.anthropic.redactedData, + }) } - case "content_block_start": { - switch (chunk.content_block!.type) { - case "text": { - if (chunk.index! > 0) { - yield { type: "text", text: "\n" } - } - yield { type: "text", text: chunk.content_block!.text } - break - } - case "thinking": { - if (chunk.index! > 0) { - yield { type: "reasoning", text: "\n" } - } + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } - yield { type: "reasoning", text: (chunk.content_block as any).thinking } - 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 + // 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, } } - - break - } - case "content_block_delta": { - switch (chunk.delta!.type) { - case "text_delta": { - yield { type: "text", text: chunk.delta!.text } - break - } - case "thinking_delta": { - yield { type: "reasoning", text: (chunk.delta as any).thinking } - 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 as any).partial_json, - } - break + aiSdkIdx += 2 + } else if (hasToolResults) { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, } } - - break - } - case "content_block_stop": { - // Block complete - no action needed for now. - // NativeToolCallParser handles tool call completion - // Note: Signature for multi-turn thinking would require using stream.finalMessage() - // after iteration completes, which requires restructuring the streaming approach. - break + aiSdkIdx++ + } else { + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cacheProviderOption, + } + } + aiSdkIdx++ } + } else { + aiSdkIdx++ } } } @@ -239,10 +346,9 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple defaultTemperature: 0, }) - // Build betas array for request headers + // Build betas array for request headers (kept for backward compatibility / testing) const betas: string[] = [] - // Add 1M context beta flag if enabled for supported models if (enable1MContext) { betas.push("context-1m-2025-08-07") } @@ -259,46 +365,49 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple } } - async completePrompt(prompt: string) { + async completePrompt(prompt: string): Promise { + const { id, temperature } = this.getModel() + try { - let { - id, - info: { supportsPromptCache }, + const { text } = await generateText({ + model: this.provider(id), + prompt, + maxOutputTokens: ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, - maxTokens = ANTHROPIC_DEFAULT_MAX_TOKENS, - reasoning: thinking, - } = this.getModel() + }) - const params: Anthropic.Messages.MessageCreateParamsNonStreaming = { - model: id, - max_tokens: maxTokens, - temperature, - thinking, - messages: [ - { - role: "user", - content: supportsPromptCache - ? [{ type: "text" as const, text: prompt, cache_control: { type: "ephemeral" } }] - : prompt, - }, - ], - stream: false, - } - - const response = await this.client.messages.create(params) - const content = response.content[0] - - if (content.type === "text") { - return content.text - } - - return "" + return text } catch (error) { - if (error instanceof Error) { - throw new Error(`Vertex completion error: ${error.message}`) - } - - throw error + TelemetryService.instance.captureException( + new ApiProviderError( + error instanceof Error ? error.message : String(error), + this.providerName, + id, + "completePrompt", + ), + ) + 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. + */ + getRedactedThinkingBlocks(): Array<{ type: "redacted_thinking"; data: string }> | undefined { + return this.lastRedactedThinkingBlocks.length > 0 ? this.lastRedactedThinkingBlocks : undefined + } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/package.json b/src/package.json index 7f5d037046..72fb3b5df9 100644 --- a/src/package.json +++ b/src/package.json @@ -462,7 +462,6 @@ "@ai-sdk/openai": "^3.0.26", "@ai-sdk/xai": "^3.0.48", "@anthropic-ai/sdk": "^0.37.0", - "@anthropic-ai/vertex-sdk": "^0.7.0", "@aws-sdk/client-bedrock-runtime": "^3.922.0", "@aws-sdk/credential-providers": "^3.922.0", "@google/genai": "^1.29.1", @@ -492,7 +491,6 @@ "fzf": "^0.5.2", "get-folder-size": "^5.0.0", "global-agent": "^3.0.0", - "google-auth-library": "^9.15.1", "gray-matter": "^4.0.3", "i18next": "^25.0.0", "ignore": "^7.0.3",