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/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.'); } 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 {