Skip to content
Closed
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
14 changes: 14 additions & 0 deletions src/Attributes/Reasoning.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Laravel\Ai\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
class Reasoning
{
public function __construct(public bool $enabled = true)
{
//
}
}
4 changes: 4 additions & 0 deletions src/Gateway/Anthropic/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ protected function buildTextRequestBody(

$providerOptions = $options?->providerOptions(Lab::Anthropic) ?? [];

if ($options?->reasoning === false) {
unset($providerOptions['thinking']);
}

if (filled($schema) && $this->supportsNativeStructuredOutput($provider)) {
$body['output_config'] = [
'format' => [
Expand Down
4 changes: 4 additions & 0 deletions src/Gateway/Gemini/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/Gateway/Ollama/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ protected function buildChatRequestBody(
$body['options'] = $mergedOptions;
}

if ($options?->reasoning === false && ! array_key_exists('think', $body)) {
$body['think'] = false;
}

return $body;
}

Expand Down
4 changes: 4 additions & 0 deletions src/Gateway/OpenAi/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
Expand Down
4 changes: 4 additions & 0 deletions src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
25 changes: 25 additions & 0 deletions src/Gateway/TextGenerationOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
//
}
Expand Down Expand Up @@ -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),
);
}

Expand All @@ -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;
}
}
20 changes: 20 additions & 0 deletions tests/Feature/AgentAttributeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();

Expand Down
25 changes: 25 additions & 0 deletions tests/Feature/Providers/Ollama/RequestMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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')]);

Expand Down
25 changes: 25 additions & 0 deletions tests/Feature/Providers/OpenAi/RequestMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
});
});
18 changes: 18 additions & 0 deletions tests/Fixtures/Agents/ReasoningDisabledAgent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Tests\Fixtures\Agents;

use Laravel\Ai\Attributes\Reasoning;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;

#[Reasoning(false)]
class ReasoningDisabledAgent implements Agent
{
use Promptable;

public function instructions(): string
{
return 'You are a helpful assistant.';
}
}
21 changes: 21 additions & 0 deletions tests/Fixtures/Agents/ReasoningMethodAgent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Tests\Fixtures\Agents;

use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;

class ReasoningMethodAgent implements Agent
{
use Promptable;

public function instructions(): string
{
return 'You are a helpful assistant.';
}

public function reasoning(): bool
{
return false;
}
}