[Platform] Split bridges into dedicated packages

This commit is contained in:
Oskar Stark
2025-12-09 21:32:18 +01:00
parent b6b45f8b04
commit fb15363452
11 changed files with 666 additions and 0 deletions

12
README.md Normal file
View File

@@ -0,0 +1,12 @@
Anthropic Platform
==================
Anthropic (Claude) platform bridge for Symfony AI.
Resources
---------
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/ai/issues) and
[send Pull Requests](https://github.com/symfony/ai/pulls)
in the [main Symfony AI repository](https://github.com/symfony/ai)

45
Tests/ClaudeTest.php Normal file
View File

@@ -0,0 +1,45 @@
<?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.
*/
namespace Symfony\AI\Platform\Bridge\Anthropic\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\Anthropic\Claude;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
final class ClaudeTest extends TestCase
{
public function testItCreatesClaudeWithDefaultSettings()
{
$claude = new Claude('claude-3-5-sonnet-latest');
$this->assertSame('claude-3-5-sonnet-latest', $claude->getName());
$this->assertSame(['max_tokens' => 1000], $claude->getOptions());
}
public function testItCreatesClaudeWithCustomSettingsIncludingMaxTokens()
{
$claude = new Claude('claude-3-5-sonnet-latest', [], ['temperature' => 0.5, 'max_tokens' => 2000]);
$this->assertSame('claude-3-5-sonnet-latest', $claude->getName());
$this->assertSame(['temperature' => 0.5, 'max_tokens' => 2000], $claude->getOptions());
}
public function testItCreatesClaudeWithCustomSettingsWithoutMaxTokens()
{
$claude = new Claude('claude-3-5-sonnet-latest', [], ['temperature' => 0.5]);
$this->assertSame('claude-3-5-sonnet-latest', $claude->getName());
$this->assertSame(['temperature' => 0.5, 'max_tokens' => 1000], $claude->getOptions());
}
}

View File

@@ -0,0 +1,103 @@
<?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.
*/
namespace Symfony\AI\Platform\Bridge\Anthropic\Tests\Contract;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\Anthropic\Claude;
use Symfony\AI\Platform\Bridge\Anthropic\Contract\AssistantMessageNormalizer;
use Symfony\AI\Platform\Contract;
use Symfony\AI\Platform\Message\AssistantMessage;
use Symfony\AI\Platform\Result\ToolCall;
final class AssistantMessageNormalizerTest extends TestCase
{
public function testSupportsNormalization()
{
$normalizer = new AssistantMessageNormalizer();
$this->assertTrue($normalizer->supportsNormalization(new AssistantMessage('Hello'), context: [
Contract::CONTEXT_MODEL => new Claude('claude-3-5-sonnet-latest'),
]));
$this->assertFalse($normalizer->supportsNormalization('not an assistant message'));
}
public function testGetSupportedTypes()
{
$normalizer = new AssistantMessageNormalizer();
$this->assertSame([AssistantMessage::class => true], $normalizer->getSupportedTypes(null));
}
#[DataProvider('normalizeDataProvider')]
public function testNormalize(AssistantMessage $message, array $expectedOutput)
{
$normalizer = new AssistantMessageNormalizer();
$normalized = $normalizer->normalize($message);
$this->assertEquals($expectedOutput, $normalized);
}
/**
* @return iterable<string, array{
* 0: AssistantMessage,
* 1: array{
* role: 'assistant',
* content: string|list<array{
* type: 'tool_use',
* id: string,
* name: string,
* input: array<string, mixed>|\stdClass
* }>
* }
* }>
*/
public static function normalizeDataProvider(): iterable
{
yield 'assistant message' => [
new AssistantMessage('Great to meet you. What would you like to know?'),
[
'role' => 'assistant',
'content' => 'Great to meet you. What would you like to know?',
],
];
yield 'function call' => [
new AssistantMessage(toolCalls: [new ToolCall('id1', 'name1', ['arg1' => '123'])]),
[
'role' => 'assistant',
'content' => [
[
'type' => 'tool_use',
'id' => 'id1',
'name' => 'name1',
'input' => ['arg1' => '123'],
],
],
],
];
yield 'function call without parameters' => [
new AssistantMessage(toolCalls: [new ToolCall('id1', 'name1')]),
[
'role' => 'assistant',
'content' => [
[
'type' => 'tool_use',
'id' => 'id1',
'name' => 'name1',
'input' => new \stdClass(),
],
],
],
];
}
}

View File

@@ -0,0 +1,47 @@
<?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.
*/
namespace Symfony\AI\Platform\Bridge\Anthropic\Tests;
use Symfony\AI\Platform\Bridge\Anthropic\Claude;
use Symfony\AI\Platform\Bridge\Anthropic\ModelCatalog;
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
use Symfony\AI\Platform\Test\ModelCatalogTestCase;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
final class ModelCatalogTest extends ModelCatalogTestCase
{
public static function modelsProvider(): iterable
{
yield 'claude-3-haiku-20240307' => ['claude-3-haiku-20240307', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]];
yield 'claude-3-opus-20240229' => ['claude-3-opus-20240229', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]];
yield 'claude-3-5-haiku-latest' => ['claude-3-5-haiku-latest', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]];
yield 'claude-3-5-haiku-20241022' => ['claude-3-5-haiku-latest', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]];
yield 'claude-3-7-sonnet-latest' => ['claude-3-7-sonnet-latest', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]];
yield 'claude-3-7-sonnet-20250219' => ['claude-3-7-sonnet-latest', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]];
yield 'claude-sonnet-4-20250514' => ['claude-sonnet-4-20250514', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]];
yield 'claude-sonnet-4-0' => ['claude-sonnet-4-0', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]];
yield 'claude-opus-4-20250514' => ['claude-opus-4-20250514', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]];
yield 'claude-opus-4-0' => ['claude-opus-4-0', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]];
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::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::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-haiku-4-5-20251001' => ['claude-haiku-4-5-20251001', Claude::class, [Capability::INPUT_MESSAGES, Capability::INPUT_IMAGE, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]];
}
protected function createModelCatalog(): ModelCatalogInterface
{
return new ModelCatalog();
}
}

117
Tests/ModelClientTest.php Normal file
View File

@@ -0,0 +1,117 @@
<?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.
*/
namespace Symfony\AI\Platform\Bridge\Anthropic\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\Anthropic\Claude;
use Symfony\AI\Platform\Bridge\Anthropic\ModelClient;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
class ModelClientTest extends TestCase
{
private MockHttpClient $httpClient;
private ModelClient $modelClient;
private Claude $model;
protected function setUp(): void
{
$this->model = new Claude('claude-3-5-sonnet-latest');
}
public function testAnthropicBetaHeaderIsSetWithSingleBetaFeature()
{
$this->httpClient = new MockHttpClient(function ($method, $url, $options) {
$this->assertEquals('POST', $method);
$this->assertEquals('https://api.anthropic.com/v1/messages', $url);
$headers = $this->parseHeaders($options['headers']);
$this->assertArrayHasKey('anthropic-beta', $headers);
$this->assertEquals('feature-1', $headers['anthropic-beta']);
return new JsonMockResponse('{"success": true}');
});
$this->modelClient = new ModelClient($this->httpClient, 'test-api-key');
$options = ['beta_features' => ['feature-1']];
$this->modelClient->request($this->model, ['message' => 'test'], $options);
}
public function testAnthropicBetaHeaderIsSetWithMultipleBetaFeatures()
{
$this->httpClient = new MockHttpClient(function ($method, $url, $options) {
$headers = $this->parseHeaders($options['headers']);
$this->assertArrayHasKey('anthropic-beta', $headers);
$this->assertEquals('feature-1,feature-2,feature-3', $headers['anthropic-beta']);
return new JsonMockResponse('{"success": true}');
});
$this->modelClient = new ModelClient($this->httpClient, 'test-api-key');
$options = ['beta_features' => ['feature-1', 'feature-2', 'feature-3']];
$this->modelClient->request($this->model, ['message' => 'test'], $options);
}
public function testAnthropicBetaHeaderIsNotSetWhenBetaFeaturesIsEmpty()
{
$this->httpClient = new MockHttpClient(function ($method, $url, $options) {
$headers = $this->parseHeaders($options['headers']);
$this->assertArrayNotHasKey('anthropic-beta', $headers);
return new JsonMockResponse('{"success": true}');
});
$this->modelClient = new ModelClient($this->httpClient, 'test-api-key');
$options = ['beta_features' => []];
$this->modelClient->request($this->model, ['message' => 'test'], $options);
}
public function testAnthropicBetaHeaderIsNotSetWhenBetaFeaturesIsNotProvided()
{
$this->httpClient = new MockHttpClient(function ($method, $url, $options) {
$headers = $this->parseHeaders($options['headers']);
$this->assertArrayNotHasKey('anthropic-beta', $headers);
return new JsonMockResponse('{"success": true}');
});
$this->modelClient = new ModelClient($this->httpClient, 'test-api-key');
$options = ['some_other_option' => 'value'];
$this->modelClient->request($this->model, ['message' => 'test'], $options);
}
/**
* @param list<string> $headers
*
* @return array<string, string>
*/
private function parseHeaders(array $headers): array
{
$parsed = [];
foreach ($headers as $header) {
if (str_contains($header, ':')) {
[$key, $value] = explode(':', $header, 2);
$parsed[trim($key)] = trim($value);
}
}
return $parsed;
}
}

View File

@@ -0,0 +1,69 @@
<?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.
*/
namespace Symfony\AI\Platform\Bridge\Anthropic\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\Anthropic\ResultConverter;
use Symfony\AI\Platform\Exception\RateLimitExceededException;
use Symfony\AI\Platform\Result\RawHttpResult;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class ResultConverterRateLimitTest extends TestCase
{
public function testRateLimitExceededThrowsException()
{
$httpClient = new MockHttpClient([
new MockResponse('{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed the rate limit for your organization"}}', [
'http_code' => 429,
'response_headers' => [
'retry-after' => '60',
],
]),
]);
$httpResponse = $httpClient->request('POST', 'https://api.anthropic.com/v1/messages');
$handler = new ResultConverter();
$this->expectException(RateLimitExceededException::class);
$this->expectExceptionMessage('Rate limit exceeded');
try {
$handler->convert(new RawHttpResult($httpResponse));
} catch (RateLimitExceededException $e) {
$this->assertSame(60, $e->getRetryAfter());
throw $e;
}
}
public function testRateLimitExceededWithoutRetryAfter()
{
$httpClient = new MockHttpClient([
new MockResponse('{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed the rate limit for your organization"}}', [
'http_code' => 429,
]),
]);
$httpResponse = $httpClient->request('POST', 'https://api.anthropic.com/v1/messages');
$handler = new ResultConverter();
$this->expectException(RateLimitExceededException::class);
$this->expectExceptionMessage('Rate limit exceeded');
try {
$handler->convert(new RawHttpResult($httpResponse));
} catch (RateLimitExceededException $e) {
$this->assertNull($e->getRetryAfter());
throw $e;
}
}
}

View File

@@ -0,0 +1,77 @@
<?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.
*/
namespace Symfony\AI\Platform\Bridge\Anthropic\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\Anthropic\ResultConverter;
use Symfony\AI\Platform\Exception\RuntimeException;
use Symfony\AI\Platform\Result\RawHttpResult;
use Symfony\AI\Platform\Result\ToolCallResult;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
use Symfony\Component\HttpClient\Response\MockResponse;
final class ResultConverterTest extends TestCase
{
public function testConvertThrowsExceptionWhenContentIsToolUseAndLacksText()
{
$httpClient = new MockHttpClient(new JsonMockResponse([
'content' => [
[
'type' => 'tool_use',
'id' => 'toolu_01UM4PcTjC1UDiorSXVHSVFM',
'name' => 'xxx_tool',
'input' => ['action' => 'get_data'],
],
],
]));
$httpResponse = $httpClient->request('POST', 'https://api.anthropic.com/v1/messages');
$handler = new ResultConverter();
$result = $handler->convert(new RawHttpResult($httpResponse));
$this->assertInstanceOf(ToolCallResult::class, $result);
$this->assertCount(1, $result->getContent());
$this->assertSame('toolu_01UM4PcTjC1UDiorSXVHSVFM', $result->getContent()[0]->getId());
$this->assertSame('xxx_tool', $result->getContent()[0]->getName());
$this->assertSame(['action' => 'get_data'], $result->getContent()[0]->getArguments());
}
public function testModelNotFoundError()
{
$httpClient = new MockHttpClient([
new MockResponse('{"type":"error","error":{"type":"not_found_error","message":"model: claude-3-5-sonnet-20241022"}}'),
]);
$response = $httpClient->request('POST', 'https://api.anthropic.com/v1/messages');
$converter = new ResultConverter();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('API Error [not_found_error]: "model: claude-3-5-sonnet-20241022"');
$converter->convert(new RawHttpResult($response));
}
public function testUnknownError()
{
$httpClient = new MockHttpClient([
new MockResponse('{"type":"error"}'),
]);
$response = $httpClient->request('POST', 'https://api.anthropic.com/v1/messages');
$converter = new ResultConverter();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('API Error [Unknown]: "An unknown error occurred."');
$converter->convert(new RawHttpResult($response));
}
}

View File

@@ -0,0 +1,80 @@
<?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.
*/
namespace Bridge\Anthropic;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\Anthropic\TokenUsageExtractor;
use Symfony\AI\Platform\Result\InMemoryRawResult;
use Symfony\AI\Platform\TokenUsage\TokenUsage;
final class TokenUsageExtractorTest extends TestCase
{
public function testItHandlesStreamResponsesWithoutProcessing()
{
$extractor = new TokenUsageExtractor();
$this->assertNull($extractor->extract(new InMemoryRawResult(), ['stream' => true]));
}
public function testItDoesNothingWithoutUsageData()
{
$extractor = new TokenUsageExtractor();
$this->assertNull($extractor->extract(new InMemoryRawResult(['some' => 'data'])));
}
public function testItExtractsTokenUsage()
{
$extractor = new TokenUsageExtractor();
$result = new InMemoryRawResult([
'usage' => [
'input_tokens' => 10,
'output_tokens' => 20,
'server_tool_use' => [
'web_search_requests' => 30,
],
'cache_creation_input_tokens' => 40,
'cache_read_input_tokens' => 50,
],
]);
$tokenUsage = $extractor->extract($result);
$this->assertInstanceOf(TokenUsage::class, $tokenUsage);
$this->assertSame(10, $tokenUsage->getPromptTokens());
$this->assertSame(30, $tokenUsage->getToolTokens());
$this->assertSame(20, $tokenUsage->getCompletionTokens());
$this->assertNull($tokenUsage->getRemainingTokens());
$this->assertNull($tokenUsage->getThinkingTokens());
$this->assertSame(90, $tokenUsage->getCachedTokens());
$this->assertNull($tokenUsage->getTotalTokens());
}
public function testItHandlesMissingUsageFields()
{
$extractor = new TokenUsageExtractor();
$result = new InMemoryRawResult([
'usage' => [
// Missing some fields
'input_tokens' => 10,
],
]);
$tokenUsage = $extractor->extract($result);
$this->assertInstanceOf(TokenUsage::class, $tokenUsage);
$this->assertSame(10, $tokenUsage->getPromptTokens());
$this->assertNull($tokenUsage->getRemainingTokens());
$this->assertNull($tokenUsage->getCompletionTokens());
$this->assertNull($tokenUsage->getTotalTokens());
}
}

61
composer.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "symfony/ai-anthropic-platform",
"description": "Anthropic (Claude) platform bridge for Symfony AI",
"license": "MIT",
"type": "symfony-ai-platform",
"keywords": [
"ai",
"anthropic",
"bridge",
"claude",
"platform"
],
"authors": [
{
"name": "Christopher Hertel",
"email": "mail@christopher-hertel.de"
},
{
"name": "Oskar Stark",
"email": "oskarstark@googlemail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=8.2",
"symfony/ai-platform": "@dev",
"symfony/http-client": "^7.3|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.46"
},
"minimum-stability": "dev",
"autoload": {
"psr-4": {
"Symfony\\AI\\Platform\\Bridge\\Anthropic\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Symfony\\AI\\PHPStan\\": "../../../../../.phpstan/",
"Symfony\\AI\\Platform\\Bridge\\Anthropic\\Tests\\": "Tests/"
}
},
"config": {
"sort-packages": true
},
"extra": {
"branch-alias": {
"dev-main": "0.x-dev"
},
"thanks": {
"name": "symfony/ai",
"url": "https://github.com/symfony/ai"
}
}
}

23
phpstan.dist.neon Normal file
View File

@@ -0,0 +1,23 @@
includes:
- ../../../../../.phpstan/extension.neon
parameters:
level: 6
paths:
- .
- Tests/
excludePaths:
- vendor/
treatPhpDocTypesAsCertain: false
ignoreErrors:
-
message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#"
reportUnmatched: false
-
identifier: missingType.iterableValue
path: Tests/*
reportUnmatched: false
-
identifier: 'symfonyAi.forbidNativeException'
path: Tests/*
reportUnmatched: false

32
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
failOnDeprecation="true"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="error_reporting" value="-1" />
</php>
<testsuites>
<testsuite name="Symfony AI Anthropic Platform Test Suite">
<directory>./Tests/</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true">
<include>
<directory>./</directory>
</include>
<exclude>
<directory>./Resources</directory>
<directory>./Tests</directory>
<directory>./vendor</directory>
</exclude>
</source>
</phpunit>