Skip to content

Commit 18456a4

Browse files
authored
fix(anthropic): keep enum and const as structured-output constraints (#7558)
* fix(anthropic): keep enum and const as structured-output constraints The SDK's transformJSONSchema pops every keyword it recognises and stringifies whatever is left into the node's description. It has no branch for enum or const, so both were silently demoted from grammar constraints into prose, leaving native structured outputs unenforced for them on every Anthropic and Azure Anthropic agent block. Lift enum and const out before the transform and re-attach them after, so the transform's sanitising is preserved verbatim. Only lift the shapes the API grammar-checks: a non-empty enum of primitives matching the node's declared type, and a primitive const. Anything else stays in place and is demoted exactly as before, since sending it would make the API reject the whole request. * chore(anthropic): type the structured-output test helper instead of any Replaces the two Record<string, any> casts in the new test helper with a WireSchemaNode interface describing the subset of the wire schema the assertions read back, per the repo's no-any rule. * test(anthropic): lock the structured-output schema non-mutation invariant The builder deep-clones before walking so it never deletes enum/const from the caller's schema object. Agent blocks inside a parallel or loop reuse the same responseFormat object across iterations, so losing that clone would strip the constraints from every iteration after the first. Verified the test fails when the clone is removed.
1 parent 0049514 commit 18456a4

3 files changed

Lines changed: 419 additions & 2 deletions

File tree

apps/sim/providers/anthropic/core.request.test.ts

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
})

apps/sim/providers/anthropic/core.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type Anthropic from '@anthropic-ai/sdk'
2-
import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema'
32
import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages/messages'
43
import type { Logger } from '@sim/logger'
54
import { getErrorMessage, toError } from '@sim/utils/errors'
@@ -8,6 +7,7 @@ import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } fro
87
import { MAX_TOOL_ITERATIONS } from '@/providers'
98
import { convertAnthropicRequestHistory } from '@/providers/anthropic/request-history'
109
import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/streaming-tool-loop'
10+
import { buildAnthropicStructuredOutputSchema } from '@/providers/anthropic/structured-output-schema'
1111
import {
1212
addAnthropicUsage,
1313
buildAnthropicUsageCost,
@@ -333,7 +333,7 @@ export async function executeAnthropicProviderRequest(
333333
const schema = request.responseFormat.schema || request.responseFormat
334334

335335
if (useNativeStructuredOutputs) {
336-
const transformedSchema = transformJSONSchema(schema)
336+
const transformedSchema = buildAnthropicStructuredOutputSchema(schema)
337337
payload.output_config = {
338338
...payload.output_config,
339339
format: {

0 commit comments

Comments
 (0)