From 1211727ba351c9de104d27296f844fe9c90193d2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 18:53:12 +0000 Subject: [PATCH] feat(anthropic): add validation for arbitrary json objects in structured outputs 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. --- src/ax/ai/anthropic/api.test.ts | 47 +++++++++++++++++++++++++++++++++ src/ax/ai/anthropic/api.ts | 46 ++++++++++++++------------------ 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/src/ax/ai/anthropic/api.test.ts b/src/ax/ai/anthropic/api.test.ts index 8df65113..ef22b81a 100644 --- a/src/ax/ai/anthropic/api.test.ts +++ b/src/ax/ai/anthropic/api.test.ts @@ -56,6 +56,53 @@ describe('AxAIAnthropic model key preset merging', () => { }); }); +describe('AxAIAnthropic schema validation', () => { + it('should throw an error for arbitrary JSON objects in structured outputs', async () => { + const ai = new AxAIAnthropic({ + apiKey: 'key', + config: { model: AxAIAnthropicModel.Claude35Sonnet }, + }); + + const fetch = createMockFetch({ + id: 'id', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + model: 'claude-3-5-sonnet-latest', + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }); + + ai.setOptions({ fetch }); + + await expect( + ai.chat({ + chatPrompt: [{ role: 'user', content: 'hi' }], + responseFormat: { + type: 'json_schema', + schema: { + type: 'object', + properties: { + arbitrary: { + type: [ + 'object', + 'array', + 'string', + 'number', + 'boolean', + 'null', + ], + }, + }, + }, + }, + }) + ).rejects.toThrow( + '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({ ... })' + ); + }); +}); + describe('AxAIAnthropic trims trailing whitespace in assistant content', () => { it('removes trailing whitespace from assistant string content in request body', async () => { const ai = new AxAIAnthropic({ diff --git a/src/ax/ai/anthropic/api.ts b/src/ax/ai/anthropic/api.ts index daebe90d..7bfecdab 100644 --- a/src/ax/ai/anthropic/api.ts +++ b/src/ax/ai/anthropic/api.ts @@ -30,23 +30,26 @@ import { AxAIAnthropicVertexModel, } from './types.js'; -/** - * Clean function schema for Anthropic API compatibility - * Anthropic uses input_schema and may not support certain JSON Schema fields - */ -const cleanSchemaForAnthropic = ( - schema: any, - preserveAdditionalProperties: boolean = false -): any => { +const cleanSchemaForAnthropic = (schema: any): any => { if (!schema || typeof schema !== 'object') { return schema; } const cleaned = { ...schema }; - // Remove fields that might cause issues with Anthropic - if (!preserveAdditionalProperties) { - delete cleaned.additionalProperties; + const isObjectType = + cleaned.type === 'object' || + (Array.isArray(cleaned.type) && cleaned.type.includes('object')); + + if (isObjectType) { + if (!cleaned.properties || Object.keys(cleaned.properties).length === 0) { + throw new Error( + '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({ ... })' + ); + } + if (cleaned.additionalProperties === undefined) { + cleaned.additionalProperties = false; + } } // Anthropic supports default, anyOf, allOf, const, enum. @@ -62,34 +65,25 @@ const cleanSchemaForAnthropic = ( cleaned.properties = Object.fromEntries( Object.entries(cleaned.properties).map(([key, value]) => [ key, - cleanSchemaForAnthropic(value, preserveAdditionalProperties), + cleanSchemaForAnthropic(value), ]) ); } // Recursively clean items (for arrays) if (cleaned.items) { - cleaned.items = cleanSchemaForAnthropic( - cleaned.items, - preserveAdditionalProperties - ); + cleaned.items = cleanSchemaForAnthropic(cleaned.items); } // Recursively clean anyOf, allOf, oneOf if (Array.isArray(cleaned.anyOf)) { - cleaned.anyOf = cleaned.anyOf.map((s: any) => - cleanSchemaForAnthropic(s, preserveAdditionalProperties) - ); + cleaned.anyOf = cleaned.anyOf.map((s: any) => cleanSchemaForAnthropic(s)); } if (Array.isArray(cleaned.allOf)) { - cleaned.allOf = cleaned.allOf.map((s: any) => - cleanSchemaForAnthropic(s, preserveAdditionalProperties) - ); + cleaned.allOf = cleaned.allOf.map((s: any) => cleanSchemaForAnthropic(s)); } if (Array.isArray(cleaned.oneOf)) { - cleaned.oneOf = cleaned.oneOf.map((s: any) => - cleanSchemaForAnthropic(s, preserveAdditionalProperties) - ); + cleaned.oneOf = cleaned.oneOf.map((s: any) => cleanSchemaForAnthropic(s)); } return cleaned; @@ -423,7 +417,7 @@ class AxAIAnthropicImpl outputFormat = { type: 'json_schema', - schema: cleanSchemaForAnthropic(schema, true), + schema: cleanSchemaForAnthropic(schema), }; this.usedStructuredOutput = true; }