Skip to content

Commit bae019e

Browse files
roaminroMastra Code (openai/gpt-5.5)
andauthored
fix(core): preserve guardrail validation with Anthropic schemas (#15739)
## Summary Fix guardrail processors with Anthropic structured output by applying the existing Anthropic schema compatibility layer when building AI SDK v5 response formats. Anthropic rejects JSON Schema numeric bounds like `minimum` and `maximum` on structured output schemas. The guardrail processors still define scores with strict Zod validation (`z.number().min(0).max(1)`), but the provider-facing JSON Schema now strips unsupported numeric bounds for Anthropic before the request is sent. This preserves the previous local validation semantics for generated output while avoiding Anthropic request failures that caused guardrails to fail open. ## What changed - Pass model info into core response-format schema generation so Anthropic compatibility can run on direct AI SDK v5 structured output. - Keep guardrail score/confidence schemas strict with Zod `min(0).max(1)`. - Remove the temporary manual score validation/clamping path. - Add regression tests for Anthropic-compatible provider schemas and preserved local Zod validation. ## Test plan - `pnpm --filter ./packages/core exec vitest run src/processors/processors/prompt-injection-detector.test.ts src/processors/processors/pii-detector.test.ts src/processors/processors/moderation.test.ts src/stream/base/schema.test.ts` - `pnpm --filter ./packages/schema-compat test -- provider-compats/anthropic.test.ts` - `pnpm --filter ./packages/core check` Note: `pnpm test:core:zod` and `pnpm --filter ./packages/core typecheck:zod-compat` were requested by package instructions, but those scripts are not present in the current root/core `package.json` files. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 The PR fixes guardrail processors to work properly with Anthropic AI models while keeping their safety checks intact. Anthropic doesn't accept certain validation constraints in schemas (like numeric ranges), so the code now removes those from what's sent to Anthropic—but keeps the strict validation on the backend to ensure guardrails still work correctly. --- ## Overview This PR ensures that guardrail processors (moderation, PII detection, prompt-injection detection) remain locally strict while producing Anthropic-compatible structured-output schemas for AI SDK v5. The core issue is that Anthropic rejects JSON Schema numeric bounds (`minimum`/`maximum`), so the fix applies an existing Anthropic schema compatibility layer when building provider-facing response formats. ## Changes ### Schema Compatibility Layer - Updated `getTransformedSchema` and `getResponseFormat` in `packages/core/src/stream/base/schema.ts` to accept an optional `model` parameter (`SchemaModelInfo`) - When model info is provided, applies `AnthropicSchemaCompatLayer` to strip numeric bounds from JSON schemas sent to Anthropic - Preserves existing behavior when no model info is provided - Added new exported type `SchemaModelInfo` to support passing model context ### Response Format Generation - Modified `packages/core/src/stream/aisdk/v5/execute.ts` to pass model context (`provider`, `modelId`, `supportsStructuredOutputs: true`) into `getResponseFormat` - Enables provider-specific schema transformations based on model capabilities ### Guardrail Validation Tests - Added new tests for moderation, PII detection, and prompt-injection detection verifying Anthropic schema compatibility - Tests confirm that generated schemas for Anthropic exclude numeric bounds (`minimum`/`maximum`) while still containing score/confidence fields - Added runtime validation tests simulating out-of-range values to verify fail-open behavior with warning logs - Tests confirm local Zod validation remains strict with numeric constraints (`z.number().min(0).max(1)`) ### Schema Compatibility Validation - Added unit test for `AnthropicSchemaCompatLayer` in `packages/schema-compat/src/provider-compats/anthropic.test.ts` verifying numeric bounds are stripped while Zod validation behavior is preserved ## Impact - Guardrail processors maintain strict local validation semantics - Schemas sent to Anthropic are compatible with Anthropic's structured output requirements - Removed temporary manual score validation/clamping path - Regression tests ensure both Anthropic compatibility and preserved local validation work correctly <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Mastra Code (openai/gpt-5.5) <noreply@mastra.ai>
1 parent 480f137 commit bae019e

8 files changed

Lines changed: 262 additions & 7 deletions

File tree

.changeset/busy-doodles-begin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed guardrail processor schemas so structured output works with Anthropic models.

packages/core/src/processors/processors/moderation.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { MockLanguageModelV1 } from '@internal/ai-sdk-v4/test';
22
import { describe, it, expect, vi, beforeEach } from 'vitest';
33
import type { MastraDBMessage } from '../../agent/message-list';
44
import { TripWire } from '../../agent/trip-wire';
5+
import { MastraLanguageModelV2Mock } from '../../loop/test-utils/MastraLanguageModelV2Mock';
56
import type { ChunkType } from '../../stream';
67
import { ChunkFrom } from '../../stream/types';
78
import type { ModerationResult } from './moderation';
@@ -700,4 +701,53 @@ describe('ModerationProcessor', () => {
700701
expect(mockAbort).not.toHaveBeenCalled();
701702
});
702703
});
704+
705+
describe('structured output schema compatibility', () => {
706+
it('should not send number bounds to Anthropic in score schemas', async () => {
707+
const mockResult = createMockModerationResult(false);
708+
const mockModel = new MastraLanguageModelV2Mock({
709+
provider: 'anthropic',
710+
modelId: 'claude-3-5-sonnet',
711+
doGenerate: async () => ({
712+
rawCall: { rawPrompt: null, rawSettings: {} },
713+
finishReason: 'stop',
714+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
715+
content: [{ type: 'text', text: JSON.stringify(mockResult) }],
716+
warnings: [],
717+
}),
718+
});
719+
720+
const moderator = new ModerationProcessor({ model: mockModel });
721+
722+
await moderator.processInput({ messages: [createTestMessage('Safe content')], abort: vi.fn() as any });
723+
724+
const responseFormat = mockModel.doGenerateCalls[0].responseFormat;
725+
expect(responseFormat?.type).toBe('json');
726+
const schema = responseFormat?.type === 'json' ? responseFormat.schema : undefined;
727+
const schemaJson = JSON.stringify(schema);
728+
expect(schemaJson).toContain('score');
729+
expect(schemaJson).not.toContain('minimum');
730+
expect(schemaJson).not.toContain('maximum');
731+
});
732+
733+
it('should reject scores outside the 0-1 range at runtime', async () => {
734+
const model = setupMockModel({
735+
object: {
736+
category_scores: [{ category: 'hate', score: 1.2 }],
737+
reason: 'Flagged content',
738+
},
739+
});
740+
const moderator = new ModerationProcessor({ model, strategy: 'warn', includeScores: true });
741+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
742+
743+
await moderator.processInput({ messages: [createTestMessage('Flagged content')], abort: vi.fn() as any });
744+
745+
expect(consoleSpy).toHaveBeenCalledWith(
746+
'[ModerationProcessor] Agent moderation failed, allowing content:',
747+
expect.any(Error),
748+
);
749+
750+
consoleSpy.mockRestore();
751+
});
752+
});
703753
});

packages/core/src/processors/processors/pii-detector.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { MockLanguageModelV1 } from '@internal/ai-sdk-v4/test';
33
import { describe, it, expect, vi, beforeEach } from 'vitest';
44
import type { MastraDBMessage } from '../../agent/message-list';
55
import { TripWire } from '../../agent/trip-wire';
6+
import { MastraLanguageModelV2Mock } from '../../loop/test-utils/MastraLanguageModelV2Mock';
67
import type { ChunkType } from '../../stream';
78
import { ChunkFrom } from '../../stream/types';
89
import type { PIIDetectionResult, PIIDetection } from './pii-detector';
@@ -1139,4 +1140,73 @@ describe('PIIDetector', () => {
11391140
expect(result).toEqual(messages); // Should return original messages on failure
11401141
});
11411142
});
1143+
1144+
describe('structured output schema compatibility', () => {
1145+
it('should not send number bounds to Anthropic in score or confidence schemas', async () => {
1146+
const mockResult = createMockPIIResult();
1147+
const mockModel = new MastraLanguageModelV2Mock({
1148+
provider: 'anthropic',
1149+
modelId: 'claude-3-5-sonnet',
1150+
doGenerate: async () => ({
1151+
rawCall: { rawPrompt: null, rawSettings: {} },
1152+
finishReason: 'stop',
1153+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
1154+
content: [{ type: 'text', text: JSON.stringify(mockResult) }],
1155+
warnings: [],
1156+
}),
1157+
});
1158+
1159+
const detector = new PIIDetector({ model: mockModel });
1160+
1161+
await detector.processInput({ messages: [createTestMessage('Hello world')], abort: vi.fn() as any });
1162+
1163+
const responseFormat = mockModel.doGenerateCalls[0].responseFormat;
1164+
expect(responseFormat?.type).toBe('json');
1165+
const schema = responseFormat?.type === 'json' ? responseFormat.schema : undefined;
1166+
const schemaJson = JSON.stringify(schema);
1167+
expect(schemaJson).toContain('score');
1168+
expect(schemaJson).toContain('confidence');
1169+
expect(schemaJson).not.toContain('minimum');
1170+
expect(schemaJson).not.toContain('maximum');
1171+
});
1172+
1173+
it('should reject scores outside the 0-1 range at runtime', async () => {
1174+
const model = setupMockModel({
1175+
categories: [{ type: 'email', score: 1.2 }],
1176+
detections: null,
1177+
});
1178+
const detector = new PIIDetector({ model, strategy: 'warn', threshold: 1.1 });
1179+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1180+
1181+
await detector.processInput({
1182+
messages: [createTestMessage('Hello world')],
1183+
abort: vi.fn() as any,
1184+
});
1185+
1186+
expect(consoleSpy).toHaveBeenCalledWith(
1187+
'[PIIDetector] Detection agent failed, allowing content:',
1188+
expect.any(Error),
1189+
);
1190+
1191+
consoleSpy.mockRestore();
1192+
});
1193+
1194+
it('should reject confidence values outside the 0-1 range at runtime', async () => {
1195+
const model = setupMockModel({
1196+
categories: null,
1197+
detections: [{ type: 'email', value: 'test@example.com', confidence: -0.2, start: 0, end: 16 }],
1198+
});
1199+
const detector = new PIIDetector({ model, strategy: 'warn', threshold: 0 });
1200+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1201+
1202+
await detector.processInput({ messages: [createTestMessage('Hello world')], abort: vi.fn() as any });
1203+
1204+
expect(consoleSpy).toHaveBeenCalledWith(
1205+
'[PIIDetector] Detection agent failed, allowing content:',
1206+
expect.any(Error),
1207+
);
1208+
1209+
consoleSpy.mockRestore();
1210+
});
1211+
});
11421212
});

packages/core/src/processors/processors/prompt-injection-detector.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,4 +723,51 @@ describe('PromptInjectionDetector', () => {
723723
});
724724
});
725725
});
726+
727+
describe('structured output schema compatibility', () => {
728+
it('should not send number bounds to Anthropic in score schemas', async () => {
729+
const mockResult = createMockDetectionResult(false);
730+
const mockModel = new MastraLanguageModelV2Mock({
731+
provider: 'anthropic',
732+
modelId: 'claude-3-5-sonnet',
733+
doGenerate: async () => ({
734+
rawCall: { rawPrompt: null, rawSettings: {} },
735+
finishReason: 'stop',
736+
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
737+
content: [{ type: 'text', text: JSON.stringify(mockResult) }],
738+
warnings: [],
739+
}),
740+
});
741+
742+
const detector = new PromptInjectionDetector({ model: mockModel });
743+
744+
await detector.processInput({ messages: [createTestMessage('Test message', 'user')], abort: vi.fn() as any });
745+
746+
const responseFormat = mockModel.doGenerateCalls[0].responseFormat;
747+
expect(responseFormat?.type).toBe('json');
748+
const schema = responseFormat?.type === 'json' ? responseFormat.schema : undefined;
749+
const schemaJson = JSON.stringify(schema);
750+
expect(schemaJson).toContain('score');
751+
expect(schemaJson).not.toContain('minimum');
752+
expect(schemaJson).not.toContain('maximum');
753+
});
754+
755+
it('should fail open and warn when scores are outside the 0-1 range', async () => {
756+
const model = setupMockModel({
757+
categories: [{ type: 'injection', score: 1.2 }],
758+
reason: 'Attack detected',
759+
});
760+
const detector = new PromptInjectionDetector({ model, strategy: 'warn', includeScores: true });
761+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
762+
763+
await detector.processInput({ messages: [createTestMessage('Test message', 'user')], abort: vi.fn() as any });
764+
765+
expect(consoleSpy).toHaveBeenCalledWith(
766+
'[PromptInjectionDetector] Detection agent failed, allowing content:',
767+
expect.any(Error),
768+
);
769+
770+
consoleSpy.mockRestore();
771+
});
772+
});
726773
});

packages/core/src/stream/aisdk/v5/execute.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,15 @@ export function execute<OUTPUT = undefined>({
8787
: 'direct'
8888
: undefined;
8989

90-
const responseFormat = structuredOutput?.schema ? getResponseFormat(structuredOutput?.schema) : undefined;
90+
const responseFormat = structuredOutput?.schema
91+
? getResponseFormat(structuredOutput?.schema, {
92+
model: {
93+
provider: model.provider,
94+
modelId: model.modelId,
95+
supportsStructuredOutputs: true,
96+
},
97+
})
98+
: undefined;
9199

92100
let prompt = inputMessages;
93101

packages/core/src/stream/base/schema.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,35 @@
1818

1919
import { describe, it, expect } from 'vitest';
2020
import { z } from 'zod/v4';
21-
import { asJsonSchema } from './schema';
21+
import { asJsonSchema, getResponseFormat } from './schema';
22+
23+
describe('getResponseFormat', () => {
24+
it('applies Anthropic schema compatibility without removing local validation', async () => {
25+
const schema = z.object({
26+
score: z.number().min(0).max(1),
27+
});
28+
29+
const responseFormat = getResponseFormat(schema, {
30+
model: {
31+
provider: 'anthropic',
32+
modelId: 'claude-3-5-sonnet',
33+
supportsStructuredOutputs: true,
34+
},
35+
});
36+
37+
expect(responseFormat.type).toBe('json');
38+
const schemaJson = JSON.stringify(responseFormat.type === 'json' ? responseFormat.schema : undefined);
39+
expect(schemaJson).toContain('score');
40+
expect(schemaJson).not.toContain('minimum');
41+
expect(schemaJson).not.toContain('maximum');
42+
43+
const validResult = await schema['~standard'].validate({ score: 0.5 });
44+
expect(validResult).toEqual({ value: { score: 0.5 } });
45+
46+
const invalidResult = await schema['~standard'].validate({ score: 1.2 });
47+
expect('issues' in invalidResult).toBe(true);
48+
});
49+
});
2250

2351
describe('asJsonSchema - Zod v4 transform compatibility', () => {
2452
describe('should handle schemas with transforms', () => {

packages/core/src/stream/base/schema.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { JSONSchema7, Schema } from '@internal/ai-sdk-v5';
2+
import { AnthropicSchemaCompatLayer, applyCompatLayer } from '@mastra/schema-compat';
23
import type { z as z3 } from 'zod/v3';
34
import type { z as z4 } from 'zod/v4';
4-
import type { StandardSchemaWithJSON } from '../../schema';
5+
import type { PublicSchema, StandardSchemaWithJSON } from '../../schema';
56
import { isStandardSchemaWithJSON, standardSchemaToJSONSchema } from '../../schema';
67

78
export type PartialSchemaOutput<OUTPUT = undefined> = OUTPUT extends undefined ? undefined : Partial<OUTPUT>;
@@ -69,8 +70,27 @@ export function asJsonSchema(schema: StandardSchemaWithJSON | undefined): JSONSc
6970
return schema;
7071
}
7172

72-
export function getTransformedSchema<OUTPUT = undefined>(schema?: StandardSchemaWithJSON<OUTPUT>) {
73-
const jsonSchema = asJsonSchema(schema);
73+
type SchemaModelInfo = {
74+
provider: string;
75+
modelId: string;
76+
supportsStructuredOutputs: boolean;
77+
};
78+
79+
export function getTransformedSchema<OUTPUT = undefined>(
80+
schema?: StandardSchemaWithJSON<OUTPUT>,
81+
options?: { model?: SchemaModelInfo },
82+
) {
83+
if (!schema) {
84+
return undefined;
85+
}
86+
87+
const jsonSchema = options?.model
88+
? (applyCompatLayer({
89+
schema: schema as PublicSchema<OUTPUT>,
90+
compatLayers: [new AnthropicSchemaCompatLayer(options.model)],
91+
mode: 'jsonSchema',
92+
}) as JSONSchema7)
93+
: asJsonSchema(schema);
7494

7595
if (!jsonSchema) {
7696
return undefined;
@@ -119,7 +139,10 @@ export function getTransformedSchema<OUTPUT = undefined>(schema?: StandardSchema
119139
};
120140
}
121141

122-
export function getResponseFormat(schema?: StandardSchemaWithJSON):
142+
export function getResponseFormat(
143+
schema?: StandardSchemaWithJSON,
144+
options?: { model?: SchemaModelInfo },
145+
):
123146
| {
124147
type: 'text';
125148
}
@@ -131,7 +154,7 @@ export function getResponseFormat(schema?: StandardSchemaWithJSON):
131154
schema?: JSONSchema7;
132155
} {
133156
if (schema) {
134-
const transformedSchema = getTransformedSchema(schema);
157+
const transformedSchema = getTransformedSchema(schema, options);
135158
return {
136159
type: 'json',
137160
schema: transformedSchema?.jsonSchema,

packages/schema-compat/src/provider-compats/anthropic.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { describe, it, expect } from 'vitest';
2+
import { z } from 'zod/v4';
3+
import { standardSchemaToJSONSchema } from '../standard-schema/standard-schema';
24
import type { ModelInformation } from '../types';
35
import { AnthropicSchemaCompatLayer } from './anthropic';
46
import { createSuite } from './test-suite';
@@ -60,4 +62,26 @@ describe('AnthropicSchemaCompatLayer', () => {
6062
expect(layer.getSchemaTarget()).toBe('jsonSchema7');
6163
});
6264
});
65+
66+
describe('number bounds', () => {
67+
it('should strip number bounds from JSON Schema while preserving Zod validation', async () => {
68+
const schema = z.object({
69+
score: z.number().min(0).max(1),
70+
});
71+
const layer = new AnthropicSchemaCompatLayer(modelInfo);
72+
const compatSchema = layer.processToCompatSchema(schema);
73+
const jsonSchema = standardSchemaToJSONSchema(compatSchema);
74+
const schemaJson = JSON.stringify(jsonSchema);
75+
76+
expect(schemaJson).toContain('score');
77+
expect(schemaJson).not.toContain('minimum');
78+
expect(schemaJson).not.toContain('maximum');
79+
80+
const validResult = await compatSchema['~standard'].validate({ score: 0.5 });
81+
expect(validResult).toEqual({ value: { score: 0.5 } });
82+
83+
const invalidResult = await compatSchema['~standard'].validate({ score: 1.2 });
84+
expect('issues' in invalidResult).toBe(true);
85+
});
86+
});
6387
});

0 commit comments

Comments
 (0)