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) ??
|
| undefined) ??
|
||||||
userConf?.url ??
|
userConf?.url ??
|
||||||
defaultConf.baseURL;
|
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 {
|
return {
|
||||||
baseURL: llmBaseURL,
|
baseURL: llmBaseURL,
|
||||||
@@ -125,6 +132,9 @@ export class ConfigManager {
|
|||||||
userConf?.modelProperties !== undefined
|
userConf?.modelProperties !== undefined
|
||||||
? userConf.modelProperties
|
? userConf.modelProperties
|
||||||
: defaultConf.modelProperties,
|
: defaultConf.modelProperties,
|
||||||
|
temperature,
|
||||||
|
topP,
|
||||||
|
maxTokens,
|
||||||
};
|
};
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { LLMConfig, Message } from "../types";
|
|||||||
export class AnthropicLLM implements LLM {
|
export class AnthropicLLM implements LLM {
|
||||||
private client: Anthropic;
|
private client: Anthropic;
|
||||||
private model: string;
|
private model: string;
|
||||||
|
private maxTokens: number;
|
||||||
|
private temperature?: number;
|
||||||
|
private topP?: number;
|
||||||
|
|
||||||
constructor(config: LLMConfig) {
|
constructor(config: LLMConfig) {
|
||||||
const apiKey = config.apiKey || process.env.ANTHROPIC_API_KEY;
|
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");
|
throw new Error("Anthropic API key is required");
|
||||||
}
|
}
|
||||||
this.client = new Anthropic({ apiKey });
|
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(
|
async generateResponse(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
responseFormat?: { type: string },
|
responseFormat?: { type: string },
|
||||||
): Promise<string> {
|
tools?: any[],
|
||||||
|
): Promise<string | LLMResponse> {
|
||||||
// Extract system message if present
|
// Extract system message if present
|
||||||
const systemMessage = messages.find((msg) => msg.role === "system");
|
const systemMessage = messages.find((msg) => msg.role === "system");
|
||||||
const otherMessages = messages.filter((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,
|
model: this.model,
|
||||||
messages: otherMessages.map((msg) => ({
|
messages: otherMessages.map((msg) => ({
|
||||||
role: msg.role as "user" | "assistant",
|
role: msg.role as "user" | "assistant",
|
||||||
@@ -36,8 +45,41 @@ export class AnthropicLLM implements LLM {
|
|||||||
typeof systemMessage?.content === "string"
|
typeof systemMessage?.content === "string"
|
||||||
? systemMessage.content
|
? systemMessage.content
|
||||||
: undefined,
|
: 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];
|
const firstBlock = response.content[0];
|
||||||
if (firstBlock.type === "text") {
|
if (firstBlock.type === "text") {
|
||||||
@@ -49,9 +91,9 @@ export class AnthropicLLM implements LLM {
|
|||||||
|
|
||||||
async generateChat(messages: Message[]): Promise<LLMResponse> {
|
async generateChat(messages: Message[]): Promise<LLMResponse> {
|
||||||
const response = await this.generateResponse(messages);
|
const response = await this.generateResponse(messages);
|
||||||
return {
|
if (typeof response === "string") {
|
||||||
content: response,
|
return { content: response, role: "assistant" };
|
||||||
role: "assistant",
|
}
|
||||||
};
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export interface LLMConfig {
|
|||||||
model?: string | any;
|
model?: string | any;
|
||||||
modelProperties?: Record<string, any>;
|
modelProperties?: Record<string, any>;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
temperature?: number;
|
||||||
|
topP?: number;
|
||||||
|
maxTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryConfig {
|
export interface MemoryConfig {
|
||||||
@@ -131,6 +134,9 @@ export const MemoryConfigSchema = z.object({
|
|||||||
baseURL: z.string().optional(),
|
baseURL: z.string().optional(),
|
||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
timeout: z.number().optional(),
|
timeout: z.number().optional(),
|
||||||
|
temperature: z.number().optional(),
|
||||||
|
topP: z.number().optional(),
|
||||||
|
maxTokens: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
historyDbPath: z.string().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)
|
super().__init__(config)
|
||||||
|
|
||||||
if not self.config.model:
|
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")
|
api_key = self.config.api_key or os.getenv("ANTHROPIC_API_KEY")
|
||||||
self.client = anthropic.Anthropic(api_key=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
|
if tools: # TODO: Remove tools if no issues found with new memory addition logic
|
||||||
params["tools"] = tools
|
params["tools"] = tools
|
||||||
params["tool_choice"] = tool_choice
|
params["tool_choice"] = {"type": tool_choice}
|
||||||
|
|
||||||
response = self.client.messages.create(**params)
|
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
|
return response.content[0].text
|
||||||
|
|||||||
Reference in New Issue
Block a user