Skip to content

feat(gateway): expose active provider in runtime footer - #95135

Open
jimmywu0816 wants to merge 1 commit into
NousResearch:mainfrom
jimmywu0816:contrib/footer-provider
Open

jimmywu0816 wants to merge 1 commit into
NousResearch:mainfrom
jimmywu0816:contrib/footer-provider

Conversation

@jimmywu0816

Copy link
Copy Markdown

Independent provider-only implementation (based on current main)

This PR adds an opt-in provider field to the built-in gateway runtime footer, showing the provider that actually served the turn — not the configured primary. It is the provider slice of #35427, implemented independently and verified against the current upstream main (f751a8c).

Problem

agent.provider is updated in place when a fallback provider activates, but the built-in footer path (gateway/runtime_footer.py / the build_footer_line() call site in gateway/run.py) never received it. A footer configured with a provider field had nothing to render, and any fallback switch was invisible in the footer.

Change

  • gateway/runtime_footer.py — new opt-in provider field in format_runtime_footer() / build_footer_line() (keyword-only param, skipped silently when the runtime value is missing). Legacy default fields (model, context_pct, cwd) and _DEFAULT_FIELDS are untouched — byte-stability preserved.
  • gateway/run.py — capture provider from the same live agent instance as model on both result paths (empty reply + normal reply) and pass it to build_footer_line().
  • tests/gateway/test_runtime_footer.py — field ordering, missing/empty/whitespace provider, and the public build_footer_line() path (44 passed).
  • Docs (en + zh-Hans) — document the new field.

Verification

uv run --extra dev pytest -q tests/gateway/test_runtime_footer.py
44 passed in 2.01s
git diff origin/main...HEAD --check
clean

Real-world gateway output with fields: ["bot", "provider", "model", "context_pct"]:

default · deepseek · deepseek-v4-flash · 12%
default · openrouter · gpt-5.6-luna · 29%   # after a fallback activation

Relationship to existing PRs

Happy to close this in favor of #67968 if maintainers prefer to fold the doc/test coverage into that branch — the two are designed to be non-overlapping.

Add an opt-in `provider` runtime-footer field showing the provider that
actually served the turn (fallback switches update agent.provider in place,
so the footer reflects the live backend, not the configured primary).

- gateway/runtime_footer.py: render `provider` when listed in fields;
  skipped silently when the runtime value is missing. Legacy default
  fields (model, context_pct, cwd) and byte-stability are preserved.
- gateway/run.py: capture provider from the same live agent instance as
  model on both result paths and pass it to build_footer_line().
- tests: field ordering, missing/empty/whitespace provider, and the
  public build_footer_line() path.
- docs (en + zh-Hans): document the new field.
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 26, 2026
@alt-glitch

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Related: #67968 already covers the runtime-provider propagation bug with a broader root-path fix. This PR is the narrower built-in runtime-footer implementation; please consider the two together.

@catecholamin

Copy link
Copy Markdown

This PR needs a rebase — its gateway/run.py anchors no longer exist on main

Verified against main @ fa75692 (2026-09-10). The v2026.9.7 module split moved the runtime-footer code path out of gateway/run.py, so all three hunks in this PR currently fail to apply.

What is gone from gateway/run.py on main:

This PR changes Status on main @ fa75692
run.py_resolved_provider capture next to _resolved_model Capture point no longer exists in run.py; it now lives in gateway/run_turn_runner.py
run.py — the two agent_result dicts ("provider": _resolved_provider,) Both dicts moved out of run.py
run.pyGatewayRunner footer call site (_bfl(..., provider=...)) Now in gateway/run_turn.py

The three _resolved_model hits still in main's run.py (L2204, L3317, L3476) are unrelated legacy references — a string in a key list, a legacy_dict_property shim, and a comment. None is the capture site this PR patches, so a naive grep will make the patch look rebasable when it isn't.

The code path on current main is split across two files — both halves need the field

The important part: on main the footer is producer + consumer in different files, and only the consumer survived the split. Patching the call site alone produces a footer that silently drops the field — no error, no log, and runtime_footer config looks perfectly correct.

1. Consumer — gateway/run_turn.py (~L1461), needs the argument added:

from gateway.runtime_footer import build_footer_line as _bfl
return _bfl(
    user_config=_load_gateway_config(),
    platform_key=_platform_config_key(source.platform), model=agent_result.get("model"),
    provider=agent_result.get("provider"),          # <-- add this
    context_tokens=agent_result.get("last_prompt_tokens", 0) or 0,
    context_length=agent_result.get("context_length") or None,
    cwd=_terminal_scope_cwd(""), turn_seconds=_turn_seconds,
)

2. Producer — gateway/run_turn_runner.py, in TurnRunner.run_sync() ("model" line is at L1663 on fa75692), the run result must carry the value. This is the half that was lost in the split, and the half this PR's run.py hunks were meant to cover. Add the field to the usage dict, right after the "model" line:

usage = {
    "last_prompt_tokens": getattr(comp, "last_prompt_tokens", 0) if has_comp else 0,
    "input_tokens": getattr(agent, "session_prompt_tokens", 0) if has_comp else 0,
    "output_tokens": getattr(agent, "session_completion_tokens", 0) if has_comp else 0,
    "model": getattr(agent, "model", None) if agent else None,
    "provider": getattr(agent, "provider", None) if agent else None,   # <-- add this
    "context_length": (getattr(comp, "context_length", 0) or 0) if has_comp else 0,
}

usage is then spread into common = {..., **usage} (L1681), and common is carried by both return paths of run_sync() — so one dict update replaces the two-dict duplication the pre-split run.py code needed.

3. gateway/runtime_footer.py — this half of the PR still applies as-is. The module was not split; _DEFAULT_FIELDS, the format_runtime_footer loop, and build_footer_line are all still where this PR expects them. The only note is that if you keep provider opt-in (out of _DEFAULT_FIELDS, as this PR does), users must list it explicitly — that's fine and I'd keep it that way.

Verification that actually catches the silent-failure mode

Grepping all four anchors green is not sufficient — the failure mode here is a None that propagates into _provider_short() → empty string → the field is dropped with no error anywhere. Assert on rendered output instead:

import sys; sys.path.insert(0, ".")
from gateway.runtime_footer import build_footer_line

cfg = {"display": {"runtime_footer": {"enabled": True,
       "fields": ["model", "provider", "context_pct", "latency"]}}}
line = build_footer_line(user_config=cfg, platform_key=None,
                         model="deepseek-v4-flash", provider="opencode-go",
                         context_tokens=42000, context_length=200000,
                         cwd="/tmp", turn_seconds=12.0)
print(line)  # expect on this PR's renderer: deepseek-v4-flash · opencode-go · 21% · 12s

With both halves applied I get model · provider · context_pct · latency in the footer, and tests/gateway/test_runtime_footer.py stays green (37 passed on v0.21.1 / WSL2). Note my local build maps provider ids to short labels (opencode-goGo) — that part is user-specific and deliberately not something I'm proposing here; this PR's raw-value rendering (L129-132) is the right default for upstream.

Happy to test a rebased push on v0.21.1 / WSL2 if that's useful — just say the word.

(Disclosure: this comment was drafted with AI assistance from a local patch that addresses the same feature; all line references were verified against main @ fa75692 before posting.)

@catecholamin

Copy link
Copy Markdown

Correction to my comment above — .provider is the wrong attribute to read

Follow-up to my previous comment: the producer snippet I posted reads agent.provider, and that is wrong — it reproduces a bug rather than fixing one. Correcting it here so nobody copies the bad version.

Why agent.provider is not enough

For any provider declared under custom_providers: in config.yaml, the runtime resolver strips the name down to a generic literal. Traced on v0.21.1:

config:            model.provider = "custom:deepseek"
_resolve_runtime_agent_kwargs_for_provider("custom:deepseek")
    -> kwargs["provider"]           = "custom"            # generic, loses the name
    -> kwargs["requested_provider"] = "custom:deepseek"   # full channel id

agent.provider is then assigned from that generic value in agent/agent_init.py (~L2245-2251):

provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
agent.provider = provider_name or ""
agent.requested_provider = (
    requested_provider.strip().lower()
    if isinstance(requested_provider, str) and requested_provider.strip()
    else agent.provider
)

So with provider: custom:deepseek configured, a footer rendering agent.provider prints custom — identical for every named custom provider, which defeats the entire point of the field. agent.requested_provider holds the real id.

Corrected producer snippet

Replace the "provider" line from my previous comment with:

"model": getattr(agent, "model", None) if agent else None,
"provider": (getattr(agent, "requested_provider", None)
             or getattr(agent, "provider", None)) if agent else None,

requested_provider falls back to agent.provider at assignment time (see above), so the or chain is belt-and-braces for plain built-in providers — not a correctness requirement.

Verified rendering difference

Same renderer, same config, only the source attribute differs:

provider value passed footer shows
"custom" (from agent.provider) deepseek-flash · custom · 21% · 12s
"custom:deepseek" (from agent.requested_provider) deepseek-flash · DeepSeek官方 · 21% · 12s

(the short labels come from my local build's map — upstream's raw-value renderer will show custom:deepseek, which is still strictly more informative than custom.)

I ran into this live: with the first version of the patch applied, the footer read custom for a custom:deepseek provider, which is what prompted the trace above. Worth an explicit test in the PR — asserting on a named custom provider (not just a built-in one like openrouter) is what catches it, since built-ins don't go through the collapse path.

Sorry for the noise; the line references in the previous comment stand, only that one attribute changes.

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

Labels

comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants