@@ -3,12 +3,16 @@ import type { Context, Model } from "../types.js";
33
44const anthropicMockState = vi . hoisted ( ( ) => ( {
55 configs : [ ] as unknown [ ] ,
6+ createImpl : null as null | ( ( params : unknown , options : unknown ) => unknown ) ,
67} ) ) ;
78
89vi . 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", () => ({
2226import { streamAnthropic , streamSimpleAnthropic } from "./anthropic.js" ;
2327
2428function 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">> = {}
4854describe ( "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