[Platform][Bedrock] Support structured output for ClaudeModelClient

This commit is contained in:
asrar
2026-02-06 01:26:07 +01:00
committed by Oskar Stark
parent 46f8372cca
commit 454b10a0c1
11 changed files with 154 additions and 5 deletions

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
use Symfony\AI\Platform\Bridge\Bedrock\PlatformFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber;
use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\MathReasoning;
use Symfony\Component\EventDispatcher\EventDispatcher;
require_once dirname(__DIR__).'/bootstrap.php';
if (!isset($_SERVER['AWS_ACCESS_KEY_ID'], $_SERVER['AWS_SECRET_ACCESS_KEY'], $_SERVER['AWS_DEFAULT_REGION'])
) {
echo 'Please set the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION environment variables.'.\PHP_EOL;
exit(1);
}
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new PlatformSubscriber());
$platform = PlatformFactory::create(eventDispatcher: $dispatcher);
$messages = new MessageBag(
Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'),
Message::ofUser('how can I solve 8x + 7 = -23'),
);
$result = $platform->invoke('claude-haiku-4-5-20251001', $messages, ['response_format' => MathReasoning::class]);
dump($result->asObject());

View File

@@ -1,6 +1,11 @@
CHANGELOG
=========
0.6
---
* Add structured output support
0.4
---

View File

@@ -174,6 +174,7 @@ final class ModelCatalog extends AbstractModelCatalog
Capability::INPUT_IMAGE,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STREAMING,
Capability::OUTPUT_STRUCTURED,
Capability::THINKING,
Capability::TOOL_CALLING,
],

View File

@@ -52,10 +52,11 @@ final class ModelClient implements ModelClientInterface
}
if (isset($options['response_format'])) {
$options['beta_features'][] = 'structured-outputs-2025-11-13';
$options['output_format'] = [
'type' => 'json_schema',
'schema' => $options['response_format']['json_schema']['schema'] ?? [],
$options['output_config'] = [
'format' => [
'type' => 'json_schema',
'schema' => $options['response_format']['json_schema']['schema'] ?? [],
],
];
unset($options['response_format']);
}

View File

@@ -37,7 +37,7 @@ final class ModelCatalogTest extends ModelCatalogTestCase
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]];
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]];
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]];
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]];
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]];
}
protected function createModelCatalog(): ModelCatalogInterface

View File

@@ -140,6 +140,41 @@ class ModelClientTest extends TestCase
$this->modelClient->request($this->model, ['message' => 'test'], $options);
}
public function testTransformsResponseFormatToOutputConfig()
{
$this->httpClient = new MockHttpClient(function ($method, $url, $options) {
$headers = $this->parseHeaders($options['headers']);
$this->assertArrayNotHasKey('anthropic-beta', $headers);
$body = json_decode($options['body'], true);
$this->assertArrayHasKey('output_config', $body);
$this->assertArrayHasKey('format', $body['output_config']);
$this->assertSame('json_schema', $body['output_config']['format']['type']);
$this->assertSame(['type' => 'object', 'properties' => ['foo' => ['type' => 'string']]], $body['output_config']['format']['schema']);
$this->assertArrayNotHasKey('response_format', $body);
return new JsonMockResponse('{"success": true}');
});
$this->modelClient = new ModelClient($this->httpClient, 'test-api-key');
$options = [
'response_format' => [
'json_schema' => [
'schema' => [
'type' => 'object',
'properties' => [
'foo' => ['type' => 'string'],
],
],
],
],
];
$this->modelClient->request($this->model, ['message' => 'test'], $options);
}
/**
* @param list<string> $headers
*

View File

@@ -42,6 +42,16 @@ final class ClaudeModelClient implements ModelClientInterface
$options['tool_choice'] = ['type' => 'auto'];
}
if (isset($options['response_format'])) {
$options['output_config'] = [
'format' => [
'type' => 'json_schema',
'schema' => $options['response_format']['json_schema']['schema'] ?? [],
],
];
unset($options['response_format']);
}
if (!isset($options['anthropic_version'])) {
$options['anthropic_version'] = 'bedrock-'.$this->version;
}

View File

@@ -1,6 +1,11 @@
CHANGELOG
=========
0.6
---
* Add structured output support
0.1
---

View File

@@ -190,6 +190,7 @@ final class ModelCatalog extends AbstractModelCatalog
Capability::INPUT_IMAGE,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STREAMING,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
@@ -200,6 +201,18 @@ final class ModelCatalog extends AbstractModelCatalog
Capability::INPUT_IMAGE,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STREAMING,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
'claude-haiku-4-5-20251001' => [
'class' => Claude::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::INPUT_IMAGE,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STREAMING,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],

View File

@@ -129,4 +129,43 @@ final class ClaudeModelClientTest extends TestCase
$response = $this->modelClient->request($this->model, ['message' => 'test'], $options);
$this->assertInstanceOf(RawBedrockResult::class, $response);
}
public function testTransformsResponseFormatToOutputConfig()
{
$this->bedrockClient->expects($this->once())
->method('invokeModel')
->with($this->callback(function ($arg) {
$this->assertInstanceOf(InvokeModelRequest::class, $arg);
$this->assertSame('application/json', $arg->getContentType());
$this->assertTrue(json_validate($arg->getBody()));
$body = json_decode($arg->getBody(), true);
$this->assertArrayHasKey('output_config', $body);
$this->assertArrayHasKey('format', $body['output_config']);
$this->assertSame('json_schema', $body['output_config']['format']['type']);
$this->assertSame(['type' => 'object', 'properties' => ['foo' => ['type' => 'string']]], $body['output_config']['format']['schema']);
$this->assertArrayNotHasKey('response_format', $body);
return true;
}))
->willReturn($this->createMock(InvokeModelResponse::class));
$this->modelClient = new ClaudeModelClient($this->bedrockClient, self::VERSION);
$options = [
'response_format' => [
'json_schema' => [
'schema' => [
'type' => 'object',
'properties' => [
'foo' => ['type' => 'string'],
],
],
],
],
];
$response = $this->modelClient->request($this->model, ['message' => 'test'], $options);
$this->assertInstanceOf(RawBedrockResult::class, $response);
}
}

View File

@@ -30,6 +30,9 @@ final class ModelCatalogTest extends ModelCatalogTestCase
yield 'nova-pro' => ['nova-pro', Nova::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
yield 'nova-premier' => ['nova-premier', Nova::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING, Capability::INPUT_IMAGE]];
yield 'claude-3-7-sonnet-20250219' => ['claude-3-7-sonnet-20250219', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]];
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::TOOL_CALLING]];
yield 'claude-opus-4-5-20251101' => ['claude-opus-4-5-20251101', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
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::TOOL_CALLING]];
}
protected function createModelCatalog(): ModelCatalogInterface