diff --git a/src/Attributes/Reasoning.php b/src/Attributes/Reasoning.php new file mode 100644 index 000000000..395b2ec4b --- /dev/null +++ b/src/Attributes/Reasoning.php @@ -0,0 +1,14 @@ +providerOptions(Lab::Anthropic) ?? []; + if ($options?->reasoning === false) { + unset($providerOptions['thinking']); + } + if (filled($schema) && $this->supportsNativeStructuredOutput($provider)) { $body['output_config'] = [ 'format' => [ diff --git a/src/Gateway/Gemini/Concerns/BuildsTextRequests.php b/src/Gateway/Gemini/Concerns/BuildsTextRequests.php index 4ba048513..5d7947dda 100644 --- a/src/Gateway/Gemini/Concerns/BuildsTextRequests.php +++ b/src/Gateway/Gemini/Concerns/BuildsTextRequests.php @@ -92,6 +92,10 @@ private function assembleRequestBody( $generationConfig = array_merge($generationConfig, $providerOptions); } + if ($options?->reasoning === false && ! array_key_exists('thinkingConfig', $generationConfig)) { + $generationConfig['thinkingConfig'] = ['thinkingBudget' => 0]; + } + if (filled($generationConfig)) { $body['generationConfig'] = $generationConfig; } diff --git a/src/Gateway/Ollama/Concerns/BuildsTextRequests.php b/src/Gateway/Ollama/Concerns/BuildsTextRequests.php index 80986b5ce..2b46d841e 100644 --- a/src/Gateway/Ollama/Concerns/BuildsTextRequests.php +++ b/src/Gateway/Ollama/Concerns/BuildsTextRequests.php @@ -75,6 +75,10 @@ protected function buildChatRequestBody( $body['options'] = $mergedOptions; } + if ($options?->reasoning === false && ! array_key_exists('think', $body)) { + $body['think'] = false; + } + return $body; } diff --git a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php index ea3dd3196..eacf98c11 100644 --- a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php +++ b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php @@ -44,6 +44,10 @@ protected function buildTextRequestBody( 'top_p' => $options?->topP, ])); + if ($options?->reasoning === false) { + $body['reasoning'] = ['effort' => 'minimal']; + } + $providerOptions = $options?->providerOptions( Lab::tryFrom($provider->driver()) ?? $provider->driver() ); diff --git a/src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php b/src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php index 596e1ea60..d856874ad 100644 --- a/src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php +++ b/src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php @@ -54,6 +54,10 @@ protected function buildTextRequestBody( $body = array_merge($body, $providerOptions); } + if ($options?->reasoning === false && ! array_key_exists('reasoning', $body)) { + $body['reasoning'] = ['exclude' => true]; + } + return $body; } diff --git a/src/Gateway/TextGenerationOptions.php b/src/Gateway/TextGenerationOptions.php index ef49dc91d..a93973649 100644 --- a/src/Gateway/TextGenerationOptions.php +++ b/src/Gateway/TextGenerationOptions.php @@ -4,6 +4,7 @@ use Laravel\Ai\Attributes\MaxSteps; use Laravel\Ai\Attributes\MaxTokens; +use Laravel\Ai\Attributes\Reasoning; use Laravel\Ai\Attributes\Temperature; use Laravel\Ai\Attributes\TopP; use Laravel\Ai\Contracts\Agent; @@ -19,6 +20,7 @@ public function __construct( public readonly ?float $temperature = null, public readonly ?Agent $agent = null, public readonly ?float $topP = null, + public readonly ?bool $reasoning = null, ) { // } @@ -50,6 +52,7 @@ public static function forAgent(Agent $agent): self temperature: self::resolve($agent, $reflection, 'temperature', Temperature::class), agent: $agent, topP: self::resolve($agent, $reflection, 'topP', TopP::class), + reasoning: self::resolveReasoning($agent, $reflection), ); } @@ -76,4 +79,26 @@ private static function resolve(Agent $agent, ReflectionClass $reflection, strin return ! empty($attributes) ? $attributes[0]->newInstance()->value : null; } + + /** + * Resolve the reasoning preference from a `reasoning()` method or a `#[Reasoning]` attribute. + */ + private static function resolveReasoning(Agent $agent, ReflectionClass $reflection): ?bool + { + if (method_exists($agent, 'reasoning')) { + try { + $value = $agent->reasoning(); + } catch (\ArgumentCountError|\Error) { + $value = null; + } + + if (! is_null($value)) { + return (bool) $value; + } + } + + $attributes = $reflection->getAttributes(Reasoning::class); + + return ! empty($attributes) ? $attributes[0]->newInstance()->enabled : null; + } } diff --git a/tests/Feature/AgentAttributeTest.php b/tests/Feature/AgentAttributeTest.php index 797f15c21..851439ebf 100644 --- a/tests/Feature/AgentAttributeTest.php +++ b/tests/Feature/AgentAttributeTest.php @@ -9,6 +9,8 @@ use Tests\Fixtures\Agents\MethodOptionsAgent; use Tests\Fixtures\Agents\MethodOverridesAttributeAgent; use Tests\Fixtures\Agents\NullableMethodOptionsAgent; +use Tests\Fixtures\Agents\ReasoningDisabledAgent; +use Tests\Fixtures\Agents\ReasoningMethodAgent; test('text generation options can be created from agent attributes', function () { $options = TextGenerationOptions::forAgent(new AttributeAgent); @@ -60,6 +62,24 @@ ->and($options->temperature)->toBe(0.9); }); +test('reasoning attribute is read from agent class', function () { + $options = TextGenerationOptions::forAgent(new ReasoningDisabledAgent); + + expect($options->reasoning)->toBeFalse(); +}); + +test('reasoning method takes priority over attribute', function () { + $options = TextGenerationOptions::forAgent(new ReasoningMethodAgent); + + expect($options->reasoning)->toBeFalse(); +}); + +test('reasoning is null when neither attribute nor method is provided', function () { + $options = TextGenerationOptions::forAgent(new AssistantAgent); + + expect($options->reasoning)->toBeNull(); +}); + test('provider attribute is used when prompting', function () { AttributeAgent::fake(); diff --git a/tests/Feature/Providers/Ollama/RequestMappingTest.php b/tests/Feature/Providers/Ollama/RequestMappingTest.php index fea9b5223..1893390be 100644 --- a/tests/Feature/Providers/Ollama/RequestMappingTest.php +++ b/tests/Feature/Providers/Ollama/RequestMappingTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\AssistantAgent; use Tests\Fixtures\Agents\AttributeAgent; +use Tests\Fixtures\Agents\ReasoningDisabledAgent; use Tests\Fixtures\Agents\StructuredAgent; use Tests\Fixtures\Tools\RandomNumberGenerator; @@ -166,6 +167,30 @@ expect($response->structured['symbol'])->toBe('Au'); }); +test('reasoning-disabled agent sends think:false at the request top level', function () { + Http::fake(['*' => $this->fakeTextResponse('Hello')]); + + (new ReasoningDisabledAgent)->prompt('Hello', provider: 'ollama'); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + + return array_key_exists('think', $body) && $body['think'] === false; + }); +}); + +test('agents without reasoning attribute do not send think key', function () { + Http::fake(['*' => $this->fakeTextResponse('Hello')]); + + agent()->prompt('Hello', provider: 'ollama'); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + + return ! array_key_exists('think', $body); + }); +}); + test('request is sent to the chat endpoint', function () { Http::fake(['*' => $this->fakeTextResponse('Hello')]); diff --git a/tests/Feature/Providers/OpenAi/RequestMappingTest.php b/tests/Feature/Providers/OpenAi/RequestMappingTest.php index 843709609..89a14e593 100644 --- a/tests/Feature/Providers/OpenAi/RequestMappingTest.php +++ b/tests/Feature/Providers/OpenAi/RequestMappingTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\AssistantAgent; use Tests\Fixtures\Agents\AttributeAgent; +use Tests\Fixtures\Agents\ReasoningDisabledAgent; use Tests\Fixtures\Agents\StructuredAgent; use Tests\Fixtures\Tools\RandomNumberGenerator; @@ -179,3 +180,27 @@ expect($response->structured['symbol'])->toBe('Au'); }); + +test('reasoning-disabled agent sends reasoning effort minimal', function () { + Http::fake(['*' => fakeOpenAiResponse('Hello')]); + + (new ReasoningDisabledAgent)->prompt('Hello', provider: 'openai'); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + + return data_get($body, 'reasoning.effort') === 'minimal'; + }); +}); + +test('agents without reasoning attribute do not send reasoning key', function () { + Http::fake(['*' => fakeOpenAiResponse('Hello')]); + + agent()->prompt('Hello', provider: 'openai'); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + + return ! array_key_exists('reasoning', $body); + }); +}); diff --git a/tests/Fixtures/Agents/ReasoningDisabledAgent.php b/tests/Fixtures/Agents/ReasoningDisabledAgent.php new file mode 100644 index 000000000..58d658f61 --- /dev/null +++ b/tests/Fixtures/Agents/ReasoningDisabledAgent.php @@ -0,0 +1,18 @@ +