Skip to content

OpenRouter: reasoning: { enabled: false } is stripped to reasoning: {}, which re-enables reasoning #1006

Description

@daveycodez

Updated after further testing. The original report claimed reasoning could not be disabled at all through this adapter. That was too strong — reasoning: { effort: 'none' } works today and is a complete workaround. The real defect is narrower, and is described below. Apologies for the noise.

Summary

Passing reasoning: { enabled: false } through modelOptions does not disable reasoning. The bundled @openrouter/sdk@0.13.20 types ChatRequestReasoning as effort + summary only, so the enabled key is stripped by the SDK's outbound schema — and what actually goes on the wire is reasoning: {}, an empty object.

That empty object is worse than sending nothing: measured on qwen/qwen3.7-flash, omitting reasoning produced 585 reasoning tokens while reasoning: {} produced 730. The caller asked for reasoning off and got more reasoning than the default, with no error and no warning.

Versions: @tanstack/ai@0.42.0, @tanstack/ai-openrouter@0.15.10, @openrouter/sdk@0.13.20.

There is a working workaround

reasoning: { effort: 'none' } disables reasoning completely and survives the adapter intact. Anyone hitting this should use it:

await chat({
  adapter: openRouterText('qwen/qwen3.7-flash'),
  messages: [...],
  modelOptions: { reasoning: { effort: 'none' } },  // works — 0 reasoning tokens
})

ChatRequestEffort includes a None: "none" member, so this is fully expressible under the current types. It is just not obvious that effort: 'none' is the off switch when the provider's own API documents enabled: false.

Evidence

What reaches the wire

Patching globalThis.fetch before importing the adapter, then calling chat() through the real openRouterText path:

modelOptions.reasoning body on the wire
omitted (no reasoning key)
{ effort: 'none' } {"reasoning":{"effort":"none"}}
{ enabled: false } {"reasoning":{}} ← key stripped, empty object sent

Confirmed independently against the SDK's outbound schema in isolation:

ChatRequest$outboundSchema.parse({ ..., reasoning: { effort: 'none' } })   -> {"effort":"none"}
ChatRequest$outboundSchema.parse({ ..., reasoning: { enabled: false } })   -> {}

Behavioural effect

Direct to OpenRouter, qwen/qwen3.7-flash, identical prompt, max_tokens: 300:

request reasoning tokens completion tokens
reasoning omitted 585 750
reasoning: { effort: 'none' } 0 236
reasoning: { effort: 'low' } 429 517
reasoning: { enabled: false } 0 247
reasoning: {}what the adapter sends 730 882

So enabled: false is correct at the API level; it simply never survives the SDK schema, and the empty object it degrades into re-enables reasoning.

(Single sample per row — the 0 vs. 585/730 split is far outside sampling noise, but the exact counts will vary.)

The type

node_modules/@openrouter/sdk/esm/models/chatrequest.d.ts:

export type ChatRequestReasoning = {
  effort?: ChatRequestEffort | null | undefined;
  summary?: ChatReasoningSummaryVerbosityEnum | null | undefined;
};

reasoning is a caller-settable key (ai-openrouter/src/text/text-provider-options.ts, in OpenRouterBaseOptions) and flows through mapOptionsToRequest via ...restModelOptions, so the adapter passes it along faithfully. The loss happens inside the SDK's zod validation.

Suggestions

  1. Don't send an empty reasoning object. If schema validation strips every key from reasoning, omitting the field entirely is strictly better than sending {} — it would at least fall back to the model default (585) rather than 730. This is the part that is actionable purely inside this repo, independent of the SDK.
  2. Document effort: 'none' as the way to disable reasoning through this adapter. It is not discoverable from the type, and OpenRouter's own docs point users at enabled: false.
  3. Consider surfacing stripped modelOptions keys. A caller-set key vanishing silently inside SDK validation cost a bisect against raw HTTP to find. A dev-mode warning when a modelOptions key does not survive would have made it immediate. This generalises beyond reasoning.
  4. Optionally, accept enabled and translate it to effort: 'none' at the adapter boundary, so code written against OpenRouter's documented API behaves as expected.

Notes on what I verified

  • Verified in source: the ChatRequestReasoning / ChatRequestEffort definitions, and that reasoning reaches mapOptionsToRequest as a caller-settable key.
  • Verified by direct execution: the outbound-schema parse results, the on-the-wire bodies through the real adapter, and every row of the token table.
  • Not verified: whether other providers' reasoning models behave the same way; all measurements are qwen/qwen3.7-flash.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions