From af97666ec9fed8a1a49c76ef3febe067dd35f535 Mon Sep 17 00:00:00 2001 From: Oliver Bartsch Date: Thu, 24 Sep 2026 11:16:26 +0200 Subject: [PATCH 1/2] [BUGFIX] Send temperature only when the caller sets one Requests to Claude Opus 4.7 and later, Sonnet 5 and other models without sampling parameters failed with "`temperature` is deprecated for this model". Every request carried a temperature, because the request classes, the Ai proxy methods and AiRequestBuilder defaulted it to 0.2 to 0.7. Temperature now defaults to null and the provider's own default applies. The hardcoded list of OpenAI reasoning models is gone; the adapter no longer knows which model accepts what. A temperature, top_p or top_k a caller does set and the model rejects is dropped and the request retried once. These only tune the output; any other rejected option still fails the request. Callers that relied on the former defaults get the provider default, which is less deterministic on models that still honour it. --- CHANGELOG.md | 9 ++ Classes/Ai.php | 12 +-- Classes/AiRequestBuilder.php | 2 +- .../SymfonyAi/SymfonyAiPlatformAdapter.php | 82 +++++++++++-------- Classes/Request/ConversationRequest.php | 2 +- Classes/Request/TextGenerationRequest.php | 2 +- Classes/Request/ToolCallingRequest.php | 2 +- Classes/Request/TranslationRequest.php | 2 +- Classes/Request/VisionRequest.php | 2 +- .../SymfonyAiPlatformAdapterTest.php | 73 +++++++++++++++++ 10 files changed, 144 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fffdea..5a42146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Classes/Ai.php b/Classes/Ai.php index ec0565d..b62dc03 100644 --- a/Classes/Ai.php +++ b/Classes/Ai.php @@ -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 = '', @@ -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 = '', @@ -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 = '', @@ -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 = '', @@ -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 = '', @@ -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 = '', diff --git a/Classes/AiRequestBuilder.php b/Classes/AiRequestBuilder.php index 71e68a2..f7d92ba 100644 --- a/Classes/AiRequestBuilder.php +++ b/Classes/AiRequestBuilder.php @@ -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 = ''; diff --git a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php index a8a2ced..cbe236f 100644 --- a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php +++ b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php @@ -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; @@ -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 Providers cached by configuration key */ private array $platforms = []; @@ -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)]); @@ -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)]); @@ -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)]); @@ -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( @@ -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( @@ -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; @@ -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; - } } diff --git a/Classes/Request/ConversationRequest.php b/Classes/Request/ConversationRequest.php index e3a2756..a402775 100644 --- a/Classes/Request/ConversationRequest.php +++ b/Classes/Request/ConversationRequest.php @@ -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, diff --git a/Classes/Request/TextGenerationRequest.php b/Classes/Request/TextGenerationRequest.php index d5eb58b..931b265 100644 --- a/Classes/Request/TextGenerationRequest.php +++ b/Classes/Request/TextGenerationRequest.php @@ -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, diff --git a/Classes/Request/ToolCallingRequest.php b/Classes/Request/ToolCallingRequest.php index d99011f..63a2559 100644 --- a/Classes/Request/ToolCallingRequest.php +++ b/Classes/Request/ToolCallingRequest.php @@ -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, diff --git a/Classes/Request/TranslationRequest.php b/Classes/Request/TranslationRequest.php index 23513df..361d0c3 100644 --- a/Classes/Request/TranslationRequest.php +++ b/Classes/Request/TranslationRequest.php @@ -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, diff --git a/Classes/Request/VisionRequest.php b/Classes/Request/VisionRequest.php index ec01202..2e94c71 100644 --- a/Classes/Request/VisionRequest.php +++ b/Classes/Request/VisionRequest.php @@ -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, diff --git a/Tests/Unit/Provider/SymfonyAi/SymfonyAiPlatformAdapterTest.php b/Tests/Unit/Provider/SymfonyAi/SymfonyAiPlatformAdapterTest.php index d19351e..3bcc2a4 100644 --- a/Tests/Unit/Provider/SymfonyAi/SymfonyAiPlatformAdapterTest.php +++ b/Tests/Unit/Provider/SymfonyAi/SymfonyAiPlatformAdapterTest.php @@ -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 { @@ -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): 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 { From 252da9d4ea81cab2d6cfccf8db59cc427a2a4931 Mon Sep 17 00:00:00 2001 From: Oliver Bartsch Date: Thu, 24 Sep 2026 11:28:06 +0200 Subject: [PATCH 2/2] [TASK] Keep the model discovery cache test stable on PHPStan 2.2.15 --- Tests/Unit/Provider/LiveModelDiscoveryCacheTest.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Tests/Unit/Provider/LiveModelDiscoveryCacheTest.php b/Tests/Unit/Provider/LiveModelDiscoveryCacheTest.php index d108372..07d3dec 100644 --- a/Tests/Unit/Provider/LiveModelDiscoveryCacheTest.php +++ b/Tests/Unit/Provider/LiveModelDiscoveryCacheTest.php @@ -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.'); }