Skip to content

[Bug] OpenRouter require_parameters: true hard-404s models that do not advertise every parameter we send #3018

Description

@wpfleger96

Summary

On the OpenRouter path we send provider: { "require_parameters": true } whenever the request body carries tools or a reasoning object (crates/buzz-agent/src/llm.rs:1558-1566). That flag is an assertion that every parameter in our body is honored by the endpoint OpenRouter routes to. But we build the body from our own capability model — not from the target model's advertised supported_parameters — so any parameter we send unconditionally is a latent hard 404 for whatever slice of the catalog does not advertise it.

The failure is not graceful. OpenRouter returns:

HTTP 404 {"error":{"message":"No endpoints found that can handle the requested parameters.","code":404}}

and openrouter_post maps any 404 to AgentError::LlmModelNotFound (llm.rs:1444-1449), which surfaces to the user as llm model not found: (<model>) 404: .... The message sends people hunting a model-name typo that does not exist — the model is present in the catalog and the key is valid.

Worth noting the diagnostic already exists one branch away: llm.rs:1487-1491 produces no OpenRouter endpoint supports the requested parameters — check model, effort, and tool requirements for a bare 503. The 404 that OpenRouter actually returns for this condition never reaches it, because the 404 arm returns first.

This was found while verifying #1975 against a live OpenRouter key. The largest instance (max_completion_tokens, 221/274 tools-capable models) is fixed on that branch. The pattern and the remaining instances are not.

The pattern

require_parameters: true is a promise about the whole body. We construct the body from what we support, and never intersect it against what the target model advertises. Every unconditional parameter is one un-advertised capability away from a 404 that reads as "model not found."

Naming the pattern matters more than the individual instances below — the next unconditional field added to openai_body inherits the same failure mode automatically.

Remaining instances

Counts below are catalog-derived projections, not observed failures. They are computed from the supported_parameters array in GET https://openrouter.ai/api/v1/models (343 model ids, 274 tools-capable), which is OpenRouter's advertisement of endpoint capability rather than tested behavior. The mechanism predicted the live google/gemini-2.5-flash 404 exactly, so the mechanism is real; the specific counts assume the catalog is accurate for all 274 and that a model's advertised set is the router's actual filter.

Catalog snapshot taken 2026-07-26.

Params we send Models satisfying require_parameters Projected 404s
max_tokens + tools + tool_choice (no thinking effort — today's default path) 268 / 274 6
the above + reasoning (thinking effort set) 190 / 274 84
drop tool_choice when un-advertised 272 / 274 2

With-effort breakdown of the 84:

 78  missing ['reasoning']
  4  missing ['reasoning', 'tool_choice']
  1  missing ['max_tokens']
  1  missing ['max_tokens', 'reasoning']

1. reasoning — 83 of 274 tools-capable models do not advertise it. llm.rs:1549-1556 inserts reasoning: { effort: ... } whenever a thinking effort is configured, and :1563 makes has_reasoning sufficient to set require_parameters. config.rs:839 reads BUZZ_AGENT_THINKING_EFFORT for every provider, so this is one env var away from reproducing today on models like deepseek/deepseek-chat, cohere/command-r-08-2024, google/gemma-3-12b-it, or the Amazon Nova family. Note this is opt-in: parse_thinking_effort maps None | Some("") to Ok(None) (config.rs:622-624), so the default path is unaffected.

2. tool_choice — 4 tools-capable models advertise tools without tool_choice. openai_body sets tool_choice: "auto" unconditionally whenever tools are non-empty (llm.rs:591-594). The 4: amazon/nova-lite-v1, amazon/nova-micro-v1, amazon/nova-premier-v1, amazon/nova-pro-v1. This is why 6 models fail even with no effort set.

3. max_tokens — 2 tools-capable models advertise no token-limit parameter at all. openai/gpt-3.5-turbo-0613 and sakana/fugu-ultra. No spelling satisfies require_parameters for these; the parameter can only be dropped.

The cheap win

Omitting tool_choice when a model does not advertise it takes the no-effort projected failure set from 6 to 2, and tool_choice: "auto" is the OpenAI-compatible API default anyway — omitting it is semantically free. Highest fix-to-risk ratio in the set.

Resolution options

Three shapes, listed without a recommendation — each trades silent degradation against hard 404 differently, and that is a product call:

  1. Drop require_parameters entirely. OpenRouter then routes to a best-effort endpoint and silently ignores unsupported parameters. No 404s ever; the cost is that a user who sets a thinking effort may get a non-reasoning response with no signal that their setting was discarded.
  2. Gate require_parameters on advertised support. Fetch supported_parameters for the effective model and set the flag only when every parameter in the body is advertised. Keeps the strictness guarantee where it can be honored; adds a catalog dependency (and a cache/staleness question) to the request path.
  3. Condition the parameters themselves on the catalog. Omit reasoning / tool_choice when the target does not advertise them, and keep require_parameters unconditional. Same catalog dependency as option 2, but degrades by dropping a field rather than by weakening the routing contract — and would want a user-visible warning when a requested effort is dropped.

Options 2 and 3 both need a decision on failure mode when the catalog fetch fails or the model is absent from it.

Reproduce

Requires a real OpenRouter key. On a build that includes the #1975 max_tokens fix:

BUZZ_AGENT_PROVIDER=openrouter \
OPENROUTER_API_KEY=... \
OPENROUTER_MODEL=deepseek/deepseek-chat \
BUZZ_AGENT_THINKING_EFFORT=low \
  buzz-agent   # drive one session/prompt -> HTTP 404 "No endpoints found..."

Same command without BUZZ_AGENT_THINKING_EFFORT is expected to succeed, which is the instance-1 boundary. For instance 2, use OPENROUTER_MODEL=amazon/nova-pro-v1 with no effort set.

Recompute the projections against the live catalog:

curl -sS https://openrouter.ai/api/v1/models \
  | python3 -c 'import json,sys
d=json.load(sys.stdin)["data"]
sp=lambda m:set(m.get("supported_parameters") or [])
t=[m for m in d if "tools" in sp(m)]
for req in [{"max_tokens","tools","tool_choice"},{"max_tokens","tools","tool_choice","reasoning"},{"max_tokens","tools"}]:
    f=[m["id"] for m in t if req-sp(m)]
    print(sorted(req), "satisfy", len(t)-len(f), "fail", len(f))'

Not in scope of #1975

#1975 fixed the max_completion_tokens -> max_tokens translation, which was the only instance affecting the default path (221 of 274 tools-capable models, i.e. every OpenRouter user regardless of settings). Everything above is either opt-in or narrow, and resolving it changes how request bodies are constructed for every provider-adjacent path — deliberately kept out of that PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions