NousResearch/hermes-agent

Empty HTTP 400 API errors surfacing and handling

Cluster 36109 · agent. Data as of

Nothing open on GitHub. Fix merged in #54080 on 28 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #54080. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #54080. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and fixes relate to empty-body HTTP 400 error responses from LLM providers going undetected or unsurfaced, including a Windows-specific regression and related sanitizer and exit code fixes.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #54080 fix(agent,cli): surface empty-body API errors + fail oneshot exit code (#36109)

    merged to main

    files: hermes_cli/oneshot.py, infographic/empty-400-unmasked/infographic.png, run_agent.py, tests/hermes_cli/test_tui_resume_flow.py, tests/run_agent/test_summarize_api_error.py

    Show the diff
    • hermes_cli/oneshot.py
      @@ -178,11 +178,12 @@ def run_oneshot(
           devnull = open(os.devnull, "w", encoding="utf-8")
       
           response: Optional[str] = None
      +    result: dict = {}
           failure: BaseException | None = None
           try:
               with redirect_stdout(devnull), redirect_stderr(devnull):
                   try:
      -                response = _run_agent(
      +                response, result = _run_agent(
                           prompt,
                           model=model,
                           provider=provider,
      @@ -213,16 +214,20 @@ def run_oneshot(
               real_stderr.flush()
               return 1
       
      +    if response:
      +        real_stdout.write(response)
      +        if not response.endswith("\n"):
      +            real_stdout.write("\n")
      +        real_stdout.flush()
      +
      +    if (result.get("failed") or result.get("partial")) and not (response or "").strip():
      +        return 2
      +
           if not (response or "").strip():
               real_stderr.write("hermes -z: no final response was produced; treating the run as failed.\n")
               real_stderr.flush()
               return 1
       
      -    assert response is not None  # narrowed by the empty-response guard above
      -    real_stdout.write(response)
      -    if not response.endswith("\n"):
      -        real_stdout.write("\n")
      -    real_stdout.flush()
           return 0
       
       
      @@ -248,9 +253,9 @@ def _run_agent(
           provider: Optional[str] = None,
           toolsets: object = None,
           use_config_toolsets: bool = True,
      -) -> str:
      +) -> tuple[str, dict]:
           """Build an AIAgent exactly like a normal CLI chat turn would, then
      -    run a single conversation.  Returns the final response string."""
      +    run a single conversation.  Returns ``(final_response, run_result)``."""
           # Imports are local so they don't run when hermes is invoked for
           # other commands (keeps top-level CLI startup cheap).
           from hermes_cli.config import load_config
      @@ -364,7 +369,8 @@ def _run_agent(
           agent.stream_delta_callback = None
           agent.tool_gen_callback = None
       
      -    return agent.chat(prompt) or ""
      +    result = agent.run_conversation(prompt)
      +    return (result.get("final_response") or "", result)
       
       
       def _oneshot_clarify_callback(question: str, choices=None) -> str:
    • run_agent.py
      @@ -1953,6 +1953,29 @@ def _summarize_api_error(error: Exception) -> str:
                       msg = AIAgent._coerce_api_error_detail(msg)
                       return AIAgent._decorate_xai_entitlement_error(f"{prefix}{msg[:300]}")
       
      +        # SDK may leave body empty while httpx still has the payload (#36109).
      +        # Redact before returning: the raw provider/proxy error body is
      +        # attacker-influenced and may echo Authorization / x-api-key / request
      +        # JSON, which would otherwise leak into final_response + logs (this path
      +        # widens exposure vs the old empty-body "HTTP 400" string).
      +        response = getattr(error, "response", None)
      +        if response is not None:
      +            snippet = (getattr(response, "text", None) or "").strip()
      +            if snippet:
      +                status_code = getattr(error, "status_code", None)
      +                prefix = f"HTTP {status_code}: " if status_code else ""
      +                try:
      +                    payload = json.loads(snippet)
      +                except (json.JSONDecodeError, TypeError):
      +                    payload = None
      +                if isinstance(payload, dict):
      +                    err = payload.get("error")
      +                    if isinstance(err, dict) and err.get("message"):
      +                        return redact_sensitive_text(f"{prefix}{str(err['message'])[:300]}")
      +                    if payload.get("message"):
      +                        return redact_sensitive_text(f"{prefix}{str(payload['message'])[:300]}")
      +                return redact_sensitive_text(f"{prefix}{snippet[:300]}")
      +
               # Fallback: truncate the raw string but give more room than 200 chars
               status_code = getattr(error, "status_code", None)
               prefix = f"HTTP {status_code}: " if status_code else ""
    • tests/hermes_cli/test_tui_resume_flow.py
      @@ -642,7 +642,7 @@ def test_oneshot_fails_closed_on_empty_final_response(monkeypatch, capsys):
           _stub_plugin_discovery(monkeypatch)
           import hermes_cli.oneshot as oneshot_mod
       
      -    monkeypatch.setattr(oneshot_mod, "_run_agent", lambda *_args, **_kwargs: "")
      +    monkeypatch.setattr(oneshot_mod, "_run_agent", lambda *_args, **_kwargs: ("", {}))
       
           assert oneshot_mod.run_oneshot("hello") == 1
           captured = capsys.readouterr()
      @@ -654,7 +654,7 @@ def test_oneshot_prints_nonempty_final_response(monkeypatch, capsys):
           _stub_plugin_discovery(monkeypatch)
           import hermes_cli.oneshot as oneshot_mod
       
      -    monkeypatch.setattr(oneshot_mod, "_run_agent", lambda *_args, **_kwargs: "done")
      +    monkeypatch.setattr(oneshot_mod, "_run_agent", lambda *_args, **_kwargs: ("done", {}))
       
           assert oneshot_mod.run_oneshot("hello") == 0
           captured = capsys.readouterr()
      @@ -678,6 +678,30 @@ def _boom(*_args, **_kwargs):
           assert "not a TTY" in captured.err
       
       
      +def test_oneshot_exit_code_when_failed_without_response(monkeypatch):
      +    from hermes_cli.oneshot import run_oneshot
      +
      +    monkeypatch.setattr(
      +        "hermes_cli.oneshot._run_agent",
      +        lambda *_a, **_k: ("", {"failed": True, "partial": False}),
      +    )
      +    assert run_oneshot("hi") == 2
      +
      +
      +def test_oneshot_exit_code_zero_when_failed_with_error_text(monkeypatch, capsys):
      +    from hermes_cli.oneshot import run_oneshot
      +
      +    monkeypatch.setattr(
      +        "hermes_cli.oneshot._run_agent",
      +        lambda *_a, **_k: (
      +            "API call failed after 3 retries: HTTP 404: model not found",
      +            {"failed": True, "partial": False},
      +        ),
      +    )
      +    assert run_oneshot("hi") == 0
      +    assert "HTTP 404" in capsys.readouterr().out
      +
      +
       def test_oneshot_reraises_keyboard_interrupt(monkeypatch):
           _stub_plugin_discovery(monkeypatch)
           import hermes_cli.oneshot as oneshot_mod
      @@ -806,9 +830,9 @@ def __init__(self, **kwargs):
                   self.stream_delta_callback = object()
                   self.tool_gen_callback = object()
       
      -        def chat(self, prompt):
      +        def run_conversation(self, prompt, **_kwargs):
                   captured["prompt"] = prompt
      -            return "ok"
      +            return {"final_response": "ok", "failed": False, "partial": False}
       
           class FakeSessionDB:
               def __new__(cls):
      @@ -852,7 +876,9 @@ def mod(name, **attrs):
               mod("hermes_cli.tools_config", _get_platform_tools=lambda *_args, **_kwargs: {"session_search"}),
           )
       
      -    assert _run_agent("recall this") == "ok"
      +    text, result = _run_agent("recall this")
      +    assert text == "ok"
      +    assert not result.get("failed")
           assert captured["session_db"] is sentinel_db
           assert captured["enabled_toolsets"] == ["session_search"]
           assert captured["prompt"] == "recall this"
    • tests/run_agent/test_summarize_api_error.py
      @@ -0,0 +1,56 @@
      +"""Regression: empty-body HTTP 4xx errors must still surface a real provider message.
      +
      +Reported on Windows (#36109): an LLM API call returned HTTP 400 with an *empty*
      +parsed SDK ``body`` ({}), so ``_summarize_api_error`` fell through to the bare
      +``str(error)`` path and the user saw only "HTTP 400" with no provider detail.
      +The SDK leaves ``body`` empty in this case, but the underlying httpx
      +``response`` still carries the real payload in ``.text``. These tests lock the
      +contract: when ``body`` is empty, fall back to ``response.text`` (parsing a JSON
      +``error.message`` / ``message`` when present) so logs and CLI show the real
      +provider error. This is a diagnostic improvement and is platform-agnostic.
      +"""
      +
      +from types import SimpleNamespace
      +
      +from run_agent import AIAgent
      +
      +
      +def _make_empty_body_error(response_text: str, status_code: int = 400) -> Exception:
      +    """Mimic an OpenAI-SDK error whose parsed body is empty but whose httpx
      +    response still holds the payload text."""
      +    err = Exception("")  # str(error) is empty/uninformative on this path
      +    err.status_code = status_code
      +    err.body = {}  # empty dict — the #36109 trigger
      +    err.response = SimpleNamespace(text=response_text)
      +    return err
      +
      +
      +def test_empty_body_falls_back_to_response_json_error_message():
      +    """A JSON payload with error.message is surfaced (not a bare HTTP 400)."""
      +    err = _make_empty_body_error(
      +        '{"error": {"message": "model `foo` does not exist", "type": "invalid_request_error"}}'
      +    )
      +    summary = AIAgent._summarize_api_error(err)
      +    assert "HTTP 400" in summary
      +    assert "model `foo` does not exist" in summary
      +
      +
      +def test_empty_body_falls_back_to_raw_response_text_when_not_json():
      +    """A non-JSON response body is surfaced verbatim (truncated), not dropped."""
      +    err = _make_empty_body_error("upstream connect error or disconnect/reset before headers")
      +    summary = AIAgent._summarize_api_error(err)
      +    assert "HTTP 400" in summary
      +    assert "upstream connect error" in summary
      +
      +
      +def test_empty_body_fallback_redacts_secrets(monkeypatch):
      +    """The surfaced provider/proxy error body must pass through the secret
      +    redactor — a proxy echoing an API key in the error must not leak it into
      +    final_response/logs (the empty-body path previously hid it as bare HTTP 400)."""
      +    monkeypatch.setenv("HERMES_REDACT_SECRETS", "true")
      +    err = _make_empty_body_error(
      +        '{"error": {"message": "bad key: [redacted:openai-key]"}}'
      +    )
      +    summary = AIAgent._summarize_api_error(err)
      +    assert "[redacted:openai-key]" not in summary
      +

1 PR closed without merging

From the close comments: merged via another change 1

  • #36135 fix(agent,cli): surface empty-body API errors and failed oneshot exit (#36109) · merged via another change → #54080, #36109, #54024
Closed issues · 1
  • Closed#36109[BUG] v0.15.1 Windows: All LLM providers return empty HTTP 400 responses
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#54080
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
28 Jun 2026

For your agent

The same facts, typed.