Skip to content

Commit 070c478

Browse files
committed
feature #1556 [Platform][Bedrock] Support structured output for ClaudeModelClient (aszenz)
This PR was squashed before being merged into the main branch. Discussion ---------- [Platform][Bedrock] Support structured output for `ClaudeModelClient` | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Docs? | no | Issues | Fix #1552 | License | MIT This PR adds structured output support for AWS Bedrock Claude 4.5 models and adds the `claude-haiku-4-5-20251001` model to Bedrock catalog plus structured output for it in Anthropic catalog. Verification: - Claude API structured outputs GA: https://platform.claude.com/docs/en/release-notes/overview#january-29-2026 - AWS Bedrock structured outputs GA: https://aws.amazon.com/about-aws/whats-new/2026/02/structured-outputs-available-amazon-bedrock/ Commits ------- 454b10a [Platform][Bedrock] Support structured output for `ClaudeModelClient`
2 parents 8175eeb + 454b10a commit 070c478

File tree

11 files changed

+154
-5
lines changed

11 files changed

+154
-5
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Platform\Bridge\Bedrock\PlatformFactory;
13+
use Symfony\AI\Platform\Message\Message;
14+
use Symfony\AI\Platform\Message\MessageBag;
15+
use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber;
16+
use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\MathReasoning;
17+
use Symfony\Component\EventDispatcher\EventDispatcher;
18+
19+
require_once dirname(__DIR__).'/bootstrap.php';
20+
21+
if (!isset($_SERVER['AWS_ACCESS_KEY_ID'], $_SERVER['AWS_SECRET_ACCESS_KEY'], $_SERVER['AWS_DEFAULT_REGION'])
22+
) {
23+
echo 'Please set the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION environment variables.'.\PHP_EOL;
24+
exit(1);
25+
}
26+
27+
$dispatcher = new EventDispatcher();
28+
$dispatcher->addSubscriber(new PlatformSubscriber());
29+
$platform = PlatformFactory::create(eventDispatcher: $dispatcher);
30+
31+
$messages = new MessageBag(
32+
Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'),
33+
Message::ofUser('how can I solve 8x + 7 = -23'),
34+
);
35+
$result = $platform->invoke('claude-haiku-4-5-20251001', $messages, ['response_format' => MathReasoning::class]);
36+
37+
dump($result->asObject());

src/platform/src/Bridge/Anthropic/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
0.6
5+
---
6+
7+
* Add structured output support
8+
49
0.4
510
---
611

src/platform/src/Bridge/Anthropic/ModelCatalog.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ public function __construct(array $additionalModels = [])
174174
Capability::INPUT_IMAGE,
175175
Capability::OUTPUT_TEXT,
176176
Capability::OUTPUT_STREAMING,
177+
Capability::OUTPUT_STRUCTURED,
177178
Capability::THINKING,
178179
Capability::TOOL_CALLING,
179180
],

src/platform/src/Bridge/Anthropic/ModelClient.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,11 @@ public function request(Model $model, array|string $payload, array $options = []
5252
}
5353

5454
if (isset($options['response_format'])) {
55-
$options['beta_features'][] = 'structured-outputs-2025-11-13';
56-
$options['output_format'] = [
57-
'type' => 'json_schema',
58-
'schema' => $options['response_format']['json_schema']['schema'] ?? [],
55+
$options['output_config'] = [
56+
'format' => [
57+
'type' => 'json_schema',
58+
'schema' => $options['response_format']['json_schema']['schema'] ?? [],
59+
],
5960
];
6061
unset($options['response_format']);
6162
}

src/platform/src/Bridge/Anthropic/Tests/ModelCatalogTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public static function modelsProvider(): iterable
3737
yield 'claude-opus-4-1' => ['claude-opus-4-1', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::OUTPUT_STRUCTURED, Capability::THINKING, Capability::TOOL_CALLING]];
3838
yield 'claude-opus-4-1-20250805' => ['claude-opus-4-1-20250805', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::OUTPUT_STRUCTURED, Capability::THINKING, Capability::TOOL_CALLING]];
3939
yield 'claude-sonnet-4-5-20250929' => ['claude-sonnet-4-5-20250929', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::OUTPUT_STRUCTURED, Capability::THINKING, Capability::TOOL_CALLING]];
40-
yield 'claude-haiku-4-5-20251001' => ['claude-haiku-4-5-20251001', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::THINKING, Capability::TOOL_CALLING]];
40+
yield 'claude-haiku-4-5-20251001' => ['claude-haiku-4-5-20251001', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::OUTPUT_STRUCTURED, Capability::THINKING, Capability::TOOL_CALLING]];
4141
}
4242

4343
protected function createModelCatalog(): ModelCatalogInterface

src/platform/src/Bridge/Anthropic/Tests/ModelClientTest.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,41 @@ public function testThinkingBetaHeaderCombinesWithOtherBetaFeatures()
140140
$this->modelClient->request($this->model, ['message' => 'test'], $options);
141141
}
142142

143+
public function testTransformsResponseFormatToOutputConfig()
144+
{
145+
$this->httpClient = new MockHttpClient(function ($method, $url, $options) {
146+
$headers = $this->parseHeaders($options['headers']);
147+
148+
$this->assertArrayNotHasKey('anthropic-beta', $headers);
149+
150+
$body = json_decode($options['body'], true);
151+
$this->assertArrayHasKey('output_config', $body);
152+
$this->assertArrayHasKey('format', $body['output_config']);
153+
$this->assertSame('json_schema', $body['output_config']['format']['type']);
154+
$this->assertSame(['type' => 'object', 'properties' => ['foo' => ['type' => 'string']]], $body['output_config']['format']['schema']);
155+
$this->assertArrayNotHasKey('response_format', $body);
156+
157+
return new JsonMockResponse('{"success": true}');
158+
});
159+
160+
$this->modelClient = new ModelClient($this->httpClient, 'test-api-key');
161+
162+
$options = [
163+
'response_format' => [
164+
'json_schema' => [
165+
'schema' => [
166+
'type' => 'object',
167+
'properties' => [
168+
'foo' => ['type' => 'string'],
169+
],
170+
],
171+
],
172+
],
173+
];
174+
175+
$this->modelClient->request($this->model, ['message' => 'test'], $options);
176+
}
177+
143178
/**
144179
* @param list<string> $headers
145180
*

src/platform/src/Bridge/Bedrock/Anthropic/ClaudeModelClient.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ public function request(Model $model, array|string $payload, array $options = []
4242
$options['tool_choice'] = ['type' => 'auto'];
4343
}
4444

45+
if (isset($options['response_format'])) {
46+
$options['output_config'] = [
47+
'format' => [
48+
'type' => 'json_schema',
49+
'schema' => $options['response_format']['json_schema']['schema'] ?? [],
50+
],
51+
];
52+
unset($options['response_format']);
53+
}
54+
4555
if (!isset($options['anthropic_version'])) {
4656
$options['anthropic_version'] = 'bedrock-'.$this->version;
4757
}

src/platform/src/Bridge/Bedrock/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
0.6
5+
---
6+
7+
* Add structured output support
8+
49
0.1
510
---
611

src/platform/src/Bridge/Bedrock/ModelCatalog.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ public function __construct(array $additionalModels = [])
190190
Capability::INPUT_IMAGE,
191191
Capability::OUTPUT_TEXT,
192192
Capability::OUTPUT_STREAMING,
193+
Capability::OUTPUT_STRUCTURED,
193194
Capability::TOOL_CALLING,
194195
],
195196
],
@@ -200,6 +201,18 @@ public function __construct(array $additionalModels = [])
200201
Capability::INPUT_IMAGE,
201202
Capability::OUTPUT_TEXT,
202203
Capability::OUTPUT_STREAMING,
204+
Capability::OUTPUT_STRUCTURED,
205+
Capability::TOOL_CALLING,
206+
],
207+
],
208+
'claude-haiku-4-5-20251001' => [
209+
'class' => Claude::class,
210+
'capabilities' => [
211+
Capability::INPUT_MESSAGES,
212+
Capability::INPUT_IMAGE,
213+
Capability::OUTPUT_TEXT,
214+
Capability::OUTPUT_STREAMING,
215+
Capability::OUTPUT_STRUCTURED,
203216
Capability::TOOL_CALLING,
204217
],
205218
],

src/platform/src/Bridge/Bedrock/Tests/Anthropic/ClaudeModelClientTest.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,43 @@ public function testSetsToolOptionsIfToolsEnabled()
129129
$response = $this->modelClient->request($this->model, ['message' => 'test'], $options);
130130
$this->assertInstanceOf(RawBedrockResult::class, $response);
131131
}
132+
133+
public function testTransformsResponseFormatToOutputConfig()
134+
{
135+
$this->bedrockClient->expects($this->once())
136+
->method('invokeModel')
137+
->with($this->callback(function ($arg) {
138+
$this->assertInstanceOf(InvokeModelRequest::class, $arg);
139+
$this->assertSame('application/json', $arg->getContentType());
140+
$this->assertTrue(json_validate($arg->getBody()));
141+
142+
$body = json_decode($arg->getBody(), true);
143+
$this->assertArrayHasKey('output_config', $body);
144+
$this->assertArrayHasKey('format', $body['output_config']);
145+
$this->assertSame('json_schema', $body['output_config']['format']['type']);
146+
$this->assertSame(['type' => 'object', 'properties' => ['foo' => ['type' => 'string']]], $body['output_config']['format']['schema']);
147+
$this->assertArrayNotHasKey('response_format', $body);
148+
149+
return true;
150+
}))
151+
->willReturn($this->createMock(InvokeModelResponse::class));
152+
153+
$this->modelClient = new ClaudeModelClient($this->bedrockClient, self::VERSION);
154+
155+
$options = [
156+
'response_format' => [
157+
'json_schema' => [
158+
'schema' => [
159+
'type' => 'object',
160+
'properties' => [
161+
'foo' => ['type' => 'string'],
162+
],
163+
],
164+
],
165+
],
166+
];
167+
168+
$response = $this->modelClient->request($this->model, ['message' => 'test'], $options);
169+
$this->assertInstanceOf(RawBedrockResult::class, $response);
170+
}
132171
}

0 commit comments

Comments
 (0)