Skip to content

fix: deliver custom-provider extra_body (request_overrides) on gateway, Open WebUI, and TUI/CLI paths - #53765

Open
apollo-orbit-dev wants to merge 9 commits into
NousResearch:mainfrom
apollo-orbit-dev:pr/hermes-request-overrides
Open

fix: deliver custom-provider extra_body (request_overrides) on gateway, Open WebUI, and TUI/CLI paths#53765
apollo-orbit-dev wants to merge 9 commits into
NousResearch:mainfrom
apollo-orbit-dev:pr/hermes-request-overrides

Conversation

@apollo-orbit-dev

Copy link
Copy Markdown

What does this PR do?

A custom_providers entry can carry an extra_body — e.g. {chat_template_kwargs: {enable_thinking: false}} to control a locally-served Qwen3 / Qwen3.6 model's "thinking" on vLLM. resolve_runtime_provider() correctly surfaces this as request_overrides on the resolved runtime, but several independent agent build/switch paths rebuild the runtime from a fixed field whitelist that omits request_overrides, so the configured extra_body never reaches the model.

This threads request_overrides through every path that was dropping it:

  • the shared gateway turn path (all messaging platforms) and the Open WebUI / api_server path,
  • the gateway /model mid-session switch,
  • the TUI dashboard + CLI in-place /model switch, and
  • TUI session rebuild / resume.

It also fixes a related TUI bug: after a /model switch the next turn failed on strict backends with HTTP 400: System message must be at the beginning (details below).

Why this approach: the value is already resolved by resolve_runtime_provider(); the fix carries it through the build/switch sites rather than re-deriving or special-casing. The messaging platforms are thin adapters over one shared GatewayRunner path, so a single change covers all of them; only api_server and the TUI/CLI have their own build/switch code, handled explicitly.

Related Issue

No separate issue — the root cause and fix are both here. Related prior work:

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • gateway/run.py — carry request_overrides through _resolve_runtime_agent_kwargs and _try_resolve_fallback_provider; merge it into the per-turn route in _resolve_turn_agent_config under any /fast service-tier overrides; apply it in _apply_session_model_override and the _resolve_session_agent_runtime fast path. (Also fixes the Open WebUI path, since api_server._create_agent builds AIAgent(**runtime_kwargs).)
  • hermes_cli/model_switch.py — add a request_overrides field to ModelSwitchResult, derived for the switched-to provider via _get_named_custom_provider.
  • gateway/slash_commands.py — persist request_overrides in both /model session-override bundles.
  • agent/agent_runtime_helpers.pyswitch_model() re-derives and applies the switched provider's request_overrides in place, preserving non-provider overrides (service_tier / speed).
  • tui_gateway/server.py — (a) stage the model-switch note for the next user turn instead of appending a mid-conversation role:"system" message (mirrors the gateway's pending-note pattern; fixes the HTTP 400: System message must be at the beginning on strict OpenAI-compatible backends like vLLM/Qwen); (b) pass request_overrides to AIAgent in _make_agent so rebuild/resume keeps the switched provider's settings.
  • Teststests/gateway/test_turn_request_overrides.py, tests/agent/test_switch_model_request_overrides.py, tests/tui_gateway/test_model_switch_marker.py, plus a _make_agent rebuild test and an updated marker test in tests/test_tui_gateway_server.py.

How to Test

  1. Add a custom_providers entry for a vLLM-served Qwen3 model with extra_body: {chat_template_kwargs: {enable_thinking: true}}; set it as model.provider, or /model-switch to it mid-session.
  2. Send a message via Telegram / Discord / Open WebUI / the TUI dashboard.
  3. Inspect the outgoing chat/completions request.

Before: extra_body is absent — chat_template_kwargs is never sent and the model doesn't think.
After: the provider's extra_body is sent and thinking toggles as configured. Verified on the wire in all three interfaces.

#53406 (double-pass) safety: the gateway AIAgent(**turn_route["runtime"], …, request_overrides=…) sub-dict excludes request_overrides (it rides route["request_overrides"] only), and api_server._create_agent uses **runtime_kwargs with no explicit request_overrides= — so request_overrides is passed exactly once.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Linux (ARM64) — Telegram gateway, Open WebUI, and TUI dashboard; live + on-the-wire verified

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots / Logs

On-the-wire payload (custom provider pinned enable_thinking:false, vLLM server default enable_thinking:true, so the provider flag must win):

Before:  extra_body=None                     # the flag never reached vLLM
After:   extra_body={'think': False, 'chat_template_kwargs': {'enable_thinking': False}}

The model's behavior matched the wire in all three interfaces (Telegram, Open WebUI, TUI dashboard).

A `custom_providers` entry can carry an `extra_body` (e.g.
`chat_template_kwargs` to toggle a local vLLM model's thinking).
`resolve_runtime_provider()` correctly surfaces it as `request_overrides`
on the resolved runtime dict, but the gateway never plumbed it through to
the per-turn agent:

- `_resolve_runtime_agent_kwargs()` rebuilt the runtime dict from a fixed
  key whitelist that omitted `request_overrides`.
- `_resolve_turn_agent_config()` rebuilt `runtime` from the same whitelist
  and set `route["request_overrides"]` solely from `/fast` service-tier
  overrides (`{}` otherwise).
- The per-turn `agent.request_overrides = turn_route.get(...)` assignment
  then clobbered the value `_merge_custom_provider_extra_body()` applied at
  agent construction.

Net: on the gateway, a custom provider's configured `extra_body` never
reached the model -- only `/fast` overrides survived. The CLI/TUI path
(which does not go through `_resolve_turn_agent_config`) and the auxiliary
client (which sends `extra_body` directly) were unaffected.

Fix: carry `request_overrides` through the runtime resolvers
(`_resolve_runtime_agent_kwargs`, `_try_resolve_fallback_provider`) and
merge the provider overrides into the per-turn route, layering any `/fast`
service-tier overrides on top (top-level keys, no collision with
`extra_body`).

Adds tests/gateway/test_turn_request_overrides.py.

Known follow-up: the mid-session `/model`-switch override path
(`_session_model_overrides` / `ModelSwitchResult`) does not yet carry
`request_overrides`.
Follow-up to the previous commit (which fixed the default/fallback
provider path). A mid-session `/model` switch stores a per-session
override bundle in `_session_model_overrides` that omitted
`request_overrides`, and the two consumers
(`_resolve_session_agent_runtime` fast path and
`_apply_session_model_override`) only copied
provider/api_key/base_url/api_mode. So switching *to* a custom provider
via `/model` did not apply its `extra_body`.

- `ModelSwitchResult` gains a `request_overrides` field, derived for the
  switched provider via `_get_named_custom_provider` /
  `_custom_provider_request_overrides` (the same overrides
  `resolve_runtime_provider` surfaces for the default path).
- Both `/model` override-storage sites in slash_commands.py persist it.
- Both consumers apply it; `_apply_session_model_override` also clears a
  stale value when switching to a provider that has none.

Extends tests/gateway/test_turn_request_overrides.py (3 new cases).
…UI/CLI)

Third in the series. The gateway rebuild path (previous two commits)
carries a custom provider's `request_overrides` (`extra_body`, e.g.
`chat_template_kwargs`) into the agent, but the *in-place* live switch used
by the TUI dashboard and the CLI — `agent.switch_model()` ->
`agent_runtime_helpers.switch_model()` — swapped
model/provider/base_url/api_key without ever updating `request_overrides`.
So a `/model` switch to a thinking-enabled custom provider in the TUI/CLI
kept the previous provider's `extra_body`.

`switch_model()` now re-derives the switched-to provider's
`request_overrides` (via `_get_named_custom_provider`) and applies it in
place, preserving non-provider overrides (`service_tier`/`speed` from
`/fast`). Logic factored into `_apply_switched_provider_request_overrides`
for testability.

Adds tests/agent/test_switch_model_request_overrides.py.
The TUI dashboard appended a role:"system" model-switch marker into the
conversation history after a /model switch. Strict OpenAI-compatible
backends (vLLM/Qwen) reject a system message that is not at the
beginning, so the NEXT turn failed with
"HTTP 400: System message must be at the beginning."

Mirror the gateway's pending-note approach: _append_model_switch_marker
now stages session["pending_model_note"], and _run_prompt_submit prepends
it to the next user turn (consumed once, not persisted). No mid-history
system message is created, so strict backends accept the turn.

Adds tests/tui_gateway/test_model_switch_marker.py; updates the existing
tui-server switch test to assert the staged-note behavior.
The TUI `_make_agent` rebuild path resolves the switched provider via
`resolve_runtime_provider()` (so `runtime` carries `request_overrides`) but
passed only model/provider/base_url/api_key/api_mode to `AIAgent` —
dropping `request_overrides`. So after switching to a thinking-enabled
custom provider, a `/new` or session resume reverted to the default
provider's first-match `extra_body` merge (thinking turned back off).

Pass `request_overrides=runtime.get("request_overrides")` to `AIAgent` in
`_make_agent`. Explicit-field construction (no `**runtime` spread) avoids
the double-pass crash in NousResearch#53406.

Adds a regression test to tests/test_tui_gateway_server.py.
@apollo-orbit-dev

Copy link
Copy Markdown
Author

Note on the test suite — pre-existing failures, not from this PR

Running tests/gateway/ + tests/tui_gateway/ locally surfaces a handful of failures, but they're pre-existing on main and unrelated to this change (this PR touches no Telegram/formatting code). All 7 are Telegram markdown-escaping / formatting tests:

  • tests/gateway/test_busy_session_ack.py::TestBusySessionAck::test_telegram_omits_status_detail_by_default
  • tests/gateway/test_telegram_approval_buttons.pytest_send_update_prompt_escapes_dynamic_prompt, test_approval_callback_escapes_dynamic_user_name
  • tests/gateway/test_telegram_model_picker.pytest_send_model_picker_escapes_dynamic_provider_label, test_back_button_escapes_dynamic_provider_label, test_model_selected_edits_message_on_success
  • tests/gateway/test_telegram_slash_confirm.py::TestSendSlashConfirm::test_uses_markdown_v2_and_escapes_special_chars

Evidence they aren't introduced here:

  • They pass in isolation on both this branch and a clean main checkout — they only fail inside the long sequential full-directory run, i.e. a test-ordering/isolation (or flaky-under-load) issue in the suite.
  • Running the same tests/gateway/ + tests/tui_gateway/ set on a clean origin/main checkout reproduces them: 8 failed, 8050 passed there (the same 7, plus a git-history test test_projects_rpc.py::test_discover_repos_from_full_history) vs 7 failed, 8008 passed on this branch.
  • This PR's own new tests pass, and the changed-area suites (gateway turn/route, /model switch, TUI marker + rebuild) are green.

Flagging so the CI result isn't read as a regression.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/tui Terminal UI (ui-tui/ + tui_gateway/) labels Jun 27, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to #52432 (which patches only the gateway clobber sub-case) and #30224 (turn-refresh merge) — this PR is the broader fix for the same root cause (custom-provider extra_body / request_overrides dropped), also covering the field whitelists, the /model switch, and the TUI/CLI paths. Not a duplicate; the narrower PRs are subsets. Flagging the cluster so a maintainer can pick the canonical fix.

…ftover

The NousResearch#53765 conflict resolution kept commit 4's pending_model_note staging in
the locked branch alongside upstream's role=user append (NousResearch#48338), leaving
_append_model_switch_marker asymmetric and an orphaned consumer in
_run_prompt_submit. Match upstream NousResearch#48338 exactly; remove the now-redundant
commit-4 test.
…verrides

# Conflicts:
#	gateway/run.py
#	tests/test_tui_gateway_server.py
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for tracing the gateway route through to the live per-turn overwrite. The central premise remains valid on current main: gateway/run.py:18394 replaces the agent's initialized overrides, while the route currently omits the resolved custom-provider value (gateway/run.py:1895-1904, gateway/run.py:3924-3933).

Problems

  • The new in-place switch helper derives extra_body by named provider only. Current main intentionally matches custom-provider request settings by provider identity, endpoint, and model/catalog in agent/agent_init.py:212-254. The switch path should retain that same condition; otherwise a different model selected at one named endpoint can inherit settings intended for another model.

Suggested changes

  • Reuse the model/base-URL-aware matcher for the switch path, clearing stale extra_body when no entry matches, and add a nonmatching-model regression test.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
…name

Addresses the hermes-sweeper review on NousResearch#53765. The in-place /model switch
helper (_apply_switched_provider_request_overrides) derived a custom
provider's extra_body by provider *name* only, while build-time matching in
agent_init._merge_custom_provider_extra_body matches by provider key, base_url,
AND model. So a different model selected at the same named endpoint could
inherit an extra_body configured for another model.

Reuse the shared agent_init._custom_provider_extra_body_for_agent matcher
(provider key + base_url + model), sourcing custom_providers from the
init-time agent._custom_providers cache (fresh-load fallback if absent). A
stale extra_body is always cleared when no entry matches; non-provider
overrides (service_tier / speed from /fast) are preserved.

Tests: add nonmatching-model and endpoint-mismatch regressions; update the
existing switch tests onto the model/base_url-aware matcher.
@apollo-orbit-dev
apollo-orbit-dev force-pushed the pr/hermes-request-overrides branch from cfd4ef7 to 09115a6 Compare July 15, 2026 14:15
@apollo-orbit-dev

Copy link
Copy Markdown
Author

Thanks for the review — good catch on the matching asymmetry. Addressed in 09115a6d.

Change: the in-place switch path (_apply_switched_provider_request_overrides) now reuses the shared model/base-URL-aware matcher agent_init._custom_provider_extra_body_for_agent (provider key + base_url + model) instead of the name-only _get_named_custom_provider, so it applies the same condition as build-time _merge_custom_provider_extra_body. Selecting a different model at the same named endpoint no longer inherits an extra_body configured for another model. custom_providers comes from the init-time agent._custom_providers cache (set right where _merge_custom_provider_extra_body runs), with a fresh-load fallback.

Stale handling: a stale extra_body is always cleared when the switched-to provider/model resolves none; non-provider overrides (service_tier/speed from /fast) are preserved.

Tests: added test_switch_to_different_model_same_endpoint_does_not_inherit (the exact nonmatching-model case) and test_switch_endpoint_mismatch_does_not_inherit; moved the existing switch tests onto the aware matcher.

I kept the switch path calling the matcher directly rather than routing through _merge_custom_provider_extra_body, since the switch needs clear-then-apply semantics whereas _merge layers onto existing overrides — happy to unify them behind one entry point if you'd prefer.

@alt-glitch alt-glitch added the area/config Config system, migrations, profiles label Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants