Skip to content

fix(agent): forward fallback_providers[].extra_body during fallback activation (#26460) - #26483

Closed
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/openrouter-fallback-extra-body-26460
Closed

fix(agent): forward fallback_providers[].extra_body during fallback activation (#26460)#26483
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/openrouter-fallback-extra-body-26460

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

Summary

  • fallback_providers entries can carry extra_body (e.g. OpenRouter provider.{order, allow_fallbacks} for per-route provider pinning) but the activation path silently dropped it. Forward it into request_overrides so the existing chat_completions transport merge picks it up on the very next request.
  • Snapshot request_overrides in _primary_runtime (init + switch_model) and restore it in _restore_primary_runtime() so the override is strictly scoped to the active fallback route — no leak into unrelated requests, no leak past the next turn.

Fixes #26460.

The bug

_try_activate_fallback() in run_agent.py reads provider, model, base_url, api_key, and key_env/api_key_env off the chosen fallback entry. Anything else on that entry — including a request-scoped extra_body block — is dropped on the floor. The reporter's case (validated against live OpenRouter):

fallback_providers:
- provider: openrouter
  model: z-ai/glm-5.1
  key_env: OPENROUTER_API_KEY
  extra_body:
    provider:
      order: [baidu/fp8, gmicloud/fp8, deepinfra/fp4]
      allow_fallbacks: false

The fallback activates and Hermes routes through OpenRouter, but the provider.order / allow_fallbacks body block never lands in the request — so OpenRouter free-routes to whatever upstream is cheapest. The only existing way to pin providers (the agent-level provider_routing config) is too broad: it injects the same provider block into every OpenRouter request in the session, distorting routing for unrelated models picked up later via /model.

The fallback-suffix workaround (z-ai/glm-5.1:baidu/fp8) is accepted by OpenRouter but the issue's live testing shows it does not actually pin the serving provider.

The fix

agent/transports/chat_completions.py already merges request_overrides["extra_body"] into the outbound extra_body (lines 387-389 / 496-505). The fix is to wire the fallback entry's extra_body into request_overrides at activation time:

  1. Activate (_try_activate_fallback): after the fallback swap succeeds, merge fb.get("extra_body") into self.request_overrides["extra_body"]. Pre-existing user extra_body keys are preserved; the fallback wins on key collision. Non-dict extra_body is ignored.
  2. Snapshot (_primary_runtime at init + switch_model): capture request_overrides so a later restoration knows the baseline.
  3. Restore (_restore_primary_runtime): reset request_overrides from the snapshot so the fallback override is dropped the moment the primary route comes back.

The agent-level provider_routing knobs (providers_allowed, providers_ignored, providers_order, provider_sort) are intentionally not touched — they remain global, the fallback override is request-scoped.

Test plan

  • Focused regression suite (7 new tests in tests/run_agent/test_provider_fallback.py::TestFallbackEntryExtraBodyForwarded):
    • test_fallback_extra_body_forwarded_to_request_overrides — happy path: provider order + allow_fallbacks lands in request_overrides["extra_body"].
    • test_global_provider_routing_unchanged — invariant: agent-level provider_routing knobs identical before/after activation.
    • test_fallback_without_extra_body_does_not_inject_key — entry without extra_body does not add the key.
    • test_fallback_extra_body_merges_with_existing_extra_body — pre-existing user extra_body preserved; fallback wins on collision.
    • test_invalid_extra_body_type_is_ignored — defensive: non-dict extra_body does not crash activation.
    • test_restore_primary_runtime_clears_fallback_extra_body — invariant: after restore, override is gone.
    • test_primary_runtime_snapshot_includes_request_overrides — invariant: snapshot has the restoration anchor.
  • Adjacent suite — all green: tests/run_agent/test_provider_fallback.py (29), test_primary_runtime_restore.py, test_fallback_model.py, test_compressor_fallback_update.py, test_switch_model_fallback_prune.py, test_anthropic_third_party_oauth_guard.py — 104 passed.
  • Regression guard: removing the new merge block in _try_activate_fallback makes test_fallback_extra_body_forwarded_to_request_overrides fail (request_overrides has no extra_body key after activation); reapplying it passes.

Contract protected

Invariant Known-bad input Negative case
Fallback extra_body is forwarded on activation. Entry with extra_body.provider.order produced no provider block in outbound request. Entry without extra_body does not inject the key (test).
Activating a fallback never mutates global provider_routing knobs. Old behavior left the knobs untouched, but only because the entire extra_body was dropped — the new behavior must continue to leave them alone. Snapshot 4-tuple compared before/after activation (test).
Pre-existing user extra_body survives the merge. Caller config sets request_overrides["extra_body"]["some_field"] — must not be clobbered by fallback merge. test_fallback_extra_body_merges_with_existing_extra_body.
_restore_primary_runtime() clears the fallback's extra_body. Without a request_overrides snapshot, the fallback's override would persist into the primary turn. test_restore_primary_runtime_clears_fallback_extra_body.
Non-dict extra_body does not crash activation. Misconfigured user yaml: extra_body: not-a-dict. test_invalid_extra_body_type_is_ignored.

Sibling code paths that may need the same fix: fb.get("request_overrides") (the issue lists this as an optional second forwarding target — same merge mechanism would carry it). Intentionally left out of this PR's scope to keep the diff focused on extra_body, which is the only field the reporter validated end-to-end. Happy to widen if preferred.

Related

…ctivation (NousResearch#26460)

`fallback_providers` entries can carry request-scoped metadata such as
OpenRouter `extra_body.provider.{order, allow_fallbacks}` for per-route
provider pinning, but `_try_activate_fallback()` only consumed
`provider`, `model`, `base_url`, `api_key`, and `key_env` / `api_key_env`.
Any `extra_body` block on the fallback entry was silently dropped, so
users could not express "main route untouched, fallback route pinned to
a specific OpenRouter provider order" without using the global
`provider_routing` knob — which then leaked into every other OpenRouter
request in the session.

Fix: when activating a fallback entry, merge `fb["extra_body"]` into
`self.request_overrides["extra_body"]`. The chat_completions transport
already forwards `request_overrides["extra_body"]` into the outbound
request body, so no transport-side change is needed. To keep this
strictly scoped to the active fallback route, snapshot
`request_overrides` in `_primary_runtime` at init / `switch_model` and
restore it in `_restore_primary_runtime()` — the next turn's primary
restoration drops the fallback's `extra_body` automatically.

Invariants protected:
- Fallback `extra_body` is forwarded into the request on the very next
  call (no transport-side rebuild needed).
- Activating a fallback never mutates the agent-level `provider_routing`
  knobs (`providers_allowed`, `providers_ignored`, `providers_order`,
  `provider_sort`).
- Pre-existing user `request_overrides["extra_body"]` is preserved on
  merge; fallback wins on key collision.
- Non-dict `extra_body` is ignored, not crashed.
- `_restore_primary_runtime()` resets `request_overrides` from the
  snapshot, so the override never leaks past the active fallback turn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 15, 2026 17:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Forwards a fallback chain entry's extra_body (e.g. OpenRouter provider.order) into request_overrides when activating that fallback, so per-fallback routing reaches the transport without mutating global provider routing knobs. Adds snapshot/restore for request_overrides so the override is cleared when the primary route is restored.

Changes:

  • Snapshot request_overrides into _primary_runtime at init and in switch_model.
  • Merge fallback entry extra_body into request_overrides in _try_activate_fallback.
  • Restore request_overrides from snapshot in _restore_primary_runtime.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
run_agent.py Snapshot/restore request_overrides and merge fallback-entry extra_body on activation.
tests/run_agent/test_provider_fallback.py New test class covering forwarding, isolation, merge, restoration, and snapshot semantics.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread run_agent.py
Comment on lines +8866 to +8871
existing_eb = base_overrides.get("extra_body")
merged_eb = (
{**existing_eb, **fb_extra_body}
if isinstance(existing_eb, dict)
else dict(fb_extra_body)
)
Comment on lines +411 to +418
def test_invalid_extra_body_type_is_ignored(self):
"""Defensive: a non-dict extra_body in the chain entry must not
crash activation or mutate request_overrides."""
fbs = [{
"provider": "openrouter",
"model": "z-ai/glm-5.1",
"extra_body": "not-a-dict", # invalid shape
}]
Comment thread run_agent.py Outdated
Comment on lines +2488 to +2492
"request_overrides": (
dict(getattr(self, "request_overrides", None) or {})
if isinstance(getattr(self, "request_overrides", None), dict)
else {}
),
Comment thread run_agent.py Outdated
Comment on lines +9001 to +9003
# extra_body.provider routing). Snapshot may be absent on
# older sessions saved before #26460.
self.request_overrides = dict(rt.get("request_overrides", {}))
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint labels May 15, 2026
Addresses four findings from the Copilot review on NousResearch#26483:

1. **Deep-merge nested extra_body keys** — `{**existing, **fb_extra_body}`
   was a shallow merge, so a fallback entry's
   `extra_body["provider"] = {"order": [...], "allow_fallbacks": False}`
   clobbered any pre-existing nested keys (e.g. OpenRouter
   `provider.require_parameters`, `provider.data_collection`). Added a
   `_deep_merge_extra_body` helper that merges one level deep — fallback
   wins on leaf collisions, primary wins on absent leaves. Documented
   semantics in the call-site comment.

2. **Test coverage for `extra_body: {}`** — the `and fb_extra_body`
   truthiness check correctly treats an explicit empty dict the same as
   an absent key, but that path was untested. Added
   `test_empty_extra_body_dict_does_not_inject_key` to lock in the
   behavior so a future cleanup of the truthiness guard doesn't
   silently inject an empty `extra_body` override.

3. **Deduplicate snapshot expression** — the 4-line
   `dict(getattr(...) or {}) if isinstance(...) else {}` pattern was
   verbatim in `__init__` and `switch_model`, plus a similar guarded
   copy in `_try_activate_fallback`. Extracted into a
   `_snapshot_request_overrides` staticmethod on `AIAgent` and adopted
   at all three sites.

4. **Older-session restore back-compat** — when restoring from a
   snapshot that lacks `request_overrides` (sessions persisted before
   NousResearch#26460), the old code unconditionally overwrote
   `self.request_overrides` with `{}`, silently dropping any overrides
   the user set after the snapshot was captured. Now guarded by
   `if "request_overrides" in rt:`, so older snapshots leave the
   current value untouched. Added
   `test_restore_from_older_snapshot_preserves_current_overrides` to
   cover this.

Plus a deep-merge regression test
(`test_fallback_extra_body_deep_merges_nested_provider_dict`) that
exercises the original Copilot scenario: primary
`extra_body.provider.require_parameters=True` survives activation of a
fallback entry that adds `provider.order` / `allow_fallbacks`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@briandevans

Copy link
Copy Markdown
Contributor Author

@copilot All four findings addressed in commit d0214c3:

  • Deep-merge nested extra_body keys (run_agent.py:8867): replaced {**existing_eb, **fb_extra_body} with new helper _deep_merge_extra_body() that merges one level deep. A fallback entry's provider.order / allow_fallbacks no longer clobbers a pre-existing provider.require_parameters / data_collection baked into primary request_overrides. Fallback still wins on leaf collisions, primary still wins on absent leaves.
  • Empty-dict extra_body test (tests/run_agent/test_provider_fallback.py): added test_empty_extra_body_dict_does_not_inject_key to lock in the and fb_extra_body truthiness behavior so a future refactor of the guard doesn't silently inject an empty override.
  • Snapshot expression duplication (run_agent.py:2488/2776/8860): extracted the 4-line dict(...) if isinstance(...) else {} pattern into a _snapshot_request_overrides staticmethod on AIAgent; adopted at __init__, switch_model, and _try_activate_fallback so the three sites stay in lockstep.
  • Older-session restore back-compat (run_agent.py:9003): _restore_primary_runtime() now only overwrites self.request_overrides when the snapshot actually carries the key (if "request_overrides" in rt). Older sessions saved before this PR leave the current value untouched instead of being silently reset to {}. Covered by test_restore_from_older_snapshot_preserves_current_overrides.

Plus a deep-merge regression test (test_fallback_extra_body_deep_merges_nested_provider_dict) that exercises the exact scenario in your inline comment: provider.require_parameters=True set by primary config survives activation of a fallback entry that adds provider.order / allow_fallbacks. Local: 32 passed in 6.67s.

@briandevans

Copy link
Copy Markdown
Contributor Author

CI audit — all 7 test failures are pre-existing baselines on clean origin/main (9fb40e6a3). Zero failures are in touched code (run_agent.py, tests/run_agent/test_provider_fallback.py).

Test Symptom Root cause on main
test_provider_parity.py::TestDeveloperRoleSwap::test_developer_role_via_nous_portal ValueError: Model has a context window of 15,000 tokens, which is below the minimum 64,000 Test constructs AIAgent(provider="nous", base_url="https://inference-api.nousresearch.com/v1") — the unmocked context-length probe caches 15,000 and trips the new 64K minimum guard in run_agent.py:2349.
test_provider_parity.py::TestBuildApiKwargsNousPortal::test_includes_nous_product_tags same same
test_provider_parity.py::TestBuildApiKwargsNousPortal::test_uses_chat_completions_format same same
test_discord_adapter.py::TestMentionStrippedCommandDispatch::test_mention_then_command TypeError: catching classes that do not inherit from BaseException is not allowed gateway/platforms/discord.py:3730 does except discord.Forbidden:, but the e2e harness stubs discord as a SimpleNamespace, so discord.Forbidden resolves to a non-exception sentinel.
test_discord_adapter.py::TestMentionStrippedCommandDispatch::test_nickname_mention_then_command same same
test_discord_adapter.py::TestMentionStrippedCommandDispatch::test_text_before_command_not_detected same same
test_discord_adapter.py::TestAutoThreadingPreservesCommand::test_command_detected_after_auto_thread same same

All seven reproduce on clean origin/main locally with identical error text.

@briandevans

Copy link
Copy Markdown
Contributor Author

Closing to keep the queue clean — branch is several hundred commits behind main and never picked up a review. Happy to reopen if the underlying fix is still useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: OpenRouter fallback entry ignores fallback-local provider routing metadata

3 participants