diff --git a/src/Concerns/HasReasoning.php b/src/Concerns/HasReasoning.php new file mode 100644 index 000000000..c22269973 --- /dev/null +++ b/src/Concerns/HasReasoning.php @@ -0,0 +1,37 @@ +reasoningEnabled = $enabled; + + return $this; + } + + public function reasoningEnabled(): ?bool + { + return $this->reasoningEnabled; + } +} diff --git a/src/Providers/Anthropic/Handlers/Structured.php b/src/Providers/Anthropic/Handlers/Structured.php index 244f1747b..e326a5194 100644 --- a/src/Providers/Anthropic/Handlers/Structured.php +++ b/src/Providers/Anthropic/Handlers/Structured.php @@ -93,7 +93,8 @@ public static function buildHttpRequestPayload(PrismRequest $request): array 'model' => $request->model(), 'messages' => MessageMap::map($request->messages(), $request->providerOptions()), 'system' => MessageMap::mapSystemMessages($request->systemPrompts()) ?: null, - 'thinking' => $request->providerOptions('thinking.enabled') === true + 'thinking' => $request->reasoningEnabled() !== false + && $request->providerOptions('thinking.enabled') === true ? [ 'type' => 'enabled', 'budget_tokens' => is_int($request->providerOptions('thinking.budgetTokens')) diff --git a/src/Providers/Anthropic/Handlers/Text.php b/src/Providers/Anthropic/Handlers/Text.php index ee6b2e37a..0f2965cb3 100644 --- a/src/Providers/Anthropic/Handlers/Text.php +++ b/src/Providers/Anthropic/Handlers/Text.php @@ -75,7 +75,8 @@ public static function buildHttpRequestPayload(PrismRequest $request): array 'model' => $request->model(), 'system' => MessageMap::mapSystemMessages($request->systemPrompts()) ?: null, 'messages' => MessageMap::map($request->messages(), $request->providerOptions()), - 'thinking' => $request->providerOptions('thinking.enabled') === true + 'thinking' => $request->reasoningEnabled() !== false + && $request->providerOptions('thinking.enabled') === true ? [ 'type' => 'enabled', 'budget_tokens' => is_int($request->providerOptions('thinking.budgetTokens')) diff --git a/src/Providers/Gemini/Handlers/Stream.php b/src/Providers/Gemini/Handlers/Stream.php index e7acfad33..97ed2d40f 100644 --- a/src/Providers/Gemini/Handlers/Stream.php +++ b/src/Providers/Gemini/Handlers/Stream.php @@ -490,6 +490,10 @@ protected function sendRequest(Request $request): Response ]; } + if ($request->reasoningEnabled() === false && $thinkingConfig === null) { + $thinkingConfig = ['thinkingBudget' => 0]; + } + /** @var Response $response */ $response = $this->client ->withOptions(['stream' => true]) diff --git a/src/Providers/Gemini/Handlers/Structured.php b/src/Providers/Gemini/Handlers/Structured.php index 6d4870f59..cbeece24c 100644 --- a/src/Providers/Gemini/Handlers/Structured.php +++ b/src/Providers/Gemini/Handlers/Structured.php @@ -120,6 +120,10 @@ public function sendRequest(Request $request): array ]); } + if ($request->reasoningEnabled() === false && $thinkingConfig === null) { + $thinkingConfig = ['thinkingBudget' => 0]; + } + /** @var Response $response */ $response = $this->client->post( "{$request->model()}:generateContent", diff --git a/src/Providers/Gemini/Handlers/Text.php b/src/Providers/Gemini/Handlers/Text.php index fce051b67..634d91dff 100644 --- a/src/Providers/Gemini/Handlers/Text.php +++ b/src/Providers/Gemini/Handlers/Text.php @@ -83,6 +83,10 @@ protected function sendRequest(Request $request): ClientResponse ]); } + if ($request->reasoningEnabled() === false && $thinkingConfig === null) { + $thinkingConfig = ['thinkingBudget' => 0]; + } + $generationConfig = Arr::whereNotNull([ 'temperature' => $request->temperature(), 'topP' => $request->topP(), diff --git a/src/Providers/Ollama/Handlers/Stream.php b/src/Providers/Ollama/Handlers/Stream.php index b5544c111..0a1975359 100644 --- a/src/Providers/Ollama/Handlers/Stream.php +++ b/src/Providers/Ollama/Handlers/Stream.php @@ -339,7 +339,9 @@ protected function sendRequest(Request $request): Response 'tools' => ToolMap::map($request->tools()), 'stream' => true, ...Arr::whereNotNull([ - 'think' => $request->providerOptions('thinking'), + 'think' => $request->providerOptions('thinking') ?? ( + $request->reasoningEnabled() === false ? false : null + ), 'keep_alive' => $request->providerOptions('keep_alive'), ]), 'options' => Arr::whereNotNull(array_merge([ diff --git a/src/Providers/Ollama/Handlers/Structured.php b/src/Providers/Ollama/Handlers/Structured.php index 20ff096f1..5ed94bae0 100644 --- a/src/Providers/Ollama/Handlers/Structured.php +++ b/src/Providers/Ollama/Handlers/Structured.php @@ -84,6 +84,9 @@ protected function sendRequest(Request $request): array 'format' => $request->schema()->toArray(), 'stream' => false, ...Arr::whereNotNull([ + 'think' => $request->providerOptions('thinking') ?? ( + $request->reasoningEnabled() === false ? false : null + ), 'keep_alive' => $request->providerOptions('keep_alive'), ]), 'options' => Arr::whereNotNull(array_merge([ diff --git a/src/Providers/Ollama/Handlers/Text.php b/src/Providers/Ollama/Handlers/Text.php index 1c4928fa9..dc012b4a4 100644 --- a/src/Providers/Ollama/Handlers/Text.php +++ b/src/Providers/Ollama/Handlers/Text.php @@ -77,7 +77,9 @@ protected function sendRequest(Request $request): array 'tools' => ToolMap::map($request->tools()), 'stream' => false, ...Arr::whereNotNull([ - 'think' => $request->providerOptions('thinking'), + 'think' => $request->providerOptions('thinking') ?? ( + $request->reasoningEnabled() === false ? false : null + ), 'keep_alive' => $request->providerOptions('keep_alive'), ]), 'options' => Arr::whereNotNull(array_merge([ diff --git a/src/Providers/OpenAI/Handlers/Stream.php b/src/Providers/OpenAI/Handlers/Stream.php index adcbadaf1..449fc0e24 100644 --- a/src/Providers/OpenAI/Handlers/Stream.php +++ b/src/Providers/OpenAI/Handlers/Stream.php @@ -588,7 +588,9 @@ protected function sendRequest(Request $request): Response 'verbosity' => $request->providerOptions('text_verbosity'), ] : null, 'truncation' => $request->providerOptions('truncation'), - 'reasoning' => $request->providerOptions('reasoning'), + 'reasoning' => $request->providerOptions('reasoning') ?? ( + $request->reasoningEnabled() === false ? ['effort' => 'minimal'] : null + ), ])) ); diff --git a/src/Providers/OpenAI/Handlers/Structured.php b/src/Providers/OpenAI/Handlers/Structured.php index ffa2f0e53..0352d6d82 100644 --- a/src/Providers/OpenAI/Handlers/Structured.php +++ b/src/Providers/OpenAI/Handlers/Structured.php @@ -216,7 +216,9 @@ protected function sendRequest(Request $request, array $responseFormat): ClientR 'previous_response_id' => $request->providerOptions('previous_response_id'), 'service_tier' => $request->providerOptions('service_tier'), 'truncation' => $request->providerOptions('truncation'), - 'reasoning' => $request->providerOptions('reasoning'), + 'reasoning' => $request->providerOptions('reasoning') ?? ( + $request->reasoningEnabled() === false ? ['effort' => 'minimal'] : null + ), 'store' => $request->providerOptions('store'), 'text' => [ 'format' => $responseFormat, diff --git a/src/Providers/OpenAI/Handlers/Text.php b/src/Providers/OpenAI/Handlers/Text.php index e370964dd..faceb937e 100644 --- a/src/Providers/OpenAI/Handlers/Text.php +++ b/src/Providers/OpenAI/Handlers/Text.php @@ -147,7 +147,9 @@ protected function sendRequest(Request $request): ClientResponse 'verbosity' => $request->providerOptions('text_verbosity'), ] : null, 'truncation' => $request->providerOptions('truncation'), - 'reasoning' => $request->providerOptions('reasoning'), + 'reasoning' => $request->providerOptions('reasoning') ?? ( + $request->reasoningEnabled() === false ? ['effort' => 'minimal'] : null + ), 'store' => $request->providerOptions('store'), ])) ); diff --git a/src/Providers/OpenRouter/Concerns/BuildsRequestOptions.php b/src/Providers/OpenRouter/Concerns/BuildsRequestOptions.php index 3124581a5..2759952f6 100644 --- a/src/Providers/OpenRouter/Concerns/BuildsRequestOptions.php +++ b/src/Providers/OpenRouter/Concerns/BuildsRequestOptions.php @@ -31,6 +31,10 @@ protected function buildRequestOptions(TextRequest|StructuredRequest $request, a 'tool_choice' => ToolChoiceMap::map($request->toolChoice()), ])); + if ($request->reasoningEnabled() === false && ! isset($options['reasoning'])) { + $options['reasoning'] = ['exclude' => true]; + } + return Arr::whereNotNull(array_merge($options, $additional)); } } diff --git a/src/Providers/Perplexity/Concerns/HandlesHttpRequests.php b/src/Providers/Perplexity/Concerns/HandlesHttpRequests.php index 0022e0ea0..1c063ffed 100644 --- a/src/Providers/Perplexity/Concerns/HandlesHttpRequests.php +++ b/src/Providers/Perplexity/Concerns/HandlesHttpRequests.php @@ -49,7 +49,9 @@ protected function buildHttpRequestPayload(TextRequest|StructuredRequest $reques 'temperature' => $request->temperature(), 'top_p' => $request->topP(), 'top_k' => $request->providerOptions('top_k'), - 'reasoning_effort' => $request->providerOptions('reasoning_effort'), + 'reasoning_effort' => $request->providerOptions('reasoning_effort') ?? ( + $request->reasoningEnabled() === false ? 'low' : null + ), 'web_search_options' => $request->providerOptions('web_search_options'), 'search_mode' => $request->providerOptions('search_mode'), 'language_preference' => $request->providerOptions('language_preference'), diff --git a/src/Structured/PendingRequest.php b/src/Structured/PendingRequest.php index 063a85def..289bc7f11 100644 --- a/src/Structured/PendingRequest.php +++ b/src/Structured/PendingRequest.php @@ -15,6 +15,7 @@ use Prism\Prism\Concerns\HasPrompts; use Prism\Prism\Concerns\HasProviderOptions; use Prism\Prism\Concerns\HasProviderTools; +use Prism\Prism\Concerns\HasReasoning; use Prism\Prism\Concerns\HasSchema; use Prism\Prism\Concerns\HasTools; use Prism\Prism\Contracts\Schema; @@ -33,6 +34,7 @@ class PendingRequest use HasPrompts; use HasProviderOptions; use HasProviderTools; + use HasReasoning; use HasSchema; use HasTools; @@ -89,6 +91,7 @@ public function toRequest(): Request maxSteps: $this->maxSteps, providerOptions: $this->providerOptions, providerTools: $this->providerTools, + reasoningEnabled: $this->reasoningEnabled, ); } } diff --git a/src/Structured/Request.php b/src/Structured/Request.php index 7f9675392..e6f9f7b36 100644 --- a/src/Structured/Request.php +++ b/src/Structured/Request.php @@ -7,6 +7,7 @@ use Closure; use Prism\Prism\Concerns\ChecksSelf; use Prism\Prism\Concerns\HasProviderOptions; +use Prism\Prism\Concerns\HasReasoning; use Prism\Prism\Contracts\Message; use Prism\Prism\Contracts\PrismRequest; use Prism\Prism\Contracts\Schema; @@ -18,7 +19,7 @@ class Request implements PrismRequest { - use ChecksSelf, HasProviderOptions; + use ChecksSelf, HasProviderOptions, HasReasoning; /** * @param SystemMessage[] $systemPrompts @@ -47,8 +48,10 @@ public function __construct( protected int $maxSteps, array $providerOptions = [], protected array $providerTools = [], + ?bool $reasoningEnabled = null, ) { $this->providerOptions = $providerOptions; + $this->reasoningEnabled = $reasoningEnabled; } /** diff --git a/src/Text/PendingRequest.php b/src/Text/PendingRequest.php index c9807ee32..1eec66654 100644 --- a/src/Text/PendingRequest.php +++ b/src/Text/PendingRequest.php @@ -17,6 +17,7 @@ use Prism\Prism\Concerns\HasPrompts; use Prism\Prism\Concerns\HasProviderOptions; use Prism\Prism\Concerns\HasProviderTools; +use Prism\Prism\Concerns\HasReasoning; use Prism\Prism\Concerns\HasTools; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Streaming\Adapters\BroadcastAdapter; @@ -38,6 +39,7 @@ class PendingRequest use HasPrompts; use HasProviderOptions; use HasProviderTools; + use HasReasoning; use HasTools; /** @@ -146,6 +148,7 @@ public function toRequest(): Request toolChoice: $this->toolChoice, providerOptions: $this->providerOptions, providerTools: $this->providerTools, + reasoningEnabled: $this->reasoningEnabled, ); } } diff --git a/src/Text/Request.php b/src/Text/Request.php index fc3355513..e3c199373 100644 --- a/src/Text/Request.php +++ b/src/Text/Request.php @@ -7,6 +7,7 @@ use Closure; use Prism\Prism\Concerns\ChecksSelf; use Prism\Prism\Concerns\HasProviderOptions; +use Prism\Prism\Concerns\HasReasoning; use Prism\Prism\Contracts\Message; use Prism\Prism\Contracts\PrismRequest; use Prism\Prism\Enums\ToolChoice; @@ -16,7 +17,7 @@ class Request implements PrismRequest { - use ChecksSelf, HasProviderOptions; + use ChecksSelf, HasProviderOptions, HasReasoning; /** * @param SystemMessage[] $systemPrompts @@ -43,8 +44,10 @@ public function __construct( protected string|ToolChoice|null $toolChoice, array $providerOptions = [], protected array $providerTools = [], + ?bool $reasoningEnabled = null, ) { $this->providerOptions = $providerOptions; + $this->reasoningEnabled = $reasoningEnabled; } public function toolChoice(): string|ToolChoice|null diff --git a/tests/Providers/Anthropic/AnthropicTextRequestTest.php b/tests/Providers/Anthropic/AnthropicTextRequestTest.php index b7286d0e0..a5c23de11 100644 --- a/tests/Providers/Anthropic/AnthropicTextRequestTest.php +++ b/tests/Providers/Anthropic/AnthropicTextRequestTest.php @@ -196,6 +196,43 @@ }); }); +it('omits thinking when withReasoning(false) is used even with thinking.enabled', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withMessages([new UserMessage('Test')]) + ->withProviderOptions(['thinking' => ['enabled' => true]]) + ->withReasoning(false) + ->asText(); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data(); + + expect($payload)->not->toHaveKey('thinking'); + + return true; + }); +}); + +it('does not include thinking when withReasoning(false) on a non-thinking model', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); + + Prism::text() + ->using(Provider::Anthropic, 'claude-3-5-haiku-latest') + ->withMessages([new UserMessage('Test')]) + ->withReasoning(false) + ->asText(); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data(); + + expect($payload)->not->toHaveKey('thinking'); + + return true; + }); +}); + it('sends correct thinking mode with default budget tokens', function (): void { FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); diff --git a/tests/Providers/Ollama/StreamTest.php b/tests/Providers/Ollama/StreamTest.php index cfd2721a1..d223c1779 100644 --- a/tests/Providers/Ollama/StreamTest.php +++ b/tests/Providers/Ollama/StreamTest.php @@ -187,6 +187,28 @@ }); }); +it('sends think:false on stream when withReasoning(false) is used', function (): void { + FixtureResponse::fakeStreamResponses('api/chat', 'ollama/stream-without-thinking'); + + $response = Prism::text() + ->using('ollama', 'gpt-oss') + ->withPrompt('Test prompt') + ->withReasoning(false) + ->asStream(); + + foreach ($response as $chunk) { + break; + } + + Http::assertSent(function (Request $request): true { + $body = $request->data(); + expect($body)->toHaveKey('think'); + expect($body['think'])->toBe(false); + + return true; + }); +}); + it('includes keep_alive parameter when provided for streaming', function (): void { FixtureResponse::fakeStreamResponses('api/chat', 'ollama/stream-without-thinking'); diff --git a/tests/Providers/Ollama/TextTest.php b/tests/Providers/Ollama/TextTest.php index 63ddf9528..0c0b2beeb 100644 --- a/tests/Providers/Ollama/TextTest.php +++ b/tests/Providers/Ollama/TextTest.php @@ -148,6 +148,43 @@ return true; }); }); + + it('sends think:false when withReasoning(false) is used', function (): void { + FixtureResponse::fakeResponseSequence('api/chat', 'ollama/text-without-thinking'); + + Prism::text() + ->using('ollama', 'gpt-oss') + ->withPrompt('Test prompt') + ->withReasoning(false) + ->asText(); + + Http::assertSent(function (Request $request): true { + $body = $request->data(); + expect($body)->toHaveKey('think'); + expect($body['think'])->toBe(false); + + return true; + }); + }); + + it('honors explicit thinking provider option over withReasoning(false)', function (): void { + FixtureResponse::fakeResponseSequence('api/chat', 'ollama/text-with-thinking-enabled'); + + Prism::text() + ->using('ollama', 'gpt-oss') + ->withPrompt('Test prompt') + ->withProviderOptions(['thinking' => true]) + ->withReasoning(false) + ->asText(); + + Http::assertSent(function (Request $request): true { + $body = $request->data(); + expect($body)->toHaveKey('think'); + expect($body['think'])->toBe(true); + + return true; + }); + }); }); describe('Keep alive parameter', function (): void { diff --git a/tests/Providers/OpenAI/TextTest.php b/tests/Providers/OpenAI/TextTest.php index 7055ec614..a5827b30e 100644 --- a/tests/Providers/OpenAI/TextTest.php +++ b/tests/Providers/OpenAI/TextTest.php @@ -598,6 +598,46 @@ ]); }); +it('maps withReasoning(false) to reasoning.effort=minimal', function (): void { + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/text-reasoning-effort'); + + Prism::text() + ->using('openai', 'gpt-5') + ->withPrompt('Who are you?') + ->withReasoning(false) + ->asText(); + + Http::assertSent(fn (Request $request): bool => $request->data()['reasoning']['effort'] === 'minimal'); +}); + +it('does not include reasoning key when withReasoning is not called', function (): void { + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/text-reasoning-effort'); + + Prism::text() + ->using('openai', 'gpt-5') + ->withPrompt('Who are you?') + ->asText(); + + Http::assertSent(function (Request $request): true { + expect($request->data())->not->toHaveKey('reasoning'); + + return true; + }); +}); + +it('honors explicit reasoning provider option over withReasoning(false)', function (): void { + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/text-reasoning-effort'); + + Prism::text() + ->using('openai', 'gpt-5') + ->withPrompt('Who are you?') + ->withProviderOptions(['reasoning' => ['effort' => 'high']]) + ->withReasoning(false) + ->asText(); + + Http::assertSent(fn (Request $request): bool => $request->data()['reasoning']['effort'] === 'high'); +}); + describe('provider tool results', function (): void { it('captures web search provider tool in providerToolCalls', function (): void { FixtureResponse::fakeResponseSequence('v1/responses', 'openai/generate-text-with-web-search-citations');