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
This commit is contained in:
daniel-lxs 2025-12-05 16:17:54 -05:00
parent 9f4dcfc0e6
commit 214790fc56
No known key found for this signature in database
GPG key ID: 21C74479048B3AA6
2 changed files with 101 additions and 0 deletions

View file

@ -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)
})
})
})

View file

@ -410,6 +410,17 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
* @returns A promise resolving to the token count
*/
override async countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number> {
// 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()