fix(llm): send providerPreferences to OpenRouter as the request's provider routing - #413
Merged
Merged
Conversation
…vider routing
`llm.providers[].providerPreferences` has been parsed and validated
since the config fix that taught the loader about it, but nothing ever
put it on the wire. An operator who pinned OpenRouter's upstream host —
`{ "order": ["z-ai"], "allow_fallbacks": false }` — kept being routed
to whichever host OpenRouter picked, and structured-output sub-calls
the pinned host cannot serve kept "working" because they ran somewhere
else. The same object under `extraBody.provider` was honoured, which is
how the gap was found.
The field is now threaded exactly like `extraBody` and `strictTools`:
entry -> `openrouter` factory -> `OpenAiProvider` -> the request body,
where it becomes the `provider` object on every chat completion the
entry makes: turns and sub-calls, streaming and unary, and
`vision.describe` (routing is where `data_collection`, `only` and
`ignore` live, and those matter most for the operator's images).
Only the `openrouter` factory forwards it. `provider` is OpenRouter's
field, not the OpenAI schema's, and no other kind documents it.
It is set before the `extraBody` merge, so an explicit
`extraBody.provider` — the workaround people already use — still wins.
With no preferences configured the body is byte-identical to before.
Deliberately not sent by the pre-save key check (it probes the cheapest
paid model, which a host pinned for the operator's model may not serve,
and would report a good key as `model_unavailable`), the contract probe
(built from wizard state that carries no entry passthroughs, `extraBody`
included), the catalog fetch (`GET /models`, no body), or OpenRouter
embeddings (a pin chosen for a chat model's hosts would strand an
embedding model).
This was referenced Sep 13, 2026
fix(llm): retry a sub-call without response_format when the endpoint refuses structured outputs
#417
Merged
Bartok9
pushed a commit
to Bartok9/atomic-agent
that referenced
this pull request
Sep 14, 2026
structured-output fallback AtomicBot-ai#417 moved complete()'s body build into a closure handed to sendWithStructuredOutputFallback; thread this.providerPreferences through that closure (after strictTools) so unary sub-calls keep OpenRouter provider routing on both the first send and the prompt-only retry.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Before:
llm.providers[].providerPreferenceswas parsed and validated, but nothing ever put it on the wire — the field's own doc comment said "carried through config, not yet read by any provider". An operator who pinned OpenRouter's upstream host ({"order": ["z-ai"], "allow_fallbacks": false}) kept being routed to whichever host OpenRouter picked. Only the same object underextraBody.providerwas honoured.After: an
openrouterentry'sproviderPreferencesis sent verbatim as the request body'sproviderobject on every chat completion the entry makes — agent turns and memory sub-calls, streaming and unary — and onvision.describe. It is set before theextraBodymerge, so an explicitextraBody.provider(the workaround people already use) keeps winning. With nothing configured, request bodies are byte-identical to before.Why
Routing preferences are how an operator pins a host, forbids fallbacks, skips hosts that retain data (
data_collection,ignore), or requires hosts that support every request parameter (require_parameters). Silently dropping them also hides real behaviour: while investigating whyz-ai/glm-5.3-flashfailed on one route, a "pinned" config kept succeeding only because its requests were served elsewhere.Deliberately not sent by:
model_unavailable;extraBodyis absent there too);GET /models, no body);Only the
openrouterfactory forwards the field:provideris OpenRouter's, not part of the OpenAI schema, and no other kind documents it.How it was verified
npm run lintcleannpx vitest run src/llm/provider src/config— 64 files / 1005 tests greenopenrouter-provider-routing.test.tsasserts the real request body: unary, streamed, a structured-output sub-call and vision carryprovider;extraBody.providerwins on unary and streamed; with nothing configured there is noproviderkey and the body is unchangedregister-built-in-providers.test.ts: built through the real factories,openroutersends it;openai-compatible,qwen-openai-compatible,aimlapiandgeminido notextraBodyturns the precedence tests redz-ai/glm-5.3-flashthrough the realopenrouterfactory: with{order:["z-ai"], allow_fallbacks:false}the response'sproviderwasZ.AI4 / 4; with{order:["streamlake"], allow_fallbacks:false}it wasStreamLake2 / 2; with no preferences it varied between hostsDocs: AGENTS.md (provider registry section) and a README block next to
strictTools.Merge note: #417 also edits
OpenAiProvider.complete(it moves thebuildOpenAiChatBodycall this PR extends into a closure) — whichever lands second needs a trivial rebase.