[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 <noreply@anthropic.com>
This commit is contained in:
paul
2026-03-03 11:59:19 +01:00
committed by Christopher Hertel
parent 92b78ea2b9
commit 1e2df898a5
11 changed files with 121 additions and 131 deletions

2
.codespellrc Normal file
View File

@@ -0,0 +1,2 @@
[codespell]
ignore-words-list = vektor

View File

@@ -425,6 +425,7 @@ deptrac:
AzurePlatform:
- PlatformComponent
- OpenAiPlatform
- OpenResponsesPlatform
- GenericPlatform
- MetaPlatform
BedrockPlatform:

View File

@@ -1,6 +1,11 @@
CHANGELOG
=========
0.6
---
* Switch to OpenResponses contract
0.2
---

View File

@@ -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;

View File

@@ -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,
);
}

View File

@@ -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 <mail@christopher-hertel.de>
*/
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),
]));
}
}

View File

@@ -1,92 +0,0 @@
<?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\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 <oskarstark@googlemail.com>
*/
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']]]);
}
}

View File

@@ -0,0 +1,81 @@
<?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\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']]]);
}
}

View File

@@ -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);
}
}

View File

@@ -25,6 +25,7 @@ final class OpenAiContract extends Contract
{
return OpenResponsesContract::create(
new AudioNormalizer(),
new DocumentNormalizer(),
...$normalizer,
);
}

View File

@@ -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'));