fix(anthropic): tool_choice format and tool response parsing (#5537)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
co-authored by
kartik-mem0
parent
4492e75d04
commit
b55c51e004
@@ -112,6 +112,13 @@ export class ConfigManager {
|
||||
| undefined) ??
|
||||
userConf?.url ??
|
||||
defaultConf.baseURL;
|
||||
const llmRaw = userConf as Record<string, unknown> | undefined;
|
||||
const temperature =
|
||||
userConf?.temperature ??
|
||||
(llmRaw?.temperature as number | undefined);
|
||||
const topP = userConf?.topP ?? (llmRaw?.top_p as number | undefined);
|
||||
const maxTokens =
|
||||
userConf?.maxTokens ?? (llmRaw?.max_tokens as number | undefined);
|
||||
|
||||
return {
|
||||
baseURL: llmBaseURL,
|
||||
@@ -125,6 +132,9 @@ export class ConfigManager {
|
||||
userConf?.modelProperties !== undefined
|
||||
? userConf.modelProperties
|
||||
: defaultConf.modelProperties,
|
||||
temperature,
|
||||
topP,
|
||||
maxTokens,
|
||||
};
|
||||
})(),
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@ import { LLMConfig, Message } from "../types";
|
||||
export class AnthropicLLM implements LLM {
|
||||
private client: Anthropic;
|
||||
private model: string;
|
||||
private maxTokens: number;
|
||||
private temperature?: number;
|
||||
private topP?: number;
|
||||
|
||||
constructor(config: LLMConfig) {
|
||||
const apiKey = config.apiKey || process.env.ANTHROPIC_API_KEY;
|
||||
@@ -12,18 +15,24 @@ export class AnthropicLLM implements LLM {
|
||||
throw new Error("Anthropic API key is required");
|
||||
}
|
||||
this.client = new Anthropic({ apiKey });
|
||||
this.model = config.model || "claude-3-sonnet-20240229";
|
||||
this.model = config.model || "claude-sonnet-4-6";
|
||||
// Defaults mirror the Python provider's AnthropicConfig
|
||||
// (max_tokens=2000, temperature=0.1, top_p omitted).
|
||||
this.maxTokens = config.maxTokens ?? 2000;
|
||||
this.temperature = config.temperature ?? 0.1;
|
||||
this.topP = config.topP;
|
||||
}
|
||||
|
||||
async generateResponse(
|
||||
messages: Message[],
|
||||
responseFormat?: { type: string },
|
||||
): Promise<string> {
|
||||
tools?: any[],
|
||||
): Promise<string | LLMResponse> {
|
||||
// Extract system message if present
|
||||
const systemMessage = messages.find((msg) => msg.role === "system");
|
||||
const otherMessages = messages.filter((msg) => msg.role !== "system");
|
||||
|
||||
const response = await this.client.messages.create({
|
||||
const params: Anthropic.MessageCreateParamsNonStreaming = {
|
||||
model: this.model,
|
||||
messages: otherMessages.map((msg) => ({
|
||||
role: msg.role as "user" | "assistant",
|
||||
@@ -36,8 +45,41 @@ export class AnthropicLLM implements LLM {
|
||||
typeof systemMessage?.content === "string"
|
||||
? systemMessage.content
|
||||
: undefined,
|
||||
max_tokens: 4096,
|
||||
});
|
||||
max_tokens: this.maxTokens,
|
||||
};
|
||||
|
||||
// Anthropic rejects requests that include both temperature and top_p;
|
||||
// prefer temperature, matching the Python provider's _get_common_params.
|
||||
if (this.temperature !== undefined) {
|
||||
params.temperature = this.temperature;
|
||||
} else if (this.topP !== undefined) {
|
||||
params.top_p = this.topP;
|
||||
}
|
||||
|
||||
if (tools) {
|
||||
params.tools = tools;
|
||||
params.tool_choice = { type: "auto" };
|
||||
}
|
||||
|
||||
const response = await this.client.messages.create(params);
|
||||
|
||||
if (tools) {
|
||||
let content = "";
|
||||
const toolCalls: Array<{ name: string; arguments: string }> = [];
|
||||
|
||||
for (const block of response.content) {
|
||||
if (block.type === "text") {
|
||||
content = block.text;
|
||||
} else if (block.type === "tool_use") {
|
||||
toolCalls.push({
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { content, role: "assistant", toolCalls };
|
||||
}
|
||||
|
||||
const firstBlock = response.content[0];
|
||||
if (firstBlock.type === "text") {
|
||||
@@ -49,9 +91,9 @@ export class AnthropicLLM implements LLM {
|
||||
|
||||
async generateChat(messages: Message[]): Promise<LLMResponse> {
|
||||
const response = await this.generateResponse(messages);
|
||||
return {
|
||||
content: response,
|
||||
role: "assistant",
|
||||
};
|
||||
if (typeof response === "string") {
|
||||
return { content: response, role: "assistant" };
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ export interface LLMConfig {
|
||||
model?: string | any;
|
||||
modelProperties?: Record<string, any>;
|
||||
timeout?: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export interface MemoryConfig {
|
||||
@@ -131,6 +134,9 @@ export const MemoryConfigSchema = z.object({
|
||||
baseURL: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
timeout: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
topP: z.number().optional(),
|
||||
maxTokens: z.number().optional(),
|
||||
}),
|
||||
}),
|
||||
historyDbPath: z.string().optional(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+15
-2
@@ -35,7 +35,7 @@ class AnthropicLLM(LLMBase):
|
||||
super().__init__(config)
|
||||
|
||||
if not self.config.model:
|
||||
self.config.model = "claude-3-5-sonnet-20240620"
|
||||
self.config.model = "claude-sonnet-4-6"
|
||||
|
||||
api_key = self.config.api_key or os.getenv("ANTHROPIC_API_KEY")
|
||||
self.client = anthropic.Anthropic(api_key=api_key)
|
||||
@@ -106,7 +106,20 @@ class AnthropicLLM(LLMBase):
|
||||
|
||||
if tools: # TODO: Remove tools if no issues found with new memory addition logic
|
||||
params["tools"] = tools
|
||||
params["tool_choice"] = tool_choice
|
||||
params["tool_choice"] = {"type": tool_choice}
|
||||
|
||||
response = self.client.messages.create(**params)
|
||||
return self._parse_response(response, tools)
|
||||
|
||||
def _parse_response(self, response, tools):
|
||||
if tools:
|
||||
result = {"content": None, "tool_calls": []}
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
result["content"] = block.text
|
||||
elif block.type == "tool_use":
|
||||
result["tool_calls"].append(
|
||||
{"name": block.name, "arguments": block.input}
|
||||
)
|
||||
return result
|
||||
return response.content[0].text
|
||||
|
||||
Reference in New Issue
Block a user