From 214790fc56a289de5780c770961ad99e0bca6e4a Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 5 Dec 2025 16:17:54 -0500 Subject: [PATCH] fix: handle tool_result blocks in Anthropic countTokens method Fixes #9871 When the Anthropic countTokens method receives content containing tool_result blocks, it now falls back to local tiktoken estimation instead of making an API call. This prevents 400 errors because Anthropic's countTokens API validates message structure the same way as the chat API - tool_result blocks require a preceding assistant message with a matching tool_use block. Changes: - Added tool_result detection in AnthropicHandler.countTokens() - Added 5 test cases covering the new behavior --- src/api/providers/__tests__/anthropic.spec.ts | 90 +++++++++++++++++++ src/api/providers/anthropic.ts | 11 +++ 2 files changed, 101 insertions(+) diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index 8b229212d4..5fdbac48b3 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -4,6 +4,7 @@ import { AnthropicHandler } from "../anthropic" import { ApiHandlerOptions } from "../../../shared/api" const mockCreate = vitest.fn() +const mockCountTokens = vitest.fn() vitest.mock("@anthropic-ai/sdk", () => { const mockAnthropicConstructor = vitest.fn().mockImplementation(() => ({ @@ -52,6 +53,7 @@ vitest.mock("@anthropic-ai/sdk", () => { }, } }), + countTokens: mockCountTokens.mockResolvedValue({ input_tokens: 42 }), }, })) @@ -60,6 +62,11 @@ vitest.mock("@anthropic-ai/sdk", () => { } }) +// Mock the base countTokens utility used by BaseProvider fallback +vitest.mock("../../../utils/countTokens", () => ({ + countTokens: vitest.fn().mockResolvedValue(15), +})) + // Import after mock import { Anthropic } from "@anthropic-ai/sdk" @@ -727,4 +734,87 @@ describe("AnthropicHandler", () => { }) }) }) + + describe("countTokens", () => { + beforeEach(() => { + mockCountTokens.mockClear() + }) + + it("should use Anthropic API for regular text content", async () => { + const content = [{ type: "text" as const, text: "Hello, world!" }] + + const result = await handler.countTokens(content) + + expect(result).toBe(42) + expect(mockCountTokens).toHaveBeenCalledTimes(1) + expect(mockCountTokens).toHaveBeenCalledWith({ + model: "claude-3-5-sonnet-20241022", + messages: [{ role: "user", content }], + }) + }) + + it("should fall back to local estimation when content contains tool_result blocks", async () => { + const content = [ + { + type: "tool_result" as const, + tool_use_id: "toolu_123", + content: "File contents here", + }, + ] + + const result = await handler.countTokens(content) + + // Should return the fallback value (15) from the mocked countTokens utility + expect(result).toBe(15) + // Should NOT call the Anthropic API + expect(mockCountTokens).not.toHaveBeenCalled() + }) + + it("should fall back when content has mixed blocks including tool_result", async () => { + const content = [ + { type: "text" as const, text: "Some text" }, + { + type: "tool_result" as const, + tool_use_id: "toolu_456", + content: "Tool output", + }, + ] + + const result = await handler.countTokens(content) + + // Should return the fallback value + expect(result).toBe(15) + // Should NOT call the Anthropic API + expect(mockCountTokens).not.toHaveBeenCalled() + }) + + it("should fall back to local estimation when API call fails", async () => { + mockCountTokens.mockRejectedValueOnce(new Error("API Error")) + + const content = [{ type: "text" as const, text: "Hello, world!" }] + + const result = await handler.countTokens(content) + + // Should return the fallback value + expect(result).toBe(15) + }) + + it("should handle image blocks via API", async () => { + const content = [ + { + type: "image" as const, + source: { + type: "base64" as const, + media_type: "image/png" as const, + data: "base64data", + }, + }, + ] + + const result = await handler.countTokens(content) + + expect(result).toBe(42) + expect(mockCountTokens).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 3d9379f540..008e516513 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -410,6 +410,17 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa * @returns A promise resolving to the token count */ override async countTokens(content: Array): Promise { + // Check if content contains tool_result blocks - these cannot be sent as standalone + // user messages because Anthropic requires tool_result to follow a tool_use message + // from the assistant. Fall back to local estimation in this case. + const hasToolResult = content.some( + (block) => typeof block === "object" && "type" in block && block.type === "tool_result", + ) + + if (hasToolResult) { + return super.countTokens(content) + } + try { // Use the current model const { id: model } = this.getModel()