Skip to content

Commit 7db81c5

Browse files
feat(anthropic): add validation for arbitrary json objects in structured outputs (#459)
Anthropic models do not support arbitrary JSON objects (e.g. f.json() or f.object() with no properties) in structured outputs. This commit adds a validation step that throws a descriptive error when this unsupported schema is detected, preventing the API from returning a cryptic "Bad Request" error. For valid structured objects, this commit also ensures `additionalProperties: false` is set recursively, which is required by the Anthropic API. A new test case has been added to verify the error-throwing behavior. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 254bf73 commit 7db81c5

File tree

2 files changed

+67
-26
lines changed

2 files changed

+67
-26
lines changed

src/ax/ai/anthropic/api.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,53 @@ describe('AxAIAnthropic model key preset merging', () => {
5656
});
5757
});
5858

59+
describe('AxAIAnthropic schema validation', () => {
60+
it('should throw an error for arbitrary JSON objects in structured outputs', async () => {
61+
const ai = new AxAIAnthropic({
62+
apiKey: 'key',
63+
config: { model: AxAIAnthropicModel.Claude35Sonnet },
64+
});
65+
66+
const fetch = createMockFetch({
67+
id: 'id',
68+
type: 'message',
69+
role: 'assistant',
70+
content: [{ type: 'text', text: 'ok' }],
71+
model: 'claude-3-5-sonnet-latest',
72+
stop_reason: 'end_turn',
73+
usage: { input_tokens: 1, output_tokens: 1 },
74+
});
75+
76+
ai.setOptions({ fetch });
77+
78+
await expect(
79+
ai.chat({
80+
chatPrompt: [{ role: 'user', content: 'hi' }],
81+
responseFormat: {
82+
type: 'json_schema',
83+
schema: {
84+
type: 'object',
85+
properties: {
86+
arbitrary: {
87+
type: [
88+
'object',
89+
'array',
90+
'string',
91+
'number',
92+
'boolean',
93+
'null',
94+
],
95+
},
96+
},
97+
},
98+
},
99+
})
100+
).rejects.toThrow(
101+
'Anthropic models do not support arbitrary JSON objects (e.g. f.json() or f.object() with no properties) in structured outputs. Please use f.string() and instruct the model to return a JSON string, or define the expected structure with f.object({ ... })'
102+
);
103+
});
104+
});
105+
59106
describe('AxAIAnthropic trims trailing whitespace in assistant content', () => {
60107
it('removes trailing whitespace from assistant string content in request body', async () => {
61108
const ai = new AxAIAnthropic({

src/ax/ai/anthropic/api.ts

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,26 @@ import {
3030
AxAIAnthropicVertexModel,
3131
} from './types.js';
3232

33-
/**
34-
* Clean function schema for Anthropic API compatibility
35-
* Anthropic uses input_schema and may not support certain JSON Schema fields
36-
*/
37-
const cleanSchemaForAnthropic = (
38-
schema: any,
39-
preserveAdditionalProperties: boolean = false
40-
): any => {
33+
const cleanSchemaForAnthropic = (schema: any): any => {
4134
if (!schema || typeof schema !== 'object') {
4235
return schema;
4336
}
4437

4538
const cleaned = { ...schema };
4639

47-
// Remove fields that might cause issues with Anthropic
48-
if (!preserveAdditionalProperties) {
49-
delete cleaned.additionalProperties;
40+
const isObjectType =
41+
cleaned.type === 'object' ||
42+
(Array.isArray(cleaned.type) && cleaned.type.includes('object'));
43+
44+
if (isObjectType) {
45+
if (!cleaned.properties || Object.keys(cleaned.properties).length === 0) {
46+
throw new Error(
47+
'Anthropic models do not support arbitrary JSON objects (e.g. f.json() or f.object() with no properties) in structured outputs. Please use f.string() and instruct the model to return a JSON string, or define the expected structure with f.object({ ... })'
48+
);
49+
}
50+
if (cleaned.additionalProperties === undefined) {
51+
cleaned.additionalProperties = false;
52+
}
5053
}
5154

5255
// Anthropic supports default, anyOf, allOf, const, enum.
@@ -62,34 +65,25 @@ const cleanSchemaForAnthropic = (
6265
cleaned.properties = Object.fromEntries(
6366
Object.entries(cleaned.properties).map(([key, value]) => [
6467
key,
65-
cleanSchemaForAnthropic(value, preserveAdditionalProperties),
68+
cleanSchemaForAnthropic(value),
6669
])
6770
);
6871
}
6972

7073
// Recursively clean items (for arrays)
7174
if (cleaned.items) {
72-
cleaned.items = cleanSchemaForAnthropic(
73-
cleaned.items,
74-
preserveAdditionalProperties
75-
);
75+
cleaned.items = cleanSchemaForAnthropic(cleaned.items);
7676
}
7777

7878
// Recursively clean anyOf, allOf, oneOf
7979
if (Array.isArray(cleaned.anyOf)) {
80-
cleaned.anyOf = cleaned.anyOf.map((s: any) =>
81-
cleanSchemaForAnthropic(s, preserveAdditionalProperties)
82-
);
80+
cleaned.anyOf = cleaned.anyOf.map((s: any) => cleanSchemaForAnthropic(s));
8381
}
8482
if (Array.isArray(cleaned.allOf)) {
85-
cleaned.allOf = cleaned.allOf.map((s: any) =>
86-
cleanSchemaForAnthropic(s, preserveAdditionalProperties)
87-
);
83+
cleaned.allOf = cleaned.allOf.map((s: any) => cleanSchemaForAnthropic(s));
8884
}
8985
if (Array.isArray(cleaned.oneOf)) {
90-
cleaned.oneOf = cleaned.oneOf.map((s: any) =>
91-
cleanSchemaForAnthropic(s, preserveAdditionalProperties)
92-
);
86+
cleaned.oneOf = cleaned.oneOf.map((s: any) => cleanSchemaForAnthropic(s));
9387
}
9488

9589
return cleaned;
@@ -423,7 +417,7 @@ class AxAIAnthropicImpl
423417

424418
outputFormat = {
425419
type: 'json_schema',
426-
schema: cleanSchemaForAnthropic(schema, true),
420+
schema: cleanSchemaForAnthropic(schema),
427421
};
428422
this.usedStructuredOutput = true;
429423
}

0 commit comments

Comments
 (0)