Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

- **Bugfix "Temperature on current models"**: Requests to Claude Opus 4.7 and later, Sonnet 5 and other
models that no longer accept sampling parameters failed with "`temperature` is deprecated for this
model", because every request carried a temperature whether the caller had asked for one or not.
Temperature is now opt-in: the request classes, the `Ai` proxy methods and `AiRequestBuilder` default
to `null`, and the provider's own default applies. A temperature, `top_p` or `top_k` a caller does set
and the model rejects is dropped and the request retried once; any other rejected option still fails.
Callers that relied on the former defaults (0.2 to 0.7) get the provider default instead, which is
less deterministic on models that still honour it; pass the value explicitly to keep the old output.

- **Feature "Routing latency"**: A cheaper model is only chosen if it is also fast enough. Smart
routing already read each model's average duration from the request log and then ignored it; a
candidate that takes more than twice as long as the current model is now skipped, because waiting is
Expand Down
12 changes: 6 additions & 6 deletions Classes/Ai.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ public function vision(
bool $disableSystemPromptComposition = false,
string|array $systemPromptOverride = '',
int $maxTokens = 150,
float $temperature = 0.2,
?float $temperature = null,
string $extensionKey = '',
string $user = '',
string $provider = '',
Expand Down Expand Up @@ -126,7 +126,7 @@ public function text(
bool $disableSystemPromptComposition = false,
string|array $systemPromptOverride = '',
int $maxTokens = 500,
float $temperature = 0.7,
?float $temperature = null,
string $extensionKey = '',
string $user = '',
string $provider = '',
Expand Down Expand Up @@ -160,7 +160,7 @@ public function translate(
bool $disableSystemPromptComposition = false,
string|array $systemPromptOverride = '',
int $maxTokens = 500,
float $temperature = 0.3,
?float $temperature = null,
string $extensionKey = '',
string $user = '',
string $provider = '',
Expand Down Expand Up @@ -196,7 +196,7 @@ public function conversation(
bool $disableSystemPromptComposition = false,
string|array $systemPromptOverride = '',
int $maxTokens = 1000,
float $temperature = 0.7,
?float $temperature = null,
string $extensionKey = '',
string $user = '',
string $provider = '',
Expand Down Expand Up @@ -243,7 +243,7 @@ public function conversationStream(
bool $disableSystemPromptComposition = false,
string|array $systemPromptOverride = '',
int $maxTokens = 1000,
float $temperature = 0.7,
?float $temperature = null,
string $extensionKey = '',
string $user = '',
string $provider = '',
Expand Down Expand Up @@ -303,7 +303,7 @@ public function toolCalling(
bool $disableSystemPromptComposition = false,
string|array $systemPromptOverride = '',
int $maxTokens = 1000,
float $temperature = 0.7,
?float $temperature = null,
string $extensionKey = '',
string $user = '',
string $provider = '',
Expand Down
2 changes: 1 addition & 1 deletion Classes/AiRequestBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ final class AiRequestBuilder
private bool $disableSystemPromptComposition = false;
private array $systemPromptOverride = [];
private int $maxTokens = 150;
private float $temperature = 0.2;
private ?float $temperature = null;
private string $extensionKey = '';
private string $user = '';
private string $providerNotation = '';
Expand Down
82 changes: 50 additions & 32 deletions Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@
use B13\Aim\Response\TextResponse;
use B13\Aim\Response\ToolCall;
use B13\Aim\Response\ToolCallingResponse;
use Symfony\AI\Platform\Exception\BadRequestException;
use Symfony\AI\Platform\Message\Content\Image;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\ProviderInterface;
use Symfony\AI\Platform\Result\BinaryResult;
use Symfony\AI\Platform\Result\DeferredResult;
use Symfony\AI\Platform\Result\MultiPartResult;
use Symfony\AI\Platform\Result\ToolCall as SymfonyToolCall;
use Symfony\AI\Platform\TokenUsage\TokenUsageInterface;
Expand Down Expand Up @@ -78,6 +80,13 @@ class SymfonyAiPlatformAdapter implements
EmbeddingCapableInterface,
ImageGenerationCapableInterface
{
/**
* Sampling options that only tune the output. A model that rejects one
* still answers the request, so these are dropped and retried rather than
* failing the request. Anything else a model rejects fails as usual.
*/
private const SAMPLING_HINTS = ['temperature', 'top_p', 'top_k'];

/** @var array<string, ProviderInterface> Providers cached by configuration key */
private array $platforms = [];

Expand Down Expand Up @@ -150,8 +159,8 @@ public function processVisionRequest(VisionRequest $request): TextResponse

try {
$platform = $this->getPlatform($request->configuration);
$options = $this->buildOptions($request->configuration->model, $request->maxTokens, $request->temperature);
$result = $platform->invoke($request->configuration->model, $messages, $options);
$options = $this->buildOptions($request->maxTokens, $request->temperature);
$result = $this->invoke($platform, $request->configuration->model, $messages, $options);
return $this->toTextResponse($result, $request->configuration);
} catch (\Throwable $e) {
return new TextResponse('', errors: [$this->describeError($e, $request->configuration)]);
Expand All @@ -169,11 +178,11 @@ public function processTextGenerationRequest(TextGenerationRequest $request): Te
if ($request->responseFormat !== null) {
$extra['response_format'] = $request->responseFormat->toArray();
}
$options = $this->buildOptions($request->configuration->model, $request->maxTokens, $request->temperature, $extra);
$options = $this->buildOptions($request->maxTokens, $request->temperature, $extra);

try {
$platform = $this->getPlatform($request->configuration);
$result = $platform->invoke($request->configuration->model, $messages, $options);
$result = $this->invoke($platform, $request->configuration->model, $messages, $options);
return $this->toTextResponse($result, $request->configuration);
} catch (\Throwable $e) {
return new TextResponse('', errors: [$this->describeError($e, $request->configuration)]);
Expand All @@ -197,8 +206,8 @@ public function processTranslationRequest(TranslationRequest $request): TextResp

try {
$platform = $this->getPlatform($request->configuration);
$options = $this->buildOptions($request->configuration->model, $request->maxTokens, $request->temperature);
$result = $platform->invoke($request->configuration->model, $messages, $options);
$options = $this->buildOptions($request->maxTokens, $request->temperature);
$result = $this->invoke($platform, $request->configuration->model, $messages, $options);
return $this->toTextResponse($result, $request->configuration);
} catch (\Throwable $e) {
return new TextResponse('', errors: [$this->describeError($e, $request->configuration)]);
Expand All @@ -217,11 +226,11 @@ public function processConversationRequest(ConversationRequest $request): Conver
if ($stream) {
$extra['stream'] = true;
}
$options = $this->buildOptions($request->configuration->model, $request->maxTokens, $request->temperature, $extra);
$options = $this->buildOptions($request->maxTokens, $request->temperature, $extra);

try {
$platform = $this->getPlatform($request->configuration);
$result = $platform->invoke($request->configuration->model, $messages, $options);
$result = $this->invoke($platform, $request->configuration->model, $messages, $options);

if ($stream) {
$streamIterator = new StreamChunkIterator(
Expand Down Expand Up @@ -261,11 +270,11 @@ public function processToolCallingRequest(ToolCallingRequest $request): ToolCall
if ($request->stream) {
$extra['stream'] = true;
}
$options = $this->buildOptions($request->configuration->model, $request->maxTokens, $request->temperature, $extra);
$options = $this->buildOptions($request->maxTokens, $request->temperature, $extra);

try {
$platform = $this->getPlatform($request->configuration);
$result = $platform->invoke($request->configuration->model, $messages, $options);
$result = $this->invoke($platform, $request->configuration->model, $messages, $options);

if ($request->stream) {
$streamIterator = new StreamChunkIterator(
Expand Down Expand Up @@ -685,17 +694,41 @@ private function toSymfonyToolCall(ToolCall $call): SymfonyToolCall
}

/**
* Build the options array for platform->invoke(), omitting temperature
* for models that don't support it.
* Invoke the platform and force the deferred result, so a request the API
* rejects fails here rather than later while reading the result.
*
* @todo This uses a hardcoded list of model prefixes which is OpenAI-specific.
* A provider-agnostic solution (e.g. model catalog metadata or automatic
* retry on rejection) should replace this in a future version.
* Newer models (Claude Opus 4.7+, Sonnet 5, OpenAI reasoning models, etc.)
* reject sampling options with a 400. When the error names one of the
* SAMPLING_HINTS the request carries, it is retried once without them.
*/
private function invoke(ProviderInterface $platform, string $model, MessageBag $messages, array $options): DeferredResult
{
try {
$result = $platform->invoke($model, $messages, $options);
$result->getResult();
return $result;
} catch (BadRequestException $e) {
$rejected = array_filter(
self::SAMPLING_HINTS,
static fn(string $hint): bool => array_key_exists($hint, $options) && str_contains($e->getMessage(), $hint),
);
if ($rejected === []) {
throw $e;
}
$result = $platform->invoke($model, $messages, array_diff_key($options, array_flip($rejected)));
$result->getResult();
return $result;
}
}

/**
* Build the options array for platform->invoke(). Temperature is only
* sent when the caller set one; otherwise the provider default applies.
*/
private function buildOptions(string $model, int $maxTokens, float $temperature, array $extra = []): array
private function buildOptions(int $maxTokens, ?float $temperature, array $extra = []): array
{
$options = [$this->maxTokensKey => $maxTokens] + $extra;
if (!$this->isReasoningModel($model)) {
if ($temperature !== null) {
$options['temperature'] = $temperature;
}
return $options;
Expand All @@ -721,19 +754,4 @@ public static function resolveMaxTokensKey(string $factoryClass): string
}
return 'max_tokens';
}

/**
* Check if a model is a reasoning model that doesn't support temperature.
*
* @todo Replace with provider-agnostic detection once model catalogs expose this.
*/
private function isReasoningModel(string $model): bool
{
foreach (['o1', 'o1-mini', 'o3', 'o3-mini', 'o4-mini'] as $prefix) {
if ($model === $prefix || str_starts_with($model, $prefix . '-')) {
return true;
}
}
return false;
}
}
2 changes: 1 addition & 1 deletion Classes/Request/ConversationRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public function __construct(
public readonly array $systemPromptOverride = [],
public readonly ?ResponseFormat $responseFormat = null,
public readonly int $maxTokens = 1000,
public readonly float $temperature = 0.7,
public readonly ?float $temperature = null,
public readonly string $user = '',
public readonly array $metadata = [],
public readonly bool $stream = false,
Expand Down
2 changes: 1 addition & 1 deletion Classes/Request/TextGenerationRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function __construct(
public readonly array $systemPromptOverride = [],
public readonly ?ResponseFormat $responseFormat = null,
public readonly int $maxTokens = 150,
public readonly float $temperature = 0.2,
public readonly ?float $temperature = null,
public readonly string $user = '',
public readonly array $metadata = [],
public readonly ?PrivacyLevel $privacyLevelOverride = null,
Expand Down
2 changes: 1 addition & 1 deletion Classes/Request/ToolCallingRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public function __construct(
public readonly array $toolResults = [],
public readonly ?ResponseFormat $responseFormat = null,
public readonly int $maxTokens = 1000,
public readonly float $temperature = 0.7,
public readonly ?float $temperature = null,
public readonly string $user = '',
public readonly array $metadata = [],
public readonly ?PrivacyLevel $privacyLevelOverride = null,
Expand Down
2 changes: 1 addition & 1 deletion Classes/Request/TranslationRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public function __construct(
public readonly bool $disableAutomaticSystemPrompt = false,
public readonly array $systemPromptOverride = [],
public readonly int $maxTokens = 150,
public readonly float $temperature = 0.2,
public readonly ?float $temperature = null,
public readonly string $user = '',
public readonly array $metadata = [],
public readonly ?PrivacyLevel $privacyLevelOverride = null,
Expand Down
2 changes: 1 addition & 1 deletion Classes/Request/VisionRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public function __construct(
public readonly bool $disableAutomaticSystemPrompt = false,
public readonly array $systemPromptOverride = [],
public readonly int $maxTokens = 150,
public readonly float $temperature = 0.2,
public readonly ?float $temperature = null,
public readonly string $user = '',
public readonly array $metadata = [],
public readonly ?PrivacyLevel $privacyLevelOverride = null,
Expand Down
7 changes: 5 additions & 2 deletions Tests/Unit/Provider/LiveModelDiscoveryCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ public function theSecondLookupIsServedFromTheCache(): void
{
$subject = $this->subject('{"data":[{"id":"llama3.2"}]}');

self::assertSame(['llama3.2'], $subject->fetchModelNames('http://localhost:11434'));
self::assertSame(['llama3.2'], $subject->fetchModelNames('http://localhost:11434'));
$first = $subject->fetchModelNames('http://localhost:11434');
$second = $subject->fetchModelNames('http://localhost:11434');

self::assertSame(['llama3.2'], $first);
self::assertSame(['llama3.2'], $second);
self::assertSame(1, $this->requests, 'The endpoint was queried twice.');
}

Expand Down
73 changes: 73 additions & 0 deletions Tests/Unit/Provider/SymfonyAi/SymfonyAiPlatformAdapterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,16 @@
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Exception\BadRequestException;
use Symfony\AI\Platform\Message\AssistantMessage as SymfonyAssistantMessage;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Message\ToolCallMessage;
use Symfony\AI\Platform\ProviderInterface;
use Symfony\AI\Platform\Result\DeferredResult;
use Symfony\AI\Platform\Result\InMemoryRawResult;
use Symfony\AI\Platform\Result\TextResult;
use Symfony\AI\Platform\ResultConverterInterface;

final class SymfonyAiPlatformAdapterTest extends TestCase
{
Expand Down Expand Up @@ -66,6 +73,72 @@ public function resolveMaxTokensKeyMapsBridgeToCorrectOptionName(string $factory
self::assertSame($expectedKey, SymfonyAiPlatformAdapter::resolveMaxTokensKey($factoryClass));
}

#[Test]
public function buildOptionsSendsTemperatureOnlyWhenTheCallerSetOne(): void
{
$adapter = new SymfonyAiPlatformAdapter('Symfony\\AI\\Platform\\Bridge\\Anthropic\\PlatformFactory');
$buildOptions = new \ReflectionMethod($adapter, 'buildOptions');

self::assertSame(['max_tokens' => 1000], $buildOptions->invoke($adapter, 1000, null));
self::assertSame(['max_tokens' => 1000, 'temperature' => 0.0], $buildOptions->invoke($adapter, 1000, 0.0));
}

#[Test]
public function invokeRetriesWithoutTemperatureWhenTheModelRejectsIt(): void
{
$adapter = new SymfonyAiPlatformAdapter('Symfony\\AI\\Platform\\Bridge\\Anthropic\\PlatformFactory');
$sentOptions = [];
$platform = $this->createPlatform(static function (array $options) use (&$sentOptions): TextResult {
$sentOptions[] = $options;
if (array_key_exists('temperature', $options)) {
throw new BadRequestException('`temperature` is deprecated for this model.');
}
return new TextResult('ok');
});

$result = (new \ReflectionMethod($adapter, 'invoke'))
->invoke($adapter, $platform, 'claude-sonnet-5', new MessageBag(Message::ofUser('Hi')), ['max_tokens' => 10, 'temperature' => 0.7]);

self::assertSame('ok', $result->asText());
self::assertCount(2, $sentOptions);
self::assertSame(['max_tokens' => 10], $sentOptions[1]);
}

#[Test]
public function invokeDoesNotRetryOtherBadRequests(): void
{
$adapter = new SymfonyAiPlatformAdapter('Symfony\\AI\\Platform\\Bridge\\Anthropic\\PlatformFactory');
$calls = 0;
$platform = $this->createPlatform(static function () use (&$calls): TextResult {
$calls++;
throw new BadRequestException('max_tokens: must be greater than 0');
});

try {
(new \ReflectionMethod($adapter, 'invoke'))
->invoke($adapter, $platform, 'acme-model', new MessageBag(Message::ofUser('Hi')), ['max_tokens' => 0, 'temperature' => 0.7]);
self::fail('BadRequestException expected');
} catch (BadRequestException) {
}
self::assertSame(1, $calls);
}

/**
* @param \Closure(array<string, mixed>): TextResult $convert
*/
private function createPlatform(\Closure $convert): ProviderInterface
{
$converter = $this->createMock(ResultConverterInterface::class);
$converter->method('convert')->willReturnCallback(
static fn(InMemoryRawResult $raw, array $options): TextResult => $convert($options),
);
$platform = $this->createMock(ProviderInterface::class);
$platform->method('invoke')->willReturnCallback(
static fn(string $model, MessageBag $input, array $options): DeferredResult => new DeferredResult($converter, new InMemoryRawResult(), $options),
);
return $platform;
}

#[Test]
public function buildMessageBagMapsAssistantToolCallsToNativeSymfonyParts(): void
{
Expand Down
Loading