From 1e2df898a5b89789d1e4dba54aadf94368d96dcf Mon Sep 17 00:00:00 2001 From: paul Date: Tue, 3 Mar 2026 11:59:19 +0100 Subject: [PATCH] [Platform][Azure] Replace Chat Completions client with Responses API client The Azure OpenAI platform previously used the Chat Completions API (`/openai/deployments/{deployment}/chat/completions`) via `CompletionsModel`. Azure now supports the unified Responses API (`/openai/v1/responses`), which is consistent with how the standard OpenAI bridge already works. - Remove `CompletionsModelClient` and replace with `Responses\ModelClient` that authenticates via `api-key` header and targets `/openai/v1/responses` - Update `ModelCatalog` to map `Gpt` models to `ResponsesModel` instead of `CompletionsModel`, enabling file input support and unified streaming - Update `PlatformFactory` to use the new `Responses\ModelClient` together with `OpenAiContract` (which includes `DocumentNormalizer`) and the `OpenResponses\ResultConverter` - Fix `DocumentNormalizer::supportsModel()` to use capability-based check (`Capability::INPUT_PDF`) instead of `$model instanceof Gpt`, so it works for any model that declares PDF support (including Azure's `ResponsesModel`) - Register `DocumentNormalizer` in `OpenAiContract::create()` so PDF documents are serialized correctly when sent via agents - Add `OpenResponsesPlatform` to `AzurePlatform` allowed deps in deptrac.yaml - Add `.codespellrc` to suppress false positive on `Vektor` (intentional name of the Vektor vector store bridge) Co-Authored-By: Claude Sonnet 4.6 --- .codespellrc | 2 + deptrac.yaml | 1 + src/platform/src/Bridge/Azure/CHANGELOG.md | 5 + .../src/Bridge/Azure/OpenAi/ModelCatalog.php | 4 +- .../Bridge/Azure/OpenAi/PlatformFactory.php | 11 ++- .../ModelClient.php} | 44 ++++----- .../OpenAi/CompletionsModelClientTest.php | 92 ------------------- .../Azure/Tests/Responses/ModelClientTest.php | 81 ++++++++++++++++ .../OpenAi/Contract/DocumentNormalizer.php | 7 +- .../Bridge/OpenAi/Contract/OpenAiContract.php | 1 + .../Tests/Contract/DocumentNormalizerTest.php | 4 + 11 files changed, 121 insertions(+), 131 deletions(-) create mode 100644 .codespellrc rename src/platform/src/Bridge/Azure/{OpenAi/CompletionsModelClient.php => Responses/ModelClient.php} (52%) delete mode 100644 src/platform/src/Bridge/Azure/Tests/OpenAi/CompletionsModelClientTest.php create mode 100644 src/platform/src/Bridge/Azure/Tests/Responses/ModelClientTest.php diff --git a/.codespellrc b/.codespellrc new file mode 100644 index 00000000..00ea392a --- /dev/null +++ b/.codespellrc @@ -0,0 +1,2 @@ +[codespell] +ignore-words-list = vektor diff --git a/deptrac.yaml b/deptrac.yaml index 60492010..06247ecd 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -425,6 +425,7 @@ deptrac: AzurePlatform: - PlatformComponent - OpenAiPlatform + - OpenResponsesPlatform - GenericPlatform - MetaPlatform BedrockPlatform: diff --git a/src/platform/src/Bridge/Azure/CHANGELOG.md b/src/platform/src/Bridge/Azure/CHANGELOG.md index c1e2dc65..da3fc293 100644 --- a/src/platform/src/Bridge/Azure/CHANGELOG.md +++ b/src/platform/src/Bridge/Azure/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +0.6 +--- + + * Switch to OpenResponses contract + 0.2 --- diff --git a/src/platform/src/Bridge/Azure/OpenAi/ModelCatalog.php b/src/platform/src/Bridge/Azure/OpenAi/ModelCatalog.php index f6c72a10..48a249ef 100644 --- a/src/platform/src/Bridge/Azure/OpenAi/ModelCatalog.php +++ b/src/platform/src/Bridge/Azure/OpenAi/ModelCatalog.php @@ -11,11 +11,11 @@ namespace Symfony\AI\Platform\Bridge\Azure\OpenAi; -use Symfony\AI\Platform\Bridge\Generic\CompletionsModel; use Symfony\AI\Platform\Bridge\Generic\EmbeddingsModel; use Symfony\AI\Platform\Bridge\OpenAi\Embeddings; use Symfony\AI\Platform\Bridge\OpenAi\Gpt; use Symfony\AI\Platform\Bridge\OpenAi\ModelCatalog as OpenAiModelCatalog; +use Symfony\AI\Platform\Bridge\OpenResponses\ResponsesModel; use Symfony\AI\Platform\Capability; use Symfony\AI\Platform\ModelCatalog\AbstractModelCatalog; @@ -34,7 +34,7 @@ final class ModelCatalog extends AbstractModelCatalog $defaultModels[$modelName] = $modelData; if (Gpt::class === $modelData['class']) { - $defaultModels[$modelName]['class'] = CompletionsModel::class; + $defaultModels[$modelName]['class'] = ResponsesModel::class; } if (Embeddings::class === $modelData['class']) { $defaultModels[$modelName]['class'] = EmbeddingsModel::class; diff --git a/src/platform/src/Bridge/Azure/OpenAi/PlatformFactory.php b/src/platform/src/Bridge/Azure/OpenAi/PlatformFactory.php index a4cbed1b..a749836e 100644 --- a/src/platform/src/Bridge/Azure/OpenAi/PlatformFactory.php +++ b/src/platform/src/Bridge/Azure/OpenAi/PlatformFactory.php @@ -12,10 +12,11 @@ namespace Symfony\AI\Platform\Bridge\Azure\OpenAi; use Psr\EventDispatcher\EventDispatcherInterface; -use Symfony\AI\Platform\Bridge\Generic\Completions; +use Symfony\AI\Platform\Bridge\Azure\Responses\ModelClient as ResponsesModelClient; use Symfony\AI\Platform\Bridge\Generic\Embeddings; +use Symfony\AI\Platform\Bridge\OpenAi\Contract\OpenAiContract; use Symfony\AI\Platform\Bridge\OpenAi\Whisper; -use Symfony\AI\Platform\Bridge\OpenAi\Whisper\AudioNormalizer; +use Symfony\AI\Platform\Bridge\OpenResponses\ResultConverter as ResponsesResultConverter; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; use Symfony\AI\Platform\Platform; @@ -41,13 +42,13 @@ final class PlatformFactory return new Platform( [ + new ResponsesModelClient($httpClient, $baseUrl, $apiKey), new EmbeddingsModelClient($httpClient, $baseUrl, $deployment, $apiVersion, $apiKey), - new CompletionsModelClient($httpClient, $baseUrl, $deployment, $apiVersion, $apiKey), new WhisperModelClient($httpClient, $baseUrl, $deployment, $apiVersion, $apiKey), ], - [new Completions\ResultConverter(), new Embeddings\ResultConverter(), new Whisper\ResultConverter()], + [new ResponsesResultConverter(), new Embeddings\ResultConverter(), new Whisper\ResultConverter()], $modelCatalog, - $contract ?? Contract::create(new AudioNormalizer()), + $contract ?? OpenAiContract::create(), $eventDispatcher, ); } diff --git a/src/platform/src/Bridge/Azure/OpenAi/CompletionsModelClient.php b/src/platform/src/Bridge/Azure/Responses/ModelClient.php similarity index 52% rename from src/platform/src/Bridge/Azure/OpenAi/CompletionsModelClient.php rename to src/platform/src/Bridge/Azure/Responses/ModelClient.php index 154fbb1d..c5afe439 100644 --- a/src/platform/src/Bridge/Azure/OpenAi/CompletionsModelClient.php +++ b/src/platform/src/Bridge/Azure/Responses/ModelClient.php @@ -9,9 +9,9 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Platform\Bridge\Azure\OpenAi; +namespace Symfony\AI\Platform\Bridge\Azure\Responses; -use Symfony\AI\Platform\Bridge\Generic\CompletionsModel; +use Symfony\AI\Platform\Bridge\OpenResponses\ResponsesModel; use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; @@ -22,54 +22,42 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; /** * @author Christopher Hertel */ -final class CompletionsModelClient implements ModelClientInterface +final class ModelClient implements ModelClientInterface { private readonly EventSourceHttpClient $httpClient; + private readonly string $endpoint; public function __construct( HttpClientInterface $httpClient, - private readonly string $baseUrl, - private readonly string $deployment, - private readonly string $apiVersion, + string $baseUrl, #[\SensitiveParameter] private readonly string $apiKey, ) { - $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); - if (str_starts_with($this->baseUrl, 'http://')) { + if ('' === $baseUrl) { + throw new InvalidArgumentException('The base URL must not be empty.'); + } + if (str_starts_with($baseUrl, 'http://') || str_starts_with($baseUrl, 'https://')) { throw new InvalidArgumentException('The base URL must not contain the protocol.'); } - if (str_starts_with($this->baseUrl, 'https://')) { - throw new InvalidArgumentException('The base URL must not contain the protocol.'); - } - if ('' === $deployment) { - throw new InvalidArgumentException('The deployment must not be empty.'); - } - if ('' === $apiVersion) { - throw new InvalidArgumentException('The API version must not be empty.'); - } if ('' === $apiKey) { throw new InvalidArgumentException('The API key must not be empty.'); } + + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + $this->endpoint = \sprintf('https://%s/openai/v1/responses', rtrim($baseUrl, '/')); } public function supports(Model $model): bool { - return $model instanceof CompletionsModel; + return $model instanceof ResponsesModel; } - public function request(Model $model, object|array|string $payload, array $options = []): RawHttpResult + public function request(Model $model, array|string $payload, array $options = []): RawHttpResult { - if (!\is_array($payload)) { - throw new InvalidArgumentException(\sprintf('Payload must be an array, but a %s was given to "%s".', get_debug_type($payload), self::class)); - } - - $url = \sprintf('https://%s/openai/deployments/%s/chat/completions', $this->baseUrl, $this->deployment); - - return new RawHttpResult($this->httpClient->request('POST', $url, [ + return new RawHttpResult($this->httpClient->request('POST', $this->endpoint, [ 'headers' => [ 'api-key' => $this->apiKey, ], - 'query' => ['api-version' => $this->apiVersion], - 'json' => array_merge($options, $payload), + 'json' => array_merge($options, ['model' => $model->getName()], $payload), ])); } } diff --git a/src/platform/src/Bridge/Azure/Tests/OpenAi/CompletionsModelClientTest.php b/src/platform/src/Bridge/Azure/Tests/OpenAi/CompletionsModelClientTest.php deleted file mode 100644 index e3f8dc68..00000000 --- a/src/platform/src/Bridge/Azure/Tests/OpenAi/CompletionsModelClientTest.php +++ /dev/null @@ -1,92 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\Platform\Bridge\Azure\Tests\OpenAi; - -use PHPUnit\Framework\Attributes\TestWith; -use PHPUnit\Framework\TestCase; -use Symfony\AI\Platform\Bridge\Azure\OpenAi\CompletionsModelClient; -use Symfony\AI\Platform\Bridge\Generic\CompletionsModel; -use Symfony\AI\Platform\Exception\InvalidArgumentException; -use Symfony\Component\HttpClient\MockHttpClient; -use Symfony\Component\HttpClient\Response\MockResponse; - -/** - * @author Oskar Stark - */ -final class CompletionsModelClientTest extends TestCase -{ - #[TestWith(['http://test.azure.com', 'The base URL must not contain the protocol.'])] - #[TestWith(['https://test.azure.com', 'The base URL must not contain the protocol.'])] - #[TestWith(['http://test.azure.com/openai', 'The base URL must not contain the protocol.'])] - #[TestWith(['https://test.azure.com:443', 'The base URL must not contain the protocol.'])] - public function testItThrowsExceptionWhenBaseUrlContainsProtocol(string $invalidUrl, string $expectedMessage) - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($expectedMessage); - - new CompletionsModelClient(new MockHttpClient(), $invalidUrl, 'deployment', 'api-version', 'api-key'); - } - - public function testItThrowsExceptionWhenDeploymentIsEmpty() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The deployment must not be empty.'); - - new CompletionsModelClient(new MockHttpClient(), 'test.azure.com', '', 'api-version', 'api-key'); - } - - public function testItThrowsExceptionWhenApiVersionIsEmpty() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The API version must not be empty.'); - - new CompletionsModelClient(new MockHttpClient(), 'test.azure.com', 'deployment', '', 'api-key'); - } - - public function testItThrowsExceptionWhenApiKeyIsEmpty() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The API key must not be empty.'); - - new CompletionsModelClient(new MockHttpClient(), 'test.azure.com', 'deployment', 'api-version', ''); - } - - public function testItAcceptsValidParameters() - { - $client = new CompletionsModelClient(new MockHttpClient(), 'test.azure.com', 'gpt-35-turbo', '2023-12-01-preview', 'valid-api-key'); - - $this->assertInstanceOf(CompletionsModelClient::class, $client); - } - - public function testItIsSupportingTheCorrectModel() - { - $client = new CompletionsModelClient(new MockHttpClient(), 'test.azure.com', 'deployment', '2023-12-01', 'api-key'); - - $this->assertTrue($client->supports(new CompletionsModel('gpt-4o'))); - } - - public function testItIsExecutingTheCorrectRequest() - { - $resultCallback = static function (string $method, string $url, array $options): MockResponse { - self::assertSame('POST', $method); - self::assertSame('https://test.azure.com/openai/deployments/gpt-deployment/chat/completions?api-version=2023-12-01', $url); - self::assertSame(['api-key: test-api-key'], $options['normalized_headers']['api-key']); - self::assertSame('{"messages":[{"role":"user","content":"Hello"}]}', $options['body']); - - return new MockResponse(); - }; - - $httpClient = new MockHttpClient([$resultCallback]); - $client = new CompletionsModelClient($httpClient, 'test.azure.com', 'gpt-deployment', '2023-12-01', 'test-api-key'); - $client->request(new CompletionsModel('gpt-4o'), ['messages' => [['role' => 'user', 'content' => 'Hello']]]); - } -} diff --git a/src/platform/src/Bridge/Azure/Tests/Responses/ModelClientTest.php b/src/platform/src/Bridge/Azure/Tests/Responses/ModelClientTest.php new file mode 100644 index 00000000..339143e0 --- /dev/null +++ b/src/platform/src/Bridge/Azure/Tests/Responses/ModelClientTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Azure\Tests\Responses; + +use PHPUnit\Framework\Attributes\TestWith; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Azure\Responses\ModelClient; +use Symfony\AI\Platform\Bridge\OpenResponses\ResponsesModel; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +final class ModelClientTest extends TestCase +{ + public function testItThrowsExceptionWhenBaseUrlIsEmpty() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The base URL must not be empty.'); + + new ModelClient(new MockHttpClient(), '', 'api-key'); + } + + #[TestWith(['http://test.openai.azure.com', 'The base URL must not contain the protocol.'])] + #[TestWith(['https://test.openai.azure.com', 'The base URL must not contain the protocol.'])] + #[TestWith(['http://test.openai.azure.com/openai', 'The base URL must not contain the protocol.'])] + #[TestWith(['https://test.openai.azure.com:443', 'The base URL must not contain the protocol.'])] + public function testItThrowsExceptionWhenBaseUrlContainsProtocol(string $invalidUrl, string $expectedMessage) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + + new ModelClient(new MockHttpClient(), $invalidUrl, 'api-key'); + } + + public function testItThrowsExceptionWhenApiKeyIsEmpty() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The API key must not be empty.'); + + new ModelClient(new MockHttpClient(), 'test.openai.azure.com', ''); + } + + public function testItAcceptsValidParameters() + { + $client = new ModelClient(new MockHttpClient(), 'test.openai.azure.com', 'valid-api-key'); + + $this->assertInstanceOf(ModelClient::class, $client); + } + + public function testItIsSupportingTheCorrectModel() + { + $client = new ModelClient(new MockHttpClient(), 'test.openai.azure.com', 'api-key'); + + $this->assertTrue($client->supports(new ResponsesModel('gpt-4o'))); + } + + public function testItIsExecutingTheCorrectRequest() + { + $resultCallback = static function (string $method, string $url, array $options): MockResponse { + self::assertSame('POST', $method); + self::assertSame('https://test.openai.azure.com/openai/v1/responses', $url); + self::assertSame(['api-key: test-api-key'], $options['normalized_headers']['api-key']); + self::assertSame('{"model":"gpt-4o","input":[{"role":"user","content":"Hello"}]}', $options['body']); + + return new MockResponse(); + }; + + $httpClient = new MockHttpClient([$resultCallback]); + $client = new ModelClient($httpClient, 'test.openai.azure.com', 'test-api-key'); + $client->request(new ResponsesModel('gpt-4o'), ['input' => [['role' => 'user', 'content' => 'Hello']]]); + } +} diff --git a/src/platform/src/Bridge/OpenAi/Contract/DocumentNormalizer.php b/src/platform/src/Bridge/OpenAi/Contract/DocumentNormalizer.php index b081e30b..c661e8d1 100644 --- a/src/platform/src/Bridge/OpenAi/Contract/DocumentNormalizer.php +++ b/src/platform/src/Bridge/OpenAi/Contract/DocumentNormalizer.php @@ -11,10 +11,9 @@ namespace Symfony\AI\Platform\Bridge\OpenAi\Contract; -use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Capability; use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; use Symfony\AI\Platform\Message\Content\Document; -use Symfony\AI\Platform\Message\Content\File; use Symfony\AI\Platform\Model; /** @@ -23,7 +22,7 @@ use Symfony\AI\Platform\Model; class DocumentNormalizer extends ModelContractNormalizer { /** - * @param File $data + * @param Document $data * * @return array{type: 'file', file: array{filename: string, file_data: string}} */ @@ -45,6 +44,6 @@ class DocumentNormalizer extends ModelContractNormalizer protected function supportsModel(Model $model): bool { - return $model instanceof Gpt; + return $model->supports(Capability::INPUT_PDF); } } diff --git a/src/platform/src/Bridge/OpenAi/Contract/OpenAiContract.php b/src/platform/src/Bridge/OpenAi/Contract/OpenAiContract.php index 203aafaa..c984b021 100644 --- a/src/platform/src/Bridge/OpenAi/Contract/OpenAiContract.php +++ b/src/platform/src/Bridge/OpenAi/Contract/OpenAiContract.php @@ -25,6 +25,7 @@ final class OpenAiContract extends Contract { return OpenResponsesContract::create( new AudioNormalizer(), + new DocumentNormalizer(), ...$normalizer, ); } diff --git a/src/platform/src/Bridge/OpenAi/Tests/Contract/DocumentNormalizerTest.php b/src/platform/src/Bridge/OpenAi/Tests/Contract/DocumentNormalizerTest.php index 95dff1b8..6a7de6c2 100644 --- a/src/platform/src/Bridge/OpenAi/Tests/Contract/DocumentNormalizerTest.php +++ b/src/platform/src/Bridge/OpenAi/Tests/Contract/DocumentNormalizerTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\AI\Platform\Bridge\OpenAi\Contract\DocumentNormalizer; use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Capability; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\Message\Content\Document; @@ -25,6 +26,9 @@ final class DocumentNormalizerTest extends TestCase $normalizer = new DocumentNormalizer(); $this->assertTrue($normalizer->supportsNormalization(new Document('some content', 'application/pdf'), context: [ + Contract::CONTEXT_MODEL => new Gpt('gpt-4o', [Capability::INPUT_PDF]), + ])); + $this->assertFalse($normalizer->supportsNormalization(new Document('some content', 'application/pdf'), context: [ Contract::CONTEXT_MODEL => new Gpt('gpt-4o'), ])); $this->assertFalse($normalizer->supportsNormalization('not a document'));