laravel/ai: Replay OpenRouter reasoning details in tool loops
Contributed by CamilleScholtz
Part of the ai v1.2.1 release
If you run reasoning models through OpenRouter, every turn of a tool loop has been starting from scratch: the reasoning that produced each tool call was read for reasoning events and then dropped before the next request. PR #1110 fixes that. The OpenRouter gateway now captures reasoning_details from every text step — streamed or not — and replays them, unmodified, on the assistant message that carries tool calls in the follow-up request. That is exactly what OpenRouter's reasoning tokens documentation asks for. The change mirrors the DeepSeek gateway, which already replays reasoning_content the same way.
What changed
The fix is three coordinated changes inside the OpenRouter gateway:
-
Non-streamed steps keep their details.
parseTextResponsenow extracts the message'sreasoning_detailsarray and stores it on the step asreplayBlocks, alongside the existingreasoningtext that powers reasoning events. -
Streamed fragments are merged into whole details. The streaming handler accumulates
reasoning_detailsdeltas in a newmergeReasoningDetailshelper. Fragments sharing anindexare combined:text,summary, anddataare concatenated, while fields such asid,format, andsignaturekeep the last non-null value they receive. The merged array becomes the step'sreplayBlocks, so a streamed step ends up with exactly the details a non-streamed response would have returned — including a signature that arrives in a later chunk than the text it signs. -
Follow-up requests carry the details. A new
OpenRouter\Concerns\MapsMessagestrait replaces the shared Chat Completions mapper onOpenRouterGateway. It delegates to the shared mapper, then addsreasoning_detailsto the outgoing assistant message — but only when that message has tool calls and replay blocks. Messages without replay blocks are mapped precisely as before.
Two earlier attempts at this behaviour (#813 and #1007) were closed without merging; this PR lands it for OpenRouter.
Why it matters
Reasoning models behind OpenRouter don't return their thinking as plain text you can rebuild. For OpenAI models the detail is an encrypted reasoning.encrypted block keyed by an rs_… id; for Anthropic models it is a signed reasoning.text. Both are opaque: once the gateway drops them, neither your application nor the provider can reconstruct the reasoning.
In a tool loop that matters on every step. The model responds with tool calls plus the reasoning that led to them; your app runs the tools and the conversation continues. If the replayed assistant message contains tool calls but no reasoning, the model starts over — re-deriving its plan, spending extra reasoning tokens you pay for, and occasionally changing its mind mid-loop. OpenRouter's docs are explicit that the details should be sent back unmodified on the assistant message carrying the tool calls.
The author verified this against the live API with openai/gpt-6-luna: a streamed step returned summary fragments plus one reasoning.encrypted detail, and OpenRouter accepted the merged details back on every follow-up across a four-step tool loop.
What you'll see in requests
Nothing about your agent code changes:
use function Laravel\Ai\agent;
$answer = agent(tools: [new FixedNumberGenerator])
->prompt('Give me a number', provider: 'openrouter');
When the first step ends with tool calls, the assistant message in the second request now looks like this:
{
"role": "assistant",
"content": null,
"reasoning_details": [
{ "type": "reasoning.summary", "summary": "I need a number.", "format": "openai-responses-v1", "index": 0 },
{ "type": "reasoning.encrypted", "data": "ciphertext", "id": "rs_123", "format": "openai-responses-v1", "index": 1 }
],
"tool_calls": [
{ "id": "call_123", "type": "function", "function": { "name": "FixedNumberGenerator", "arguments": "{}" } }
]
}
Before this change, the same message went out without reasoning_details.
Upgrade impact
There are no migration steps. The replay happens inside the gateway whenever an OpenRouter step ends with tool calls, for both streamed and non-streamed requests, so upgrading changes behaviour rather than API. A few practical notes:
- Payloads grow slightly. Summaries and encrypted blocks now ride along on each follow-up request. That overhead is intentional — OpenRouter needs the details back as-is.
- Remove workarounds. If you compensated for the per-step reset by re-prompting with extra context or manually replaying reasoning, you can drop that code.
- Serialization side effects. Assistant messages you persist or log may now carry replay blocks. That's harmless locally, and it's what OpenRouter expects on the way back in.
- No change without reasoning. Tool calls that never had reasoning details are still replayed without them, and messages without replay blocks map exactly as in v1.2.0 — both cases are covered by tests.
Takeaways
- OpenRouter tool loops now replay
reasoning_detailsunmodified on the assistant message that carries tool calls, as OpenRouter's reasoning tokens docs require. - Streamed fragments are merged by
index—text,summary, anddataconcatenated;id,format, andsignaturekeep their last value — so streamed steps replay the same details as non-streamed ones. - No config or code changes are needed to upgrade from v1.2.0; expect slightly larger follow-up payloads in tool loops.
- The behaviour mirrors the DeepSeek gateway's existing
reasoning_contentreplay, keeping reasoning-capable providers consistent.