Skip to content

fix(gateway): propagate max_tokens through pinned-provider and rehydrate paths - #60008

Open
Kewe63 wants to merge 2 commits into
NousResearch:mainfrom
Kewe63:fix/59763-runtime-max-tokens-complete
Open

fix(gateway): propagate max_tokens through pinned-provider and rehydrate paths#60008
Kewe63 wants to merge 2 commits into
NousResearch:mainfrom
Kewe63:fix/59763-runtime-max-tokens-complete

Conversation

@Kewe63

@Kewe63 Kewe63 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Three sibling paths were silently dropping the resolved max_tokens cap whenever a provider was pinned instead of taking the default env route. HERMES_MAX_TOKENS / model.max_tokens / per-provider max_output_tokens only reached the wire on the default route (#59763); every pinned-provider route sent requests uncapped.

This PR addresses all three paths in one consolidation by extracting the cap-resolution logic into a shared helper, with full regression test coverage and the explicit-caller-cap fix on the auxiliary-client side.


Bug Surface Per Path

Path File Symptom before
Channel override pinning a provider gateway/run.py:_resolve_runtime_agent_kwargs_for_provider Return dict omitted max_tokensruntime_kwargs.get("max_tokens") was None for every turn
Persisted /model rehydration after restart gateway/run.py:_rehydrate_session_model_override Override dict carried api_key / api_mode / credential_pool / base_url but never max_tokens → fast-path read None for the lifetime of the session
Auxiliary caller cap on OpenAI-wire agent/auxiliary_client.py:_build_call_kwargs Explicit caller-supplied max_tokens silently dropped on every wire except Anthropic-Messages + NVIDIA NIM — no way to bound an auxiliary call to OpenRouter

This PR:

  1. Extracts the cap resolution into _resolve_max_tokens_cap(runtime) — single source of truth, used by both provider-resolution paths.
  2. Adds the same max_tokens to _rehydrate_session_model_override's override dict (second bug class).
  3. Fixes _build_call_kwargs to forward caller-supplied explicit caps on OpenAI-wire / OpenRouter / custom, while preserving the documented default-cap-omitted behaviour for max_tokens=None (third bug class). The retry ladder at line 6288-6294 already strips a 400-rejected max_tokens, so the wire-rejection safety argument survives this change.
  4. Adds regression tests covering every change of behaviour.

Changes

gateway/run.py

  • Extracted helper _resolve_max_tokens_cap (~30 lines + docstring).
  • Delegated from _resolve_runtime_agent_kwargs — drops inline resolution block, becomes a ~5-line call.
  • Added "max_tokens" to _resolve_runtime_agent_kwargs_for_provider's return dict.
  • Added override["max_tokens"] = runtime.get("max_tokens") to the rehydration block.

agent/auxiliary_client.py

  • Second arg branch in _build_call_kwargs: max_tokens is not None blocks now have an else: kwargs["max_tokens"] = max_tokens for non-Anthropic / non-NVIDIA-NIM wires, so caller-supplied caps reach the wire.
  • Comment updated to document the new contract.

tests/gateway/test_max_tokens_propagation.py — 3 new tests:

  • test_pinned_provider_resolves_max_tokens_via_helper_resolve_runtime_agent_kwargs() and _resolve_runtime_agent_kwargs_for_provider("openrouter") agree on every field, including max_tokens (regression for the helper unification).
  • test_pinned_provider_env_var_winsHERMES_MAX_TOKENS env var overrides model.max_tokens: 8192 on the pinned-provider route too.
  • test_pinned_provider_no_cap_means_nonemax_tokens is None when no cap is configured (same contract as the default route, preserves back-compat).

tests/agent/test_auxiliary_build_call_kwargs.py (new file) — 5 tests:

  • test_default_omits_max_tokensmax_tokens=None → neither key in kwargs (preserves the documented quirk-sidestep behaviour on ZAI vision 1210, GitHub Copilot, GPT-5 needing max_completion_tokens).
  • test_caller_supplied_cap_reaches_openrouter — caller cap reaches OpenRouter.
  • test_caller_supplied_cap_reaches_openai_compat — caller cap reaches the custom OpenAI-compatible path.
  • test_caller_supplied_cap_reaches_anthropic_compat — Anthropic-Messages branch continues to forward (was already correct, pinned so the new default-forwarding branch doesn't break it).
  • test_caller_supplied_cap_reaches_nvidia_nim — NVIDIA NIM branch continues to forward (was already correct, empty-choices workaround).

How to Test

pytest tests/gateway/test_max_tokens_propagation.py tests/agent/test_auxiliary_build_call_kwargs.py -v

All 9 existing test_max_tokens_propagation.py tests still pass; the three new tests cover pinned-provider consistency. All five new _build_call_kwargs tests run against the real auxiliary_client via the standard _current_custom_base_url mock — no end-to-end fixtures needed.


Manual Reproduction (Before)

# config.yaml
model:
  provider: openrouter
  max_tokens: 8192

Channel override (e.g. Discord) pins provider: openrouter. Observer captures outbound request — max_tokens field is absent, request asks for the model's default maximum (e.g. 65,536 tokens). Account daily credit limit is exceeded; #59763.

After: outbound request carries max_tokens: 8192 on the default route AND the pinned-provider route AND the rehydrated override route. Auxiliary calls with call_llm(provider="openrouter", max_tokens=512) carry "max_tokens": 512.


Related


Checklist

  • Tests pass — 9/9 existing + 3 new in test_max_tokens_propagation.py, 5/5 new in test_auxiliary_build_call_kwargs.py
  • Follows Conventional Commits
  • Changes scoped to this fix only — 2 source files + 2 test files

Risk & Impact

Low. The extraction is behavior-preserving on the default route — _resolve_max_tokens_cap is the same logic, just shared. The two new propagation points (pinned-provider return dict, rehydration override dict) are additive fields that were previously None. The auxiliary-client fix only changes behavior for callers that explicitly pass max_tokens on non-Anthropic/non-NVIDIA-NIM wires — the default None case is unchanged.

Type: 🐛 Bug fix
Closes: #59763

…ate paths

Three sibling paths were silently dropping the resolved ``max_tokens``
cap whenever a provider was pinned instead of taking the default env
route — ``HERMES_MAX_TOKENS`` / ``model.max_tokens`` /
``max_output_tokens`` only reached the wire on the default route (NousResearch#59763).

This commit consolidates the cap resolution into a single
``_resolve_max_tokens_cap(runtime)`` helper, then hooks the previously
orphaned paths into it. Concretely:

**1. Provider-pinned route (``_resolve_runtime_agent_kwargs_for_provider``)**
— the channel-override / persisted-/-model /explicit-credential path
on ``gateway/run.py``. Its return dict omitted ``max_tokens`` entirely,
so ``runtime_kwargs.get("max_tokens")`` on the consumer side resolved
to ``None`` for every turn. The patch delegates to the same helper as
the default route and includes ``max_tokens`` in the returned dict.

**2. Persisted /model rehydration
(``_rehydrate_session_model_override``)**
— when a session restored a persisted model override after a gateway
restart, the override dict carried ``api_key`` / ``api_mode`` /
``credential_pool`` / ``base_url`` but never ``max_tokens``. The fast
path at ``gateway/run.py:3706`` then read ``None`` for ``max_tokens``
for the lifetime of the session. Adding
``override["max_tokens"] = runtime.get("max_tokens")`` to the same
rehydration block carries the cap forward.

**3. Auxiliary-client caller caps (``_build_call_kwargs`` in
``agent/auxiliary_client.py``)** — issue flagged the unconditional drop
of caller-supplied ``max_tokens`` on every OpenAI-wire / OpenRouter
provider. The fix preserves the documented default (None → omit the
param), AND forward an explicit caller cap on OpenAI-wire / OpenRouter
/ custom. The retry ladder at agent/auxiliary_client.py:6288-6294
already strips a 400-rejected `max_tokens`, so the wire-rejection
safety argument survives this change. Anthropic-Messages and NVIDIA
NIM branches (the two MANDATORY-on-output-threshold providers) are
unchanged — they always carry the cap.

Test coverage:
- ``tests/gateway/test_max_tokens_propagation.py`` — three new tests
  pin the helper + the pinned-provider route: a) default
  ``model.max_tokens`` reaches the pinned-provider return dict
  (agreement with the default route on every other field), b)
  ``HERMES_MAX_TOKENS`` env var overrides the pinned-provider route
  too, c) when no cap is configured the pinned route returns ``None``
  (same contract as the default route).
- ``tests/agent/test_auxiliary_build_call_kwargs.py`` — new file.
  Five tests pin the two new branches: caller-supplied cap reaches
  OpenRouter, custom OpenAI-wire, Anthropic-Messages, NVIDIA NIM, and
  the default (``max_tokens=None``) — preserves the
  intentionally-omitted behaviour the existing comment documented as
  critical for providers that reject the parameter outright (ZAI
  vision 1210, GitHub Copilot, GPT-5 needing
  ``max_completion_tokens``).

Two unrelated follow-up PRs (NousResearch#59792 webtecnica's single-file hotfix
on the gateway side) only address point (1) with duplicated
copied-and-pasted resolution logic — no helper extraction, no test
coverage, no fix for (2)/(3). This PR supersedes NousResearch#59792 by addressing
the full bug class without introducing duplicated logic.

Co-Authored-By: Hermes Agent <noreply@hermes-agent.nousresearch.com>
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/config Config system, migrations, profiles sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages P2 Medium — degraded but workaround exists labels Jul 7, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: fixes issue #59763 and supersets competing open PR #59792 (single-file, path 1 only, no tests). This PR is the broader consolidation covering all three drop sites (pinned-provider resolve, /model rehydration, auxiliary-client cap) via a shared helper. Both are open — a maintainer should pick the canonical fix.

…ontract change

Follow-up to the previous commit: the NousResearch#59763 fix on
``agent/auxiliary_client.py::_build_call_kwargs`` flipped the OpenAI-wire
branch from "drop caller-supplied max_tokens silently" to "forward caller
cap on every wire." That change is the core bug fix (the issue title
called the previous behaviour out as a leak in OpenRouter); the retry
ladder further down the file (line 6288-6294) strips a 400-rejected
``max_tokens``, so the wire-rejection safety argument the old comment
invoked survives the contract change.

Two pre-existing test files pinned the **old** contract literally:

- ``tests/agent/test_auxiliary_client.py::TestBuildCallKwargsMaxTokens
  ::test_omits_max_tokens_for_openai_compatible`` — asserted
  ``"max_tokens" not in kwargs`` when the test passed
  ``max_tokens=1234`` explicitly. The test name and assertion were
  perfect descriptions of the bug, not the fix, so update them.
- ``tests/agent/test_unsupported_temperature_retry.py`` —
  ``test_retries_once_without_temperature`` (sync + async variants)
  asserted ``"max_tokens" not in first_kwargs`` /
  ``not in retry_kwargs`` for the same reason. Update them.

This commit keeps the **NousResearch#34530 contract** (default-omit) intact via
the new ``test_default_omits_max_tokens_on_openai_wire`` test which
pins ``max_tokens=None`` → key absent, on every wire the parametrize
covers. The previous behaviour is preserved for the default path; the
fix's behavioural delta is restricted to caller-supplied caps, which the
shape of every existing test (calling ``max_tokens=1234`` explicitly)
already exercises.

The new test ``test_forwards_caller_supplied_max_tokens_on_openai_wire``
is the positive complement: it asserts explicit cap reaches every wire
(OpenAI-compat, OpenRouter, custom, Nous, ZAI). Anthropic-Messages and
NVIDIA NIM wires keep their existing assertions
(``test_keeps_max_tokens_on_anthropic_wire`` and
``test_keeps_max_tokens_for_nvidia_nim``).

Co-Authored-By: Hermes Agent <noreply@hermes-agent.nousresearch.com>

@teknium1 teknium1 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.

Thanks for consolidating the pinned-provider and auxiliary explicit-cap fixes. The reported omissions still exist on current main (gateway/run.py:1935-1953; agent/auxiliary_client.py:6410-6414).

Problems

  • The rehydration addition does not cover an immediate /model switch. Both live override writers omit max_tokens at gateway/slash_commands.py:1643-1649 and gateway/slash_commands.py:1891-1897; the API-key fast path returns override.get("max_tokens") at gateway/run.py:3804-3824. Thus the first turn after a live switch remains uncapped.
  • The added resolver tests do not cover that session-override flow (tests/gateway/test_max_tokens_propagation.py:201-272).

Suggested changes

  • Carry the resolved cap into both live /model override writers (or resolve it in the fast path), then add live and rehydrated override-to-turn-runtime regression tests.
  • Remove or exercise the unused _pinned_provider_returns helper in the new test file.

Automated hermes-sweeper review.

Comment thread gateway/run.py
# gateway/run.py:3706 sees it on every turn (#59763). When
# ``max_tokens`` is None the cap is intentionally absent —
# the model's default applies.
override["max_tokens"] = runtime.get("max_tokens")

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.

This repairs only restart rehydration. The two live /model override writers still omit max_tokens (gateway/slash_commands.py:1643-1649 and :1891-1897), while the API-key fast path reads that missing value at gateway/run.py:3804-3824. Please cover the live path too so the first post-switch turn has the same cap as a rehydrated session.

@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
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/gateway Gateway runner, session dispatch, delivery 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-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages 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