fix(anthropic): tool_choice format and tool response parsing (#5537)

Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
Hrushikesh Yadav
2026-06-15 17:28:45 +05:30
committed by GitHub
co-authored by kartik-mem0
parent 4492e75d04
commit b55c51e004
5 changed files with 345 additions and 11 deletions
+263
View File
@@ -0,0 +1,263 @@
/// <reference types="jest" />
/**
* Anthropic LLM — unit tests (mocked @anthropic-ai/sdk).
*/
const mockCreate = jest.fn();
jest.mock("@anthropic-ai/sdk", () => {
return jest.fn().mockImplementation(() => ({
messages: { create: mockCreate },
}));
});
import { AnthropicLLM } from "../src/llms/anthropic";
describe("AnthropicLLM (unit)", () => {
beforeEach(() => mockCreate.mockClear());
it("returns text when no tools are provided and model returns a text block", async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: "text", text: '{"facts": ["fact1"]}' }],
});
const llm = new AnthropicLLM({ apiKey: "test-key" });
const result = await llm.generateResponse([
{ role: "user", content: "Hello" },
]);
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(result).toBe('{"facts": ["fact1"]}');
// No tools → tool_choice must NOT be forwarded
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs.tool_choice).toBeUndefined();
});
// Bug #1 regression: bare string "auto" must NOT be sent; object form required
it("forwards tool_choice as { type: 'auto' } (not bare string) when tools are provided", async () => {
mockCreate.mockResolvedValueOnce({
content: [
{
type: "tool_use",
id: "toolu_1",
name: "add_graph_memory",
input: { source: "Alice", destination: "Bob" },
},
],
});
const tools = [
{
name: "add_graph_memory",
description: "Add a graph memory",
input_schema: { type: "object", properties: {} },
},
];
const llm = new AnthropicLLM({ apiKey: "test-key" });
await llm.generateResponse(
[{ role: "user", content: "Alice knows Bob" }],
undefined,
tools,
);
const callArgs = mockCreate.mock.calls[0][0];
// Must be object form, not a bare string
expect(callArgs.tool_choice).toEqual({ type: "auto" });
expect(callArgs.tool_choice).not.toBe("auto");
});
// Bug #2 regression: must NOT throw on a tool_use block
it("does NOT throw when the model returns a tool_use block", async () => {
mockCreate.mockResolvedValueOnce({
content: [
{
type: "tool_use",
id: "toolu_1",
name: "add_graph_memory",
input: { source: "Alice", destination: "Bob" },
},
],
});
const tools = [
{
name: "add_graph_memory",
description: "Add a graph memory",
input_schema: { type: "object", properties: {} },
},
];
const llm = new AnthropicLLM({ apiKey: "test-key" });
await expect(
llm.generateResponse(
[{ role: "user", content: "Alice knows Bob" }],
undefined,
tools,
),
).resolves.not.toThrow();
});
it("parses tool_use blocks into toolCalls with JSON-stringified arguments", async () => {
const inputObj = { source: "Alice", destination: "Bob" };
mockCreate.mockResolvedValueOnce({
content: [
{
type: "tool_use",
id: "toolu_1",
name: "add_graph_memory",
input: inputObj,
},
],
});
const tools = [
{
name: "add_graph_memory",
description: "Add a graph memory",
input_schema: { type: "object", properties: {} },
},
];
const llm = new AnthropicLLM({ apiKey: "test-key" });
const result = await llm.generateResponse(
[{ role: "user", content: "Alice knows Bob" }],
undefined,
tools,
);
expect(result).toHaveProperty("toolCalls");
const response = result as {
content: string;
role: string;
toolCalls: Array<{ name: string; arguments: string }>;
};
expect(response.toolCalls).toHaveLength(1);
expect(response.toolCalls[0].name).toBe("add_graph_memory");
expect(JSON.parse(response.toolCalls[0].arguments)).toEqual(inputObj);
});
it("handles a mixed text + tool_use response", async () => {
const inputObj = { source: "Alice", destination: "Bob" };
mockCreate.mockResolvedValueOnce({
content: [
{ type: "text", text: "Calling the tool now." },
{
type: "tool_use",
id: "toolu_2",
name: "add_graph_memory",
input: inputObj,
},
],
});
const tools = [
{
name: "add_graph_memory",
description: "Add a graph memory",
input_schema: { type: "object", properties: {} },
},
];
const llm = new AnthropicLLM({ apiKey: "test-key" });
const result = await llm.generateResponse(
[{ role: "user", content: "Alice knows Bob" }],
undefined,
tools,
);
expect(result).toHaveProperty("toolCalls");
const response = result as {
content: string;
role: string;
toolCalls: Array<{ name: string; arguments: string }>;
};
expect(response.content).toBe("Calling the tool now.");
expect(response.role).toBe("assistant");
expect(response.toolCalls).toHaveLength(1);
expect(response.toolCalls[0].name).toBe("add_graph_memory");
expect(JSON.parse(response.toolCalls[0].arguments)).toEqual(inputObj);
});
it("returns a structured response when tools are provided but the model returns only a text block", async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: "text", text: "Just a text response" }],
});
const tools = [
{
name: "noop",
description: "No operation",
input_schema: { type: "object", properties: {} },
},
];
const llm = new AnthropicLLM({ apiKey: "test-key" });
const result = await llm.generateResponse(
[{ role: "user", content: "Hello" }],
undefined,
tools,
);
expect(result).toEqual({
content: "Just a text response",
role: "assistant",
toolCalls: [],
});
});
// Parity with the Python provider's AnthropicConfig defaults:
// model claude-sonnet-4-6, max_tokens 2000, temperature 0.1, top_p omitted.
it("sends Python-parity defaults (model, max_tokens, temperature)", async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: "text", text: "ok" }],
});
const llm = new AnthropicLLM({ apiKey: "test-key" });
await llm.generateResponse([{ role: "user", content: "Hi" }]);
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs.model).toBe("claude-sonnet-4-6");
expect(callArgs.max_tokens).toBe(2000);
expect(callArgs.temperature).toBe(0.1);
expect(callArgs.top_p).toBeUndefined();
});
it("forwards maxTokens, temperature, and model from config", async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: "text", text: "ok" }],
});
const llm = new AnthropicLLM({
apiKey: "test-key",
model: "claude-opus-4-8",
maxTokens: 1024,
temperature: 0.7,
});
await llm.generateResponse([{ role: "user", content: "Hi" }]);
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs.model).toBe("claude-opus-4-8");
expect(callArgs.max_tokens).toBe(1024);
expect(callArgs.temperature).toBe(0.7);
});
// Anthropic rejects requests with both temperature and top_p set.
it("never sends both temperature and top_p (prefers temperature)", async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: "text", text: "ok" }],
});
const llm = new AnthropicLLM({
apiKey: "test-key",
temperature: 0.5,
topP: 0.9,
});
await llm.generateResponse([{ role: "user", content: "Hi" }]);
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs.temperature).toBe(0.5);
expect(callArgs.top_p).toBeUndefined();
});
});