Skip to content

Add Reasoning attribute for disabling reasoning across providers#486

Closed
ChrisThompsonTLDR wants to merge 1 commit into
laravel:0.xfrom
ChrisThompsonTLDR:feature/reasoning-attribute
Closed

Add Reasoning attribute for disabling reasoning across providers#486
ChrisThompsonTLDR wants to merge 1 commit into
laravel:0.xfrom
ChrisThompsonTLDR:feature/reasoning-attribute

Conversation

@ChrisThompsonTLDR

@ChrisThompsonTLDR ChrisThompsonTLDR commented May 2, 2026

Copy link
Copy Markdown

Summary

Adds a #[Reasoning(false)] class attribute (and matching reasoning() agent method) so an agent can express "skip reasoning / thinking" once and have each gateway translate it to the right wire-format key.

Why

Each provider exposes reasoning / thinking through a different field: Ollama uses think, OpenAI uses reasoning.effort, Anthropic uses a thinking block, Gemini uses thinkingConfig.thinkingBudget, OpenRouter uses reasoning.exclude. To disable reasoning today, an agent has to know each provider's wire format and hand-roll it via providerOptions(). Code that's portable across providers (an agent that can run against an OpenAI or an Ollama backend at runtime) ends up maintaining a translation table.

This PR puts that table inside the SDK.

Per-provider mapping when reasoning is disabled

Provider Wire format
Ollama top-level think: false
OpenAI reasoning: { effort: "minimal" }
Anthropic strips any thinking provider option
Gemini generationConfig.thinkingConfig.thinkingBudget = 0
OpenRouter reasoning: { exclude: true }

Other providers (Mistral, Groq, DeepSeek, xAI, Bedrock) treat the attribute as a graceful no-op — no error, no extra key sent.

Example

use Laravel\Ai\Attributes\Reasoning;

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

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

The same can be expressed via a method, mirroring how temperature() / maxSteps() work today:

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

The method takes priority over the attribute, matching the resolution order in TextGenerationOptions::forAgent().

Backward compatibility

Fully additive. If #[Reasoning] is never declared and no reasoning() method exists, requests are built exactly as today. Existing providerOptions() keys (think, reasoning, thinkingConfig) still win — the new logic only fills in the default when the corresponding key is not already set.

Tests

  • tests/Feature/AgentAttributeTest.php: attribute resolution, method takes priority, null when neither is set
  • tests/Feature/Providers/Ollama/RequestMappingTest.php: think: false is sent at the top level when reasoning is disabled; key is absent otherwise
  • tests/Feature/Providers/OpenAi/RequestMappingTest.php: reasoning.effort = minimal is sent when reasoning is disabled; key is absent otherwise

vendor/bin/pest --exclude-group=integration reports 1012 passing / 1 pre-existing skipped. (3 integration tests in AgentReasoningTest fail on the base branch as well due to a missing pdo_sqlite extension in this environment — unrelated to this change.)

vendor/bin/pint --test is clean for every file changed by this PR.

Adds a `#[Reasoning(false)]` class attribute (and matching `reasoning()`
agent method) that lets an agent express "skip reasoning / thinking"
once and have each gateway translate it to the right wire-format key.

Per-provider mapping when reasoning is disabled:

| Provider   | Wire format                            |
|------------|----------------------------------------|
| Ollama     | top-level `think: false`               |
| OpenAI     | `reasoning: { effort: "minimal" }`     |
| Anthropic  | strips any `thinking` provider option  |
| Gemini     | `generationConfig.thinkingConfig.thinkingBudget = 0` |
| OpenRouter | `reasoning: { exclude: true }`         |

Other providers (Mistral, Groq, DeepSeek, xAI, Bedrock) treat the
attribute as a graceful no-op. Existing `providerOptions()` overrides
continue to win, so the change is fully backward compatible.

The attribute mirrors the resolution rules of `#[Temperature]`,
`#[MaxSteps]` etc.: an `reasoning()` method on the agent takes
precedence over the class attribute.
@pushpak1300

Copy link
Copy Markdown
Member

Thanks for your pull request!

Unfortunately, I'm going to delay merging this code for now. To preserve our ability to adequately maintain this package, we need to be very careful regarding the amount of code we include.

The core issue is that reasoning is fundamentally tied to the model name, not the agent. An agent declaring #[Reasoning(false)] is meaningless on GPT-4o but significant on gpt-5.4 — and the agent doesn't control which model runs at runtime. Unlike temperature or maxTokens, reasoning isn't a universal parameter.

Additionally, reasoning configuration is more nuanced than a boolean: OpenAI uses effort levels, Anthropic uses budget_tokens, and Gemini uses thinkingBudget. A simple on/off switch doesn't map cleanly to real-world APIs, so users will likely fall back to providerOptions() anyway — which is exactly the escape hatch we already provide.

For these reasons, we think HasProviderOptions remains the right boundary for this concern.

@pushpak1300 pushpak1300 closed this May 2, 2026
@ChrisThompsonTLDR

Copy link
Copy Markdown
Author

Thanks so much for the careful review and for taking the time to explain the rationale — points 2 and 3 are well-taken. The boolean genuinely doesn't capture effort or budget_tokens, and providerOptions() is a perfectly good escape hatch for callers who need that level of control.

If keeping the door cracked open is at all considered, a couple of related observations might be worth weighing:

On the model-coupling concern: a similar coupling is already accepted for #[Temperature] and #[MaxTokens]. A temperature of 1.0 is reasonable on some models and harsh on others; max-tokens caps vary by model. Both are still supported as agent-level declarations, presumably because the convenience of declaring a sensible default once outweighs the mismatch risk. A similar argument might apply here — Reasoning(false) is a useful default for agents that orchestrate many sequential tool calls (where hidden CoT compounds across steps), even if the agent's model can be overridden at runtime.

On the boolean limitation: the current attribute is the smallest useful step, but it doesn't preclude a richer form being added later. A future Reasoning::Off / Reasoning::low() / Reasoning::budget(2048) shape could be layered in additively, and the resolution path can read either form. The bool is the degenerate case, but it covers the most common ask (skip CoT entirely).

On the wire-format side: there's a related PR open at prism-php/prism#1018 that adds withReasoning() to Prism with a similar cross-provider translation table — Ollama gets think, OpenAI gets effort: "minimal", Anthropic strips the thinking block, Gemini gets thinkingConfig. If Prism is going to centralize that table for direct users of Prism, having a parallel (even if narrower) surface in laravel/ai keeps an agent's declared intent working consistently across both worlds.

One operational note on providerOptions(): in practice, providerOptions(['think' => false]) is silently ignored on the Ollama wire today — the gateway routes provider options into body.options.*, but Ollama's think flag has to be top-level at body.think. The only path that currently puts it in the right place is the reasoning field. If the attribute is the controversial piece but the gateway-level top-level-think write is acceptable independently, splitting the PR into a smaller gateway-only change might be a workable middle ground — happy to do that.

Whatever direction is preferred is appreciated either way. Just wanted to surface the evidence before this closed permanently.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants