mirror of
https://github.com/symfony/ai-anthropic-platform.git
synced 2026-03-23 23:32:07 +01:00
[Platform] Split bridges into dedicated packages
This commit is contained in:
12
README.md
Normal file
12
README.md
Normal 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
45
Tests/ClaudeTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
103
Tests/Contract/AssistantMessageNormalizerTest.php
Normal file
103
Tests/Contract/AssistantMessageNormalizerTest.php
Normal 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(),
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
47
Tests/ModelCatalogTest.php
Normal file
47
Tests/ModelCatalogTest.php
Normal 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
117
Tests/ModelClientTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
69
Tests/ResultConverterRateLimitTest.php
Normal file
69
Tests/ResultConverterRateLimitTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
77
Tests/ResultConverterTest.php
Normal file
77
Tests/ResultConverterTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
80
Tests/TokenUsageExtractorTest.php
Normal file
80
Tests/TokenUsageExtractorTest.php
Normal 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
61
composer.json
Normal 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
23
phpstan.dist.neon
Normal 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
32
phpunit.xml.dist
Normal 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>
|
||||
Reference in New Issue
Block a user