@@ -303,3 +303,237 @@ describe('executeAnthropicProviderRequest forced tool use', () => {
303303 expect ( warn ) . toHaveBeenCalledWith ( expect . stringContaining ( 'rejects forced tool_choice' ) )
304304 } )
305305} )
306+
307+ describe ( 'executeAnthropicProviderRequest native structured outputs' , ( ) => {
308+ /** The subset of the wire schema these assertions read back. */
309+ interface WireSchemaNode {
310+ type ?: string | string [ ]
311+ description ?: string
312+ format ?: string
313+ enum ?: unknown [ ]
314+ const ?: unknown
315+ minItems ?: number
316+ minimum ?: number
317+ additionalProperties ?: boolean
318+ required ?: string [ ]
319+ properties : Record < string , WireSchemaNode >
320+ items : WireSchemaNode
321+ anyOf : WireSchemaNode [ ]
322+ $defs : Record < string , WireSchemaNode >
323+ }
324+
325+ async function sendSchema ( schema : Record < string , unknown > ) : Promise < WireSchemaNode > {
326+ const create = vi . fn ( ) . mockResolvedValue ( {
327+ id : 'msg-schema' ,
328+ type : 'message' ,
329+ role : 'assistant' ,
330+ model : 'claude-sonnet-4-5' ,
331+ content : [ { type : 'text' , text : '{}' } ] ,
332+ stop_reason : 'end_turn' ,
333+ stop_sequence : null ,
334+ usage : { input_tokens : 2 , output_tokens : 2 } ,
335+ } )
336+
337+ await executeAnthropicProviderRequest (
338+ {
339+ model : 'claude-sonnet-4-5' ,
340+ apiKey : 'test-key' ,
341+ maxTokens : 1024 ,
342+ messages : [ { role : 'user' , content : 'Extract' } ] ,
343+ responseFormat : { name : 'extract' , schema } ,
344+ } ,
345+ {
346+ providerId : 'anthropic' ,
347+ providerLabel : 'Anthropic' ,
348+ createClient : ( ) => ( { messages : { create } } ) as never ,
349+ logger : { info : vi . fn ( ) , warn : vi . fn ( ) , error : vi . fn ( ) , debug : vi . fn ( ) } ,
350+ }
351+ )
352+
353+ const payload = create . mock . calls [ 0 ] [ 0 ] as Anthropic . Messages . MessageCreateParams & {
354+ output_config ?: { format ?: { type : string ; schema : WireSchemaNode } }
355+ }
356+ return payload . output_config ?. format ?. schema as WireSchemaNode
357+ }
358+
359+ beforeEach ( ( ) => {
360+ mockExecuteTool . mockReset ( )
361+ } )
362+
363+ it ( 'keeps enum and const as grammar constraints at every level' , async ( ) => {
364+ const wireSchema = await sendSchema ( {
365+ type : 'object' ,
366+ additionalProperties : false ,
367+ required : [ 'kind' , 'version' , 'entities' ] ,
368+ properties : {
369+ kind : { type : 'string' , enum : [ 'person' , 'org' ] } ,
370+ version : { type : 'string' , const : 'v1' } ,
371+ entities : {
372+ type : 'array' ,
373+ items : {
374+ type : 'object' ,
375+ additionalProperties : false ,
376+ required : [ 'role' ] ,
377+ properties : {
378+ role : {
379+ type : 'string' ,
380+ description : 'Which role applies.' ,
381+ enum : [ 'owner' , 'viewer' ] ,
382+ } ,
383+ } ,
384+ } ,
385+ } ,
386+ } ,
387+ } )
388+
389+ expect ( wireSchema . properties . kind . enum ) . toEqual ( [ 'person' , 'org' ] )
390+ expect ( wireSchema . properties . version . const ) . toBe ( 'v1' )
391+ expect ( wireSchema . properties . entities . items . properties . role . enum ) . toEqual ( [ 'owner' , 'viewer' ] )
392+ } )
393+
394+ it ( 'does not leave enum duplicated as description prose' , async ( ) => {
395+ const wireSchema = await sendSchema ( {
396+ type : 'object' ,
397+ additionalProperties : false ,
398+ required : [ 'kind' ] ,
399+ properties : {
400+ kind : { type : 'string' , description : 'Which heading.' , enum : [ 'goal' , 'status' ] } ,
401+ } ,
402+ } )
403+
404+ expect ( wireSchema . properties . kind . description ) . toBe ( 'Which heading.' )
405+ expect ( wireSchema . properties . kind . enum ) . toEqual ( [ 'goal' , 'status' ] )
406+ } )
407+
408+ it ( 'preserves enum inside $defs and anyOf branches' , async ( ) => {
409+ const wireSchema = await sendSchema ( {
410+ type : 'object' ,
411+ additionalProperties : false ,
412+ required : [ 'status' , 'choice' ] ,
413+ $defs : {
414+ Status : { type : 'string' , enum : [ 'open' , 'closed' ] } ,
415+ } ,
416+ properties : {
417+ status : { $ref : '#/$defs/Status' } ,
418+ choice : {
419+ oneOf : [
420+ { type : 'string' , enum : [ 'a' , 'b' ] } ,
421+ { type : 'string' , const : 'c' } ,
422+ ] ,
423+ } ,
424+ } ,
425+ } )
426+
427+ expect ( wireSchema . $defs . Status . enum ) . toEqual ( [ 'open' , 'closed' ] )
428+ expect ( wireSchema . properties . choice . anyOf [ 0 ] . enum ) . toEqual ( [ 'a' , 'b' ] )
429+ expect ( wireSchema . properties . choice . anyOf [ 1 ] . const ) . toBe ( 'c' )
430+ } )
431+
432+ it ( 'still sanitizes the constraints the API rejects' , async ( ) => {
433+ const wireSchema = await sendSchema ( {
434+ type : 'object' ,
435+ required : [ 'tags' , 'score' , 'slug' ] ,
436+ properties : {
437+ tags : { type : 'array' , minItems : 5 , items : { type : 'string' , enum : [ 'x' , 'y' ] } } ,
438+ score : { type : 'number' , minimum : 1 , maximum : 10 } ,
439+ slug : { type : 'string' , format : 'slug' } ,
440+ } ,
441+ } )
442+
443+ expect ( wireSchema . additionalProperties ) . toBe ( false )
444+ expect ( wireSchema . properties . tags . minItems ) . toBeUndefined ( )
445+ expect ( wireSchema . properties . tags . items . enum ) . toEqual ( [ 'x' , 'y' ] )
446+ expect ( wireSchema . properties . score . minimum ) . toBeUndefined ( )
447+ expect ( wireSchema . properties . score . description ) . toContain ( 'minimum' )
448+ expect ( wireSchema . properties . slug . format ) . toBeUndefined ( )
449+ } )
450+
451+ it . each < [ string , Record < string , unknown > , 'enum' | 'const' ] > ( [
452+ [ 'object enum members' , { type : 'object' , enum : [ { a : 1 } , { a : 2 } ] } , 'enum' ] ,
453+ [
454+ 'array enum members' ,
455+ { type : 'array' , items : { type : 'string' } , enum : [ [ 'a' ] , [ 'b' ] ] } ,
456+ 'enum' ,
457+ ] ,
458+ [ 'enum containing null on a string node' , { type : 'string' , enum : [ 'a' , null ] } , 'enum' ] ,
459+ [ 'mixed-type enum on a string node' , { type : 'string' , enum : [ 'a' , 1 ] } , 'enum' ] ,
460+ [ 'string enum on a number node' , { type : 'number' , enum : [ 'a' , 'b' ] } , 'enum' ] ,
461+ [ 'empty enum' , { type : 'string' , enum : [ ] } , 'enum' ] ,
462+ [ 'enum on a node with no declared type' , { anyOf : [ { type : 'string' } ] , enum : [ 'a' ] } , 'enum' ] ,
463+ [ 'const with an object value' , { type : 'object' , const : { a : 1 } } , 'const' ] ,
464+ [
465+ 'const with an array value' ,
466+ { type : 'array' , items : { type : 'string' } , const : [ 'a' ] } ,
467+ 'const' ,
468+ ] ,
469+ ] ) (
470+ 'leaves %s demoted into description, exactly as the SDK transform does today' ,
471+ async ( _name , node , keyword ) => {
472+ const wireSchema = await sendSchema ( {
473+ type : 'object' ,
474+ additionalProperties : false ,
475+ required : [ 'field' ] ,
476+ properties : { field : node as Record < string , unknown > } ,
477+ } )
478+
479+ expect ( wireSchema . properties . field [ keyword ] ) . toBeUndefined ( )
480+ expect ( wireSchema . properties . field . description ) . toContain ( `${ keyword } :` )
481+ }
482+ )
483+
484+ it ( 'lifts the primitive const and enum shapes the API does grammar-check' , async ( ) => {
485+ const wireSchema = await sendSchema ( {
486+ type : 'object' ,
487+ additionalProperties : false ,
488+ required : [ 'count' , 'flag' , 'rank' , 'nothing' , 'fixed' , 'zero' , 'off' ] ,
489+ properties : {
490+ count : { type : 'number' , enum : [ 1 , 2 , 3 ] } ,
491+ flag : { type : 'boolean' , enum : [ true , false ] } ,
492+ rank : { type : 'integer' , enum : [ 1 , 2 ] } ,
493+ nothing : { type : 'null' , enum : [ null ] } ,
494+ fixed : { type : 'string' , const : 'v1' } ,
495+ zero : { type : 'number' , const : 0 } ,
496+ off : { type : 'boolean' , const : false } ,
497+ } ,
498+ } )
499+
500+ expect ( wireSchema . properties . count . enum ) . toEqual ( [ 1 , 2 , 3 ] )
501+ expect ( wireSchema . properties . flag . enum ) . toEqual ( [ true , false ] )
502+ expect ( wireSchema . properties . rank . enum ) . toEqual ( [ 1 , 2 ] )
503+ expect ( wireSchema . properties . nothing . enum ) . toEqual ( [ null ] )
504+ expect ( wireSchema . properties . fixed . const ) . toBe ( 'v1' )
505+ expect ( wireSchema . properties . zero . const ) . toBe ( 0 )
506+ expect ( wireSchema . properties . off . const ) . toBe ( false )
507+ } )
508+
509+ it ( "does not mutate the caller's schema, so parallel iterations reuse it safely" , async ( ) => {
510+ const shared = {
511+ type : 'object' ,
512+ additionalProperties : false ,
513+ required : [ 'kind' ] ,
514+ properties : { kind : { type : 'string' , enum : [ 'a' , 'b' ] , description : 'd' } } ,
515+ }
516+ const before = JSON . stringify ( shared )
517+
518+ const first = await sendSchema ( shared )
519+ const second = await sendSchema ( shared )
520+
521+ expect ( JSON . stringify ( shared ) ) . toBe ( before )
522+ expect ( first . properties . kind . enum ) . toEqual ( [ 'a' , 'b' ] )
523+ expect ( second . properties . kind . enum ) . toEqual ( [ 'a' , 'b' ] )
524+ } )
525+
526+ it ( 'does not mistake a property literally named enum for a keyword' , async ( ) => {
527+ const wireSchema = await sendSchema ( {
528+ type : 'object' ,
529+ additionalProperties : false ,
530+ required : [ 'enum' ] ,
531+ properties : {
532+ enum : { type : 'string' , enum : [ 'left' , 'right' ] } ,
533+ } ,
534+ } )
535+
536+ expect ( wireSchema . properties . enum . type ) . toBe ( 'string' )
537+ expect ( wireSchema . properties . enum . enum ) . toEqual ( [ 'left' , 'right' ] )
538+ } )
539+ } )
0 commit comments