Skip to content

Commit d5a789c

Browse files
committed
fix(llm): guard Anthropic provider tool descriptors
1 parent 420450b commit d5a789c

2 files changed

Lines changed: 355 additions & 19 deletions

File tree

src/llm/providers/anthropic.test.ts

Lines changed: 217 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@ import type { Context, Model } from "../types.js";
33

44
const anthropicMockState = vi.hoisted(() => ({
55
configs: [] as unknown[],
6+
createImpl: null as null | ((params: unknown, options: unknown) => unknown),
67
}));
78

89
vi.mock("@anthropic-ai/sdk", () => ({
910
default: class MockAnthropic {
1011
messages = {
11-
create: vi.fn(() => {
12+
create: vi.fn((params: unknown, options: unknown) => {
13+
if (anthropicMockState.createImpl) {
14+
return anthropicMockState.createImpl(params, options);
15+
}
1216
throw new Error("stop after constructor");
1317
}),
1418
};
@@ -22,7 +26,9 @@ vi.mock("@anthropic-ai/sdk", () => ({
2226
import { streamAnthropic, streamSimpleAnthropic } from "./anthropic.js";
2327

2428
function createSseResponse(events: Record<string, unknown>[] = []): Response {
25-
const body = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("");
29+
const body = events
30+
.map((event) => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`)
31+
.join("");
2632
return new Response(body, {
2733
status: 200,
2834
headers: { "content-type": "text/event-stream" },
@@ -48,6 +54,7 @@ function makeAnthropicModel(overrides: Partial<Model<"anthropic-messages">> = {}
4854
describe("Anthropic provider", () => {
4955
beforeEach(() => {
5056
anthropicMockState.configs = [];
57+
anthropicMockState.createImpl = null;
5158
});
5259

5360
it("keeps Cloudflare AI Gateway upstream provider auth on the Anthropic API key", async () => {
@@ -168,6 +175,214 @@ describe("Anthropic provider", () => {
168175
]);
169176
});
170177

178+
it("skips malformed tools when building Anthropic provider payloads", async () => {
179+
let capturedPayload: unknown;
180+
const tools = [
181+
{
182+
get name() {
183+
throw new Error("legacy anthropic tool name getter exploded");
184+
},
185+
description: "unreadable name",
186+
parameters: { type: "object", properties: {} },
187+
},
188+
{
189+
name: "description_poisoned_tool",
190+
get description() {
191+
throw new Error("legacy anthropic tool description getter exploded");
192+
},
193+
parameters: {
194+
type: "object",
195+
properties: {
196+
query: { type: "string" },
197+
},
198+
},
199+
},
200+
{
201+
name: "parameters_poisoned_tool",
202+
get parameters() {
203+
throw new Error("legacy anthropic tool parameters getter exploded");
204+
},
205+
},
206+
{
207+
name: "dynamic_schema_tool",
208+
description: "unsupported dynamic schema",
209+
parameters: {
210+
type: "object",
211+
properties: {
212+
target: { $dynamicRef: "#target" },
213+
},
214+
},
215+
},
216+
{
217+
name: "tojson_projected_tool",
218+
description: "schema projection differs from live properties",
219+
parameters: {
220+
type: "object",
221+
properties: {
222+
target: { $dynamicRef: "#target" },
223+
},
224+
toJSON() {
225+
return {
226+
type: "object",
227+
properties: {
228+
safe: { type: "string" },
229+
},
230+
required: ["safe"],
231+
};
232+
},
233+
},
234+
},
235+
{
236+
name: "dynamic_keyword_field_tool",
237+
description: "schema map names can look like dynamic schema keywords",
238+
parameters: {
239+
type: "object",
240+
properties: {
241+
$dynamicRef: { type: "string" },
242+
},
243+
required: ["$dynamicRef"],
244+
},
245+
},
246+
{
247+
name: "good_plugin_tool",
248+
description: "valid schema",
249+
parameters: {
250+
type: "object",
251+
properties: {
252+
query: { type: "string" },
253+
},
254+
required: ["query"],
255+
},
256+
},
257+
];
258+
259+
const stream = streamAnthropic(
260+
makeAnthropicModel(),
261+
{
262+
messages: [{ role: "user", content: "hello", timestamp: 0 }],
263+
tools,
264+
} as unknown as Context,
265+
{
266+
apiKey: "sk-ant-provider",
267+
onPayload: (payload) => {
268+
capturedPayload = payload;
269+
throw new Error("stop before network");
270+
},
271+
},
272+
);
273+
274+
const result = await stream.result();
275+
276+
expect(result.stopReason).toBe("error");
277+
expect(result.errorMessage).toContain("stop before network");
278+
const payloadTools = (capturedPayload as { tools?: Array<Record<string, unknown>> }).tools;
279+
expect(payloadTools).toHaveLength(4);
280+
expect(payloadTools?.[0]).toMatchObject({
281+
name: "description_poisoned_tool",
282+
input_schema: {
283+
properties: {
284+
query: { type: "string" },
285+
},
286+
},
287+
});
288+
expect(payloadTools?.[0]).not.toHaveProperty("description");
289+
expect(payloadTools?.[1]).toMatchObject({
290+
name: "tojson_projected_tool",
291+
input_schema: {
292+
properties: {
293+
safe: { type: "string" },
294+
},
295+
required: ["safe"],
296+
},
297+
});
298+
expect(payloadTools?.[2]).toMatchObject({
299+
name: "dynamic_keyword_field_tool",
300+
description: "schema map names can look like dynamic schema keywords",
301+
input_schema: {
302+
properties: {
303+
$dynamicRef: { type: "string" },
304+
},
305+
required: ["$dynamicRef"],
306+
},
307+
});
308+
expect(payloadTools?.[3]).toMatchObject({
309+
name: "good_plugin_tool",
310+
description: "valid schema",
311+
input_schema: {
312+
properties: {
313+
query: { type: "string" },
314+
},
315+
required: ["query"],
316+
},
317+
});
318+
});
319+
320+
it("remaps OAuth tool-use names without scanning poisoned descriptors", async () => {
321+
anthropicMockState.createImpl = () => ({
322+
asResponse: () =>
323+
Promise.resolve(
324+
createSseResponse([
325+
{
326+
type: "message_start",
327+
message: { id: "msg_1", usage: { input_tokens: 1, output_tokens: 0 } },
328+
},
329+
{
330+
type: "content_block_start",
331+
index: 0,
332+
content_block: {
333+
type: "tool_use",
334+
id: "toolu_1",
335+
name: "Read",
336+
input: { file_path: "README.md" },
337+
},
338+
},
339+
{ type: "content_block_stop", index: 0 },
340+
{
341+
type: "message_delta",
342+
delta: { stop_reason: "tool_use" },
343+
usage: { input_tokens: 1, output_tokens: 1 },
344+
},
345+
{ type: "message_stop" },
346+
]),
347+
),
348+
});
349+
const tools = [
350+
{
351+
get name() {
352+
throw new Error("legacy anthropic OAuth remap name getter exploded");
353+
},
354+
description: "unreadable name",
355+
parameters: { type: "object", properties: {} },
356+
},
357+
{
358+
name: "read",
359+
description: "read a file",
360+
parameters: { type: "object", properties: {} },
361+
},
362+
];
363+
364+
const stream = streamAnthropic(
365+
makeAnthropicModel(),
366+
{
367+
messages: [{ role: "user", content: "hello", timestamp: 0 }],
368+
tools,
369+
} as unknown as Context,
370+
{
371+
apiKey: "sk-ant-oat-example",
372+
},
373+
);
374+
375+
const result = await stream.result();
376+
377+
expect(result.stopReason).toBe("toolUse");
378+
expect(result.content).toContainEqual(
379+
expect.objectContaining({
380+
type: "toolCall",
381+
name: "read",
382+
}),
383+
);
384+
});
385+
171386
it("clamps max adaptive effort when the Claude model does not advertise it", async () => {
172387
let capturedPayload: unknown;
173388
const stream = streamSimpleAnthropic(

0 commit comments

Comments
 (0)