bug #1711 [Platform] Throw InvalidArgumentException when ModelClient receives a string payload (fabpot)

This PR was squashed before being merged into the main branch.

Discussion
----------

[Platform] Throw InvalidArgumentException when ModelClient receives a string payload

| Q             | A
| ------------- | ---
| Bug fix?      | yes
| New feature?  | no <!-- please update src/**/CHANGELOG.md files -->
| Docs?         | no <!-- required for new features -->
| Issues        | n/a
| License       | MIT

While working on #1644, I realized that passing a string to `ModelClientInterface::request()` was allowed, but most ModelClient implementations use `array_merge($options, $payload)` aithout checking if `$payload` is a string.

Commits
-------

e29a00bd [Platform] Throw InvalidArgumentException when ModelClient receives a string payload
This commit is contained in:
Oskar Stark
2026-03-02 11:03:52 +01:00
21 changed files with 118 additions and 1 deletions

View File

@@ -11,6 +11,7 @@
namespace Symfony\AI\Platform\Bridge\Anthropic;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -38,6 +39,10 @@ final class ModelClient implements ModelClientInterface
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
$headers = [
'x-api-key' => $this->apiKey,
'anthropic-version' => '2023-06-01',

View File

@@ -14,6 +14,7 @@ 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\AI\Platform\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
@@ -175,6 +176,16 @@ class ModelClientTest extends TestCase
$this->modelClient->request($this->model, ['message' => 'test'], $options);
}
public function testStringPayloadThrowsException()
{
$this->modelClient = new ModelClient(new MockHttpClient(), 'test-api-key');
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Payload must be an array, but a string was given');
$this->modelClient->request($this->model, 'string payload');
}
/**
* @param list<string> $headers
*

View File

@@ -12,6 +12,7 @@
namespace Symfony\AI\Platform\Bridge\Azure\Meta;
use Symfony\AI\Platform\Bridge\Meta\Llama;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -36,6 +37,10 @@ final class LlamaModelClient implements ModelClientInterface
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
$url = \sprintf('https://%s/chat/completions', $this->baseUrl);
return new RawHttpResult($this->httpClient->request('POST', $url, [

View File

@@ -58,6 +58,10 @@ final class CompletionsModelClient implements ModelClientInterface
public function request(Model $model, object|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, [

View File

@@ -59,6 +59,10 @@ final class WhisperModelClient implements ModelClientInterface
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
$task = $options['task'] ?? Task::TRANSCRIPTION;
$endpoint = Task::TRANSCRIPTION === $task ? 'transcriptions' : 'translations';
$url = \sprintf('https://%s/openai/deployments/%s/audio/%s', $this->baseUrl, $this->deployment, $endpoint);

View File

@@ -15,6 +15,7 @@ use AsyncAws\BedrockRuntime\BedrockRuntimeClient;
use AsyncAws\BedrockRuntime\Input\InvokeModelRequest;
use Symfony\AI\Platform\Bridge\Anthropic\Claude;
use Symfony\AI\Platform\Bridge\Bedrock\RawBedrockResult;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
@@ -36,6 +37,10 @@ final class ClaudeModelClient implements ModelClientInterface
public function request(Model $model, array|string $payload, array $options = []): RawBedrockResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
unset($payload['model']);
if (isset($options['tools'])) {

View File

@@ -14,6 +14,7 @@ namespace Symfony\AI\Platform\Bridge\Bedrock\Nova;
use AsyncAws\BedrockRuntime\BedrockRuntimeClient;
use AsyncAws\BedrockRuntime\Input\InvokeModelRequest;
use Symfony\AI\Platform\Bridge\Bedrock\RawBedrockResult;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
@@ -34,6 +35,10 @@ class NovaModelClient implements ModelClientInterface
public function request(Model $model, array|string $payload, array $options = []): RawBedrockResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
unset($payload['model']);
$modelOptions = [];

View File

@@ -47,6 +47,10 @@ final class ModelClient implements ModelClientInterface
public function request(BaseModel $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
return new RawHttpResult(
$this->httpClient->request(
'POST', 'https://api.cerebras.ai/v1/chat/completions',
@@ -55,7 +59,7 @@ final class ModelClient implements ModelClientInterface
'Content-Type' => 'application/json',
'Authorization' => \sprintf('Bearer %s', $this->apiKey),
],
'json' => \is_array($payload) ? array_merge($payload, $options) : $payload,
'json' => array_merge($payload, $options),
]
)
);

View File

@@ -11,6 +11,7 @@
namespace Symfony\AI\Platform\Bridge\DeepSeek;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -38,6 +39,10 @@ final class ModelClient implements ModelClientInterface
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
return new RawHttpResult($this->httpClient->request('POST', 'https://api.deepseek.com/chat/completions', [
'auth_bearer' => $this->apiKey,
'json' => array_merge($options, $payload),

View File

@@ -12,6 +12,7 @@
namespace Symfony\AI\Platform\Bridge\DockerModelRunner\Completions;
use Symfony\AI\Platform\Bridge\DockerModelRunner\Completions;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -39,6 +40,10 @@ final class ModelClient implements ModelClientInterface
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
return new RawHttpResult($this->httpClient->request('POST', \sprintf('%s/engines/v1/chat/completions', $this->hostUrl), [
'json' => array_merge($options, $payload),
]));

View File

@@ -12,6 +12,7 @@
namespace Symfony\AI\Platform\Bridge\Gemini\Gemini;
use Symfony\AI\Platform\Bridge\Gemini\Gemini;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -44,6 +45,10 @@ final class ModelClient implements ModelClientInterface
*/
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
$url = \sprintf(
'https://generativelanguage.googleapis.com/v1beta/models/%s:%s',
$model->getName(),

View File

@@ -12,6 +12,7 @@
namespace Symfony\AI\Platform\Bridge\Generic\Completions;
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -44,6 +45,10 @@ class ModelClient implements ModelClientInterface
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
return new RawHttpResult($this->httpClient->request('POST', $this->baseUrl.$this->path, [
'auth_bearer' => $this->apiKey,
'headers' => ['Content-Type' => 'application/json'],

View File

@@ -15,6 +15,7 @@ use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\Generic\Completions\ModelClient;
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse;
@@ -31,6 +32,16 @@ final class ModelClientTest extends TestCase
$this->assertTrue($modelClient->supports(new CompletionsModel('gpt-4o')));
}
public function testStringPayloadThrowsException()
{
$modelClient = new ModelClient(new MockHttpClient(), 'http://localhost:8000');
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Payload must be an array, but a string was given');
$modelClient->request(new CompletionsModel('gpt-4o'), 'string payload');
}
public function testItIsExecutingTheCorrectRequest()
{
$resultCallback = static function (string $method, string $url, array $options): HttpResponse {

View File

@@ -12,6 +12,7 @@
namespace Symfony\AI\Platform\Bridge\Mistral\Llm;
use Symfony\AI\Platform\Bridge\Mistral\Mistral;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -39,6 +40,10 @@ final class ModelClient implements ModelClientInterface
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
return new RawHttpResult($this->httpClient->request('POST', 'https://api.mistral.ai/v1/chat/completions', [
'auth_bearer' => $this->apiKey,
'headers' => [

View File

@@ -65,6 +65,10 @@ final class OllamaClient implements ModelClientInterface
*/
private function doCompletionRequest(array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
// Revert Ollama's default streaming behavior
$options['stream'] ??= false;

View File

@@ -13,6 +13,7 @@ namespace Symfony\AI\Platform\Bridge\OpenAi\Gpt;
use Symfony\AI\Platform\Bridge\OpenAi\AbstractModelClient;
use Symfony\AI\Platform\Bridge\OpenAi\Gpt;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -43,6 +44,10 @@ final class ModelClient extends AbstractModelClient implements ModelClientInterf
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
if (isset($options[PlatformSubscriber::RESPONSE_FORMAT]['json_schema']['schema'])) {
$schema = $options[PlatformSubscriber::RESPONSE_FORMAT]['json_schema'];
$options['text']['format'] = $schema;

View File

@@ -78,6 +78,16 @@ final class ModelClientTest extends TestCase
$this->assertTrue($modelClient->supports(new Gpt('gpt-4o')));
}
public function testStringPayloadThrowsException()
{
$modelClient = new ModelClient(new MockHttpClient(), 'sk-api-key');
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Payload must be an array, but a string was given');
$modelClient->request(new Gpt('gpt-4o'), 'string payload');
}
public function testItIsExecutingTheCorrectRequest()
{
$resultCallback = static function (string $method, string $url, array $options): HttpResponse {

View File

@@ -13,6 +13,7 @@ namespace Symfony\AI\Platform\Bridge\OpenAi\Whisper;
use Symfony\AI\Platform\Bridge\OpenAi\AbstractModelClient;
use Symfony\AI\Platform\Bridge\OpenAi\Whisper;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -38,6 +39,10 @@ final class ModelClient extends AbstractModelClient implements ModelClientInterf
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
$task = $options['task'] ?? Task::TRANSCRIPTION;
$endpoint = Task::TRANSCRIPTION === $task ? 'transcriptions' : 'translations';
unset($options['task']);

View File

@@ -11,6 +11,7 @@
namespace Symfony\AI\Platform\Bridge\OpenResponses;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -41,6 +42,10 @@ class ModelClient implements ModelClientInterface
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
if (isset($options[PlatformSubscriber::RESPONSE_FORMAT]['json_schema']['schema'])) {
$schema = $options[PlatformSubscriber::RESPONSE_FORMAT]['json_schema'];
$options['text']['format'] = $schema;

View File

@@ -48,6 +48,10 @@ final class ModelClient implements ModelClientInterface
public function request(Model $model, array|string $payload, array $options = []): RawResultInterface
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
return new RawHttpResult($this->httpClient->request('POST', 'https://api.perplexity.ai/chat/completions', [
'auth_bearer' => $this->apiKey,
'json' => array_merge($options, $payload),

View File

@@ -12,6 +12,7 @@
namespace Symfony\AI\Platform\Bridge\Scaleway\Llm;
use Symfony\AI\Platform\Bridge\Scaleway\Scaleway;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -39,6 +40,10 @@ final class ModelClient implements ModelClientInterface
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
if (\is_string($payload)) {
throw new InvalidArgumentException(\sprintf('Payload must be an array, but a string was given to "%s".', self::class));
}
return new RawHttpResult($this->httpClient->request('POST', 'https://api.scaleway.ai/v1/chat/completions', [
'auth_bearer' => $this->apiKey,
'json' => array_merge($options, $payload),