Route OpenRouter requests by provider order

Merged
YO
11d ago
+477-16

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 provider gets 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_order is a list of OpenRouter provider slugs. It goes in any llm block: the DAG-level one, the with of chat.completion, with.llm of browser.run, computer.run, and xlsx.extract, an agent DAG's root llm, and dagu browser session --llm.
  • A model: list entry can set its own provider_order, which replaces the shared one for that entry.
  • Name: DAG keys are snake_case (base_url, api_key_name). camelCase keys such as baseURL are legacy, and the decoder refuses them with a rename hint. So the key is provider_order, not providerOrder. In the IR JSON it is providerOrder, next to baseURL and apiKeyName.

Behavior

  • Provider level. llm.Config gains ProviderOrder, and runtime.NewLLMProvider passes it in. The OpenRouter provider adds "provider": {"order": [...]} to every chat request it builds, streaming or not. It leaves provider out when no order is set.
    • Every step type builds its provider through runtime.EffectiveLLMConfig and runtime.NewLLMProvider. That covers chat, the agent planner, the browser model bridge, computer.run (including the generic computer-use session's llm.ChatRequest), and xlsx.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.
  • allow_fallbacks keeps 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_order on a model whose provider is not openrouter: field 'llm.provider_order': provider_order is only supported by the openrouter provider, not "anthropic". For a model list entry the path is llm.model[1].provider_order. A shared order that would reach a non-openrouter entry is refused at llm.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. NewLLMProvider refuses it with the same message once it resolves to another provider.
    • A missing provider is still reported as "provider is required".
  • Schemas (schemas/dag.schema.json links to internal/cmn/schema/dag.schema.json) accept provider_order as a list of strings on the llm block and on model entries.

Not changed

  • decision.evaluate calls OpenRouter's separate decisions API with its own with config. It does not use llm settings.
  • dagu browser session gets no --provider-order flag. --llm '{provider: openrouter, model: ..., provider_order: [azure/eu]}' already works.
  • The docs site (dagucloud/docs) is not touched here. Its llm field tables (step-types/llm/index.md, providers.md, reliability.md, yaml-specification.md) should gain provider_order when this ships in a release.

Test plan

  • TestChatProviderOrder (openrouter, httptest): a configured order reaches the server as provider.order. Without one the body has no provider key.
  • 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.
  • TestEffectiveLLMConfigProviderOrder and TestNewLLMProviderRefusesProviderOrder (runtime).
  • TestChatProviderOrder (intg, end to end): a chat.completion step against a fake OpenRouter that answers 404 unless azure/eu is 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 NewLLMProvider or EffectiveLLMConfig, 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.
  • A real request through a built dagu with provider: openrouter, model: openai/gpt-6.1-terra, provider_order: [azure/eu], max_tokens: 16 got 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, and gofmt.
  • llms.txt regenerated from skills/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 llm block accepts provider_order; a model: list entry can set its own, which replaces the shared order for that entry.
  • The OpenRouter provider sends it as provider.order on every request it builds, streaming or not.
  • Validation refuses provider_order for any provider other than openrouter, 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.