Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/ax/ai/anthropic/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
46 changes: 20 additions & 26 deletions src/ax/ai/anthropic/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -423,7 +417,7 @@ class AxAIAnthropicImpl

outputFormat = {
type: 'json_schema',
schema: cleanSchemaForAnthropic(schema, true),
schema: cleanSchemaForAnthropic(schema),
};
this.usedStructuredOutput = true;
}
Expand Down