Skip to content

Commit 1b1907e

Browse files
committed
fix: 12980 align nativeStructuredOuptut across bedrock and anthropic
1 parent 07f7de8 commit 1b1907e

2 files changed

Lines changed: 169 additions & 38 deletions

File tree

packages/amazon-bedrock/src/bedrock-chat-language-model.test.ts

Lines changed: 158 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ const fakeFetchWithAuth = injectFetchHeaders({ 'x-amz-auth': 'test-auth' });
6868

6969
const modelId = 'anthropic.claude-3-haiku-20240307-v1:0';
7070
const anthropicModelId = 'anthropic.claude-3-5-sonnet-20240620-v1:0'; // Define at top level
71+
const supportedStructuredOutputModelId =
72+
'anthropic.claude-sonnet-4-5-20250929-v1:0';
73+
const supportedStructuredOutput41ModelId =
74+
'anthropic.claude-opus-4-1-20250805-v1:0';
75+
const dateBasedStructuredOutput4ModelId =
76+
'anthropic.claude-sonnet-4-20250514-v1:0';
7177
const baseUrl = 'https://bedrock-runtime.us-east-1.amazonaws.com';
7278

7379
const streamUrl = `${baseUrl}/model/${encodeURIComponent(
@@ -77,6 +83,15 @@ const generateUrl = `${baseUrl}/model/${encodeURIComponent(modelId)}/converse`;
7783
const anthropicGenerateUrl = `${baseUrl}/model/${encodeURIComponent(
7884
anthropicModelId,
7985
)}/converse`;
86+
const supportedStructuredOutputGenerateUrl = `${baseUrl}/model/${encodeURIComponent(
87+
supportedStructuredOutputModelId,
88+
)}/converse`;
89+
const supportedStructuredOutput41GenerateUrl = `${baseUrl}/model/${encodeURIComponent(
90+
supportedStructuredOutput41ModelId,
91+
)}/converse`;
92+
const dateBasedStructuredOutput4GenerateUrl = `${baseUrl}/model/${encodeURIComponent(
93+
dateBasedStructuredOutput4ModelId,
94+
)}/converse`;
8095

8196
const novaModelId = 'us.amazon.nova-2-lite-v1:0';
8297
const novaGenerateUrl = `${baseUrl}/model/${encodeURIComponent(
@@ -103,16 +118,22 @@ const server = createTestServer({
103118
},
104119
// Configure the server for the Anthropic model from the start
105120
[anthropicGenerateUrl]: {},
121+
[supportedStructuredOutputGenerateUrl]: {},
122+
[supportedStructuredOutput41GenerateUrl]: {},
123+
[dateBasedStructuredOutput4GenerateUrl]: {},
106124
[novaGenerateUrl]: {},
107125
[openaiGenerateUrl]: {},
108126
[newerAnthropicGenerateUrl]: {},
109127
});
110128

111129
function prepareJsonFixtureResponse(
112130
filename: string,
113-
{ headers }: { headers?: Record<string, string> } = {},
131+
{
132+
headers,
133+
url = generateUrl,
134+
}: { headers?: Record<string, string>; url?: string } = {},
114135
) {
115-
server.urls[generateUrl].response = {
136+
server.urls[url].response = {
116137
type: 'json-value',
117138
headers,
118139
body: JSON.parse(
@@ -149,6 +170,18 @@ beforeEach(() => {
149170
type: 'json-value',
150171
body: {},
151172
};
173+
server.urls[supportedStructuredOutputGenerateUrl].response = {
174+
type: 'json-value',
175+
body: {},
176+
};
177+
server.urls[supportedStructuredOutput41GenerateUrl].response = {
178+
type: 'json-value',
179+
body: {},
180+
};
181+
server.urls[dateBasedStructuredOutput4GenerateUrl].response = {
182+
type: 'json-value',
183+
body: {},
184+
};
152185
mockPrepareAnthropicTools.mockClear();
153186
});
154187

@@ -183,6 +216,36 @@ const newerAnthropicModel = new BedrockChatLanguageModel(
183216
},
184217
);
185218

219+
const supportedStructuredOutputModel = new BedrockChatLanguageModel(
220+
supportedStructuredOutputModelId,
221+
{
222+
baseUrl: () => baseUrl,
223+
headers: {},
224+
fetch: fakeFetchWithAuth,
225+
generateId: () => 'test-id',
226+
},
227+
);
228+
229+
const supportedStructuredOutput41Model = new BedrockChatLanguageModel(
230+
supportedStructuredOutput41ModelId,
231+
{
232+
baseUrl: () => baseUrl,
233+
headers: {},
234+
fetch: fakeFetchWithAuth,
235+
generateId: () => 'test-id',
236+
},
237+
);
238+
239+
const dateBasedStructuredOutput4Model = new BedrockChatLanguageModel(
240+
dateBasedStructuredOutput4ModelId,
241+
{
242+
baseUrl: () => baseUrl,
243+
headers: {},
244+
fetch: fakeFetchWithAuth,
245+
generateId: () => 'test-id',
246+
},
247+
);
248+
186249
let mockOptions: { success: boolean; errorValue?: any } = { success: true };
187250

188251
describe('doStream', () => {
@@ -4403,9 +4466,11 @@ describe('doGenerate', () => {
44034466
});
44044467

44054468
it('should use native output_config.format instead of json tool when thinking is enabled with structured output', async () => {
4406-
prepareJsonFixtureResponse('bedrock-text');
4469+
prepareJsonFixtureResponse('bedrock-text', {
4470+
url: supportedStructuredOutputGenerateUrl,
4471+
});
44074472

4408-
await model.doGenerate({
4473+
await supportedStructuredOutputModel.doGenerate({
44094474
prompt: [
44104475
{
44114476
role: 'user',
@@ -4467,9 +4532,11 @@ describe('doGenerate', () => {
44674532
});
44684533

44694534
it('should merge output_config.effort and output_config.format when thinking with maxReasoningEffort and structured output', async () => {
4470-
prepareJsonFixtureResponse('bedrock-text');
4535+
prepareJsonFixtureResponse('bedrock-text', {
4536+
url: supportedStructuredOutputGenerateUrl,
4537+
});
44714538

4472-
await model.doGenerate({
4539+
await supportedStructuredOutputModel.doGenerate({
44734540
prompt: [
44744541
{
44754542
role: 'user',
@@ -4516,30 +4583,95 @@ describe('doGenerate', () => {
45164583
});
45174584
});
45184585

4519-
it('should still use json tool fallback for structured output without thinking enabled', async () => {
4520-
server.urls[generateUrl].response = {
4521-
type: 'json-value',
4522-
body: {
4523-
output: {
4524-
message: {
4525-
role: 'assistant',
4526-
content: [
4527-
{
4528-
toolUse: {
4529-
toolUseId: 'json-tool-id',
4530-
name: 'json',
4531-
input: { name: 'Test' },
4532-
},
4533-
},
4534-
],
4586+
it('should use native output_config.format for supported Anthropic models without thinking enabled', async () => {
4587+
prepareJsonFixtureResponse('bedrock-text', {
4588+
url: supportedStructuredOutputGenerateUrl,
4589+
});
4590+
4591+
await supportedStructuredOutputModel.doGenerate({
4592+
prompt: [
4593+
{
4594+
role: 'user',
4595+
content: [{ type: 'text', text: 'Generate a name' }],
4596+
},
4597+
],
4598+
responseFormat: {
4599+
type: 'json',
4600+
schema: {
4601+
type: 'object',
4602+
properties: {
4603+
name: { type: 'string' },
45354604
},
4605+
required: ['name'],
45364606
},
4537-
usage: { inputTokens: 4, outputTokens: 10, totalTokens: 14 },
4538-
stopReason: 'tool_use',
45394607
},
4540-
};
4608+
});
45414609

4542-
const result = await model.doGenerate({
4610+
const requestBody = await server.calls[0].requestBodyJson;
4611+
4612+
expect(requestBody.toolConfig).toBeUndefined();
4613+
4614+
expect(requestBody.additionalModelRequestFields?.output_config).toEqual({
4615+
format: {
4616+
type: 'json_schema',
4617+
schema: {
4618+
type: 'object',
4619+
properties: {
4620+
name: { type: 'string' },
4621+
},
4622+
required: ['name'],
4623+
},
4624+
},
4625+
});
4626+
});
4627+
4628+
it('should use native output_config.format for Anthropic 4-1 models without thinking enabled', async () => {
4629+
prepareJsonFixtureResponse('bedrock-text', {
4630+
url: supportedStructuredOutput41GenerateUrl,
4631+
});
4632+
4633+
await supportedStructuredOutput41Model.doGenerate({
4634+
prompt: [
4635+
{
4636+
role: 'user',
4637+
content: [{ type: 'text', text: 'Generate a name' }],
4638+
},
4639+
],
4640+
responseFormat: {
4641+
type: 'json',
4642+
schema: {
4643+
type: 'object',
4644+
properties: {
4645+
name: { type: 'string' },
4646+
},
4647+
required: ['name'],
4648+
},
4649+
},
4650+
});
4651+
4652+
const requestBody = await server.calls[0].requestBodyJson;
4653+
4654+
expect(requestBody.toolConfig).toBeUndefined();
4655+
expect(requestBody.additionalModelRequestFields?.output_config).toEqual({
4656+
format: {
4657+
type: 'json_schema',
4658+
schema: {
4659+
type: 'object',
4660+
properties: {
4661+
name: { type: 'string' },
4662+
},
4663+
required: ['name'],
4664+
},
4665+
},
4666+
});
4667+
});
4668+
4669+
it('should use json tool fallback for date-based Anthropic 4 models without thinking enabled', async () => {
4670+
prepareJsonFixtureResponse('bedrock-text', {
4671+
url: dateBasedStructuredOutput4GenerateUrl,
4672+
});
4673+
4674+
await dateBasedStructuredOutput4Model.doGenerate({
45434675
prompt: [
45444676
{
45454677
role: 'user',
@@ -4564,20 +4696,9 @@ describe('doGenerate', () => {
45644696
expect(requestBody.toolConfig.tools).toHaveLength(1);
45654697
expect(requestBody.toolConfig.tools[0].toolSpec.name).toBe('json');
45664698
expect(requestBody.toolConfig.toolChoice).toEqual({ any: {} });
4567-
45684699
expect(
45694700
requestBody.additionalModelRequestFields?.output_config?.format,
45704701
).toBeUndefined();
4571-
4572-
expect(result.content).toMatchInlineSnapshot(`
4573-
[
4574-
{
4575-
"text": "{"name":"Test"}",
4576-
"type": "text",
4577-
},
4578-
]
4579-
`);
4580-
expect(result.providerMetadata?.bedrock?.isJsonResponseFromTool).toBe(true);
45814702
});
45824703

45834704
it('should extract reasoning text with signature', async () => {

packages/amazon-bedrock/src/bedrock-chat-language-model.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export class BedrockChatLanguageModel implements LanguageModelV4 {
159159

160160
const useNativeStructuredOutput =
161161
isAnthropicModel &&
162-
isThinkingEnabled &&
162+
supportsAnthropicNativeStructuredOutput(this.modelId) &&
163163
responseFormat?.type === 'json' &&
164164
responseFormat.schema != null;
165165

@@ -987,6 +987,16 @@ export class BedrockChatLanguageModel implements LanguageModelV4 {
987987
}
988988
}
989989

990+
function supportsAnthropicNativeStructuredOutput(modelId: string): boolean {
991+
// only version 4+ supports structured output
992+
const v = modelId.indexOf('-4-');
993+
if (v < 0) return false;
994+
995+
// versions before 4-1 don't suport structured outputs yet
996+
// reject bedrock style claude-opus-4-20250514-v1:0
997+
return modelId[v + 3] !== '-' && modelId[v + 4] === '-';
998+
}
999+
9901000
const BedrockStopReasonSchema = z.union([
9911001
z.enum(BEDROCK_STOP_REASONS),
9921002
z.string(),

0 commit comments

Comments
 (0)