mirror of
https://github.com/symfony/ai.git
synced 2026-03-23 23:42:18 +01:00
[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:
2
.codespellrc
Normal file
2
.codespellrc
Normal file
@@ -0,0 +1,2 @@
|
||||
[codespell]
|
||||
ignore-words-list = vektor
|
||||
@@ -425,6 +425,7 @@ deptrac:
|
||||
AzurePlatform:
|
||||
- PlatformComponent
|
||||
- OpenAiPlatform
|
||||
- OpenResponsesPlatform
|
||||
- GenericPlatform
|
||||
- MetaPlatform
|
||||
BedrockPlatform:
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
0.6
|
||||
---
|
||||
|
||||
* Switch to OpenResponses contract
|
||||
|
||||
0.2
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -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']]]);
|
||||
}
|
||||
}
|
||||
@@ -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']]]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ final class OpenAiContract extends Contract
|
||||
{
|
||||
return OpenResponsesContract::create(
|
||||
new AudioNormalizer(),
|
||||
new DocumentNormalizer(),
|
||||
...$normalizer,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user