Route OpenRouter requests by provider order
Merged
YO
11d ago+477+477+012345678901234567890123456789-16-16-01234567890123456789
Summary
An OpenRouter account whose privacy settings allow only zero-data-retention (ZDR) endpoints cannot use openai/gpt-6.1-terra through Dagu. OpenRouter's only ZDR endpoint for that model is Azure EU (azure/eu). It carries a regional surcharge, and OpenRouter passes over it unless the request names it:
- A request without
providergets 404 "0 endpoints ... matching your guardrail restrictions and data policy ... ZDR violation (account settings)". The routing funnel shows "Filter by Regional Surcharge" dropped the endpoint. - The same request with
"provider": {"order": ["azure/eu"]}gets 200, served by Azure.
Dagu's OpenRouter provider had no way to send provider. This PR adds provider_order.
YAML
steps:
- action: chat.completion
with:
provider: openrouter
model: openai/gpt-6.1-terra
provider_order: [azure/eu]
prompt: Hello.
provider_orderis a list of OpenRouter provider slugs. It goes in anyllmblock: the DAG-level one, thewithofchat.completion,with.llmofbrowser.run,computer.run, andxlsx.extract, an agent DAG's rootllm, anddagu browser session --llm.- A
model:list entry can set its ownprovider_order, which replaces the shared one for that entry. - Name: DAG keys are snake_case (
base_url,api_key_name). camelCase keys such asbaseURLare legacy, and the decoder refuses them with a rename hint. So the key isprovider_order, notproviderOrder. In the IR JSON it isproviderOrder, next tobaseURLandapiKeyName.
Behavior
- Provider level.
llm.ConfiggainsProviderOrder, andruntime.NewLLMProviderpasses it in. The OpenRouter provider adds"provider": {"order": [...]}to every chat request it builds, streaming or not. It leavesproviderout when no order is set.- Every step type builds its provider through
runtime.EffectiveLLMConfigandruntime.NewLLMProvider. That covers chat, the agent planner, the browser model bridge,computer.run(including the generic computer-use session'sllm.ChatRequest), andxlsx.extract. So none of their request builders changed. - Each model fallback gets its own provider, so an order set on one entry applies only to that entry.
- Every step type builds its provider through
allow_fallbackskeeps OpenRouter's default (true). The account's data policy still filters every fallback endpoint, so a fallback cannot break a ZDR setting. Refusing fallbacks would fail the request whenever the named endpoints are down. There is a comment at the call site.- Other providers are refused, not ignored. Silently dropping a routing order hides a misconfiguration: the step would reach endpoints the author tried to exclude.
- Validation refuses
provider_orderon a model whose provider is notopenrouter:field 'llm.provider_order': provider_order is only supported by the openrouter provider, not "anthropic". For a model list entry the path isllm.model[1].provider_order. A shared order that would reach a non-openrouter entry is refused atllm.provider_order. A mixed fallback list sets the order on its openrouter entries instead. - A provider given as a value reference (
${env.LLM_PROVIDER}) passes validation.NewLLMProviderrefuses it with the same message once it resolves to another provider. - A missing provider is still reported as "provider is required".
- Validation refuses
- Schemas (
schemas/dag.schema.jsonlinks tointernal/cmn/schema/dag.schema.json) acceptprovider_orderas a list of strings on thellmblock and on model entries.
Not changed
decision.evaluatecalls OpenRouter's separate decisions API with its ownwithconfig. It does not usellmsettings.dagu browser sessiongets no--provider-orderflag.--llm '{provider: openrouter, model: ..., provider_order: [azure/eu]}'already works.- The docs site (dagucloud/docs) is not touched here. Its
llmfield tables (step-types/llm/index.md,providers.md,reliability.md,yaml-specification.md) should gainprovider_orderwhen this ships in a release.
Test plan
-
TestChatProviderOrder(openrouter, httptest): a configured order reaches the server asprovider.order. Without one the body has noproviderkey. -
TestProviderOrder(spec):- accepted for openrouter at the DAG level, inherited by a step, on a step, on a model entry, and with a value-reference provider;
- refused for openai at the DAG level, for anthropic on a step, for a shared order reaching an anthropic entry of
computer.run, and for a gemini entry; - a missing provider still reports "provider is required";
- a non-list entry order is refused.
-
TestDAGSchemaLLMProviderOrder: the schema accepts a list and refuses a scalar, both on the block and on a model entry. -
TestEffectiveLLMConfigProviderOrderandTestNewLLMProviderRefusesProviderOrder(runtime). -
TestChatProviderOrder(intg, end to end): achat.completionstep against a fake OpenRouter that answers 404 unlessazure/euis named. The shared-order step succeeds, and a two-entry fallback list falls back from the entry without an order to the one with it. - Mutation checks. Each of these fails one of the tests above:
- never or always sending
provider; - dropping the order in
NewLLMProviderorEffectiveLLMConfig, or merging it the wrong way; - removing the runtime refusal;
- removing validation or the copy at the step or DAG level, or the copy of an entry's order;
- ignoring an entry's or the shared order;
- not deferring value references, refusing a missing provider, or allowing the wrong provider;
- not parsing an entry's order, or not checking its type;
- dropping either schema property.
- never or always sending
- A real request through a built
daguwithprovider: openrouter,model: openai/gpt-6.1-terra,provider_order: [azure/eu],max_tokens: 16got 200 and answered "OK". The key used locally is not ZDR-restricted, so the same request without the order also succeeded. The run confirms OpenRouter accepts the object; the 404 above came from the restricted account. - golangci-lint v2.13.2 for linux, windows, and darwin on the changed packages,
go fix -embedlit=false, andgofmt. -
llms.txtregenerated fromskills/dagu/references/steptypes.md.
Summary by cubic
Adds provider_order to LLM config so OpenRouter requests can name the upstream providers to try first, in order. This fixes 404 errors for OpenRouter accounts restricted to zero-data-retention endpoints, where a model's only eligible endpoint carries a regional surcharge and OpenRouter passes over it unless the request names it.
Behavior
- Every
llmblock acceptsprovider_order; amodel:list entry can set its own, which replaces the shared order for that entry. - The OpenRouter provider sends it as
provider.orderon every request it builds, streaming or not. - Validation refuses
provider_orderfor any provider other thanopenrouter, and a provider given as a value reference is refused once it resolves to another provider. - The DAG schema,
llms.txt, and the step-types reference accept the new key.
Summary by CodeRabbit
- New Features
- OpenRouter configurations can specify an ordered list of upstream providers to try first. Set a shared order or override it for individual models.
- Documentation
- Updated the LLM configuration reference to describe provider ordering and where it can be configured, including DAG, step, browser, and computer settings.
- Validation
- Provider ordering is rejected for non-OpenRouter providers and for values that are not lists of strings.