{"repo":"NousResearch/hermes-agent","public_id":"36109","display_label":"Empty HTTP 400 API errors surfacing and handling","summary":"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.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54080,"merged_at":"2026-06-28T09:05:21+00:00"},"resolution":{"state":"resolved","by":"pr-54080"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T09:05:21+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":36109,"pr_number":54080,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-28T09:05:21+00:00"}],"members":[{"kind":"issue","number":36109,"title":"[BUG] v0.15.1 Windows: All LLM providers return empty HTTP 400 responses","url":null,"state":"closed"},{"kind":"pr","number":36135,"title":"fix(agent,cli): surface empty-body API errors and failed oneshot exit (#36109)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54080","number":54080},{"kind":"pr","value":"#36109","number":36109},{"kind":"pr","value":"#54024","number":54024}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":54080,"title":"fix(agent,cli): surface empty-body API errors + fail oneshot exit code (#36109)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"],"fix_hunks":[{"file":"hermes_cli/oneshot.py","hunks":["@@ -178,11 +178,12 @@ def run_oneshot(\n     devnull = open(os.devnull, \"w\", encoding=\"utf-8\")\n \n     response: Optional[str] = None\n+    result: dict = {}\n     failure: BaseException | None = None\n     try:\n         with redirect_stdout(devnull), redirect_stderr(devnull):\n             try:\n-                response = _run_agent(\n+                response, result = _run_agent(\n                     prompt,\n                     model=model,\n                     provider=provider,","@@ -213,16 +214,20 @@ def run_oneshot(\n         real_stderr.flush()\n         return 1\n \n+    if response:\n+        real_stdout.write(response)\n+        if not response.endswith(\"\\n\"):\n+            real_stdout.write(\"\\n\")\n+        real_stdout.flush()\n+\n+    if (result.get(\"failed\") or result.get(\"partial\")) and not (response or \"\").strip():\n+        return 2\n+\n     if not (response or \"\").strip():\n         real_stderr.write(\"hermes -z: no final response was produced; treating the run as failed.\\n\")\n         real_stderr.flush()\n         return 1\n \n-    assert response is not None  # narrowed by the empty-response guard above\n-    real_stdout.write(response)\n-    if not response.endswith(\"\\n\"):\n-        real_stdout.write(\"\\n\")\n-    real_stdout.flush()\n     return 0\n \n ","@@ -248,9 +253,9 @@ def _run_agent(\n     provider: Optional[str] = None,\n     toolsets: object = None,\n     use_config_toolsets: bool = True,\n-) -> str:\n+) -> tuple[str, dict]:\n     \"\"\"Build an AIAgent exactly like a normal CLI chat turn would, then\n-    run a single conversation.  Returns the final response string.\"\"\"\n+    run a single conversation.  Returns ``(final_response, run_result)``.\"\"\"\n     # Imports are local so they don't run when hermes is invoked for\n     # other commands (keeps top-level CLI startup cheap).\n     from hermes_cli.config import load_config","@@ -364,7 +369,8 @@ def _run_agent(\n     agent.stream_delta_callback = None\n     agent.tool_gen_callback = None\n \n-    return agent.chat(prompt) or \"\"\n+    result = agent.run_conversation(prompt)\n+    return (result.get(\"final_response\") or \"\", result)\n \n \n def _oneshot_clarify_callback(question: str, choices=None) -> str:"]},{"file":"run_agent.py","hunks":["@@ -1953,6 +1953,29 @@ def _summarize_api_error(error: Exception) -> str:\n                 msg = AIAgent._coerce_api_error_detail(msg)\n                 return AIAgent._decorate_xai_entitlement_error(f\"{prefix}{msg[:300]}\")\n \n+        # SDK may leave body empty while httpx still has the payload (#36109).\n+        # Redact before returning: the raw provider/proxy error body is\n+        # attacker-influenced and may echo Authorization / x-api-key / request\n+        # JSON, which would otherwise leak into final_response + logs (this path\n+        # widens exposure vs the old empty-body \"HTTP 400\" string).\n+        response = getattr(error, \"response\", None)\n+        if response is not None:\n+            snippet = (getattr(response, \"text\", None) or \"\").strip()\n+            if snippet:\n+                status_code = getattr(error, \"status_code\", None)\n+                prefix = f\"HTTP {status_code}: \" if status_code else \"\"\n+                try:\n+                    payload = json.loads(snippet)\n+                except (json.JSONDecodeError, TypeError):\n+                    payload = None\n+                if isinstance(payload, dict):\n+                    err = payload.get(\"error\")\n+                    if isinstance(err, dict) and err.get(\"message\"):\n+                        return redact_sensitive_text(f\"{prefix}{str(err['message'])[:300]}\")\n+                    if payload.get(\"message\"):\n+                        return redact_sensitive_text(f\"{prefix}{str(payload['message'])[:300]}\")\n+                return redact_sensitive_text(f\"{prefix}{snippet[:300]}\")\n+\n         # Fallback: truncate the raw string but give more room than 200 chars\n         status_code = getattr(error, \"status_code\", None)\n         prefix = f\"HTTP {status_code}: \" if status_code else \"\""]},{"file":"tests/hermes_cli/test_tui_resume_flow.py","hunks":["@@ -642,7 +642,7 @@ def test_oneshot_fails_closed_on_empty_final_response(monkeypatch, capsys):\n     _stub_plugin_discovery(monkeypatch)\n     import hermes_cli.oneshot as oneshot_mod\n \n-    monkeypatch.setattr(oneshot_mod, \"_run_agent\", lambda *_args, **_kwargs: \"\")\n+    monkeypatch.setattr(oneshot_mod, \"_run_agent\", lambda *_args, **_kwargs: (\"\", {}))\n \n     assert oneshot_mod.run_oneshot(\"hello\") == 1\n     captured = capsys.readouterr()","@@ -654,7 +654,7 @@ def test_oneshot_prints_nonempty_final_response(monkeypatch, capsys):\n     _stub_plugin_discovery(monkeypatch)\n     import hermes_cli.oneshot as oneshot_mod\n \n-    monkeypatch.setattr(oneshot_mod, \"_run_agent\", lambda *_args, **_kwargs: \"done\")\n+    monkeypatch.setattr(oneshot_mod, \"_run_agent\", lambda *_args, **_kwargs: (\"done\", {}))\n \n     assert oneshot_mod.run_oneshot(\"hello\") == 0\n     captured = capsys.readouterr()","@@ -678,6 +678,30 @@ def _boom(*_args, **_kwargs):\n     assert \"not a TTY\" in captured.err\n \n \n+def test_oneshot_exit_code_when_failed_without_response(monkeypatch):\n+    from hermes_cli.oneshot import run_oneshot\n+\n+    monkeypatch.setattr(\n+        \"hermes_cli.oneshot._run_agent\",\n+        lambda *_a, **_k: (\"\", {\"failed\": True, \"partial\": False}),\n+    )\n+    assert run_oneshot(\"hi\") == 2\n+\n+\n+def test_oneshot_exit_code_zero_when_failed_with_error_text(monkeypatch, capsys):\n+    from hermes_cli.oneshot import run_oneshot\n+\n+    monkeypatch.setattr(\n+        \"hermes_cli.oneshot._run_agent\",\n+        lambda *_a, **_k: (\n+            \"API call failed after 3 retries: HTTP 404: model not found\",\n+            {\"failed\": True, \"partial\": False},\n+        ),\n+    )\n+    assert run_oneshot(\"hi\") == 0\n+    assert \"HTTP 404\" in capsys.readouterr().out\n+\n+\n def test_oneshot_reraises_keyboard_interrupt(monkeypatch):\n     _stub_plugin_discovery(monkeypatch)\n     import hermes_cli.oneshot as oneshot_mod","@@ -806,9 +830,9 @@ def __init__(self, **kwargs):\n             self.stream_delta_callback = object()\n             self.tool_gen_callback = object()\n \n-        def chat(self, prompt):\n+        def run_conversation(self, prompt, **_kwargs):\n             captured[\"prompt\"] = prompt\n-            return \"ok\"\n+            return {\"final_response\": \"ok\", \"failed\": False, \"partial\": False}\n \n     class FakeSessionDB:\n         def __new__(cls):","@@ -852,7 +876,9 @@ def mod(name, **attrs):\n         mod(\"hermes_cli.tools_config\", _get_platform_tools=lambda *_args, **_kwargs: {\"session_search\"}),\n     )\n \n-    assert _run_agent(\"recall this\") == \"ok\"\n+    text, result = _run_agent(\"recall this\")\n+    assert text == \"ok\"\n+    assert not result.get(\"failed\")\n     assert captured[\"session_db\"] is sentinel_db\n     assert captured[\"enabled_toolsets\"] == [\"session_search\"]\n     assert captured[\"prompt\"] == \"recall this\""]},{"file":"tests/run_agent/test_summarize_api_error.py","hunks":["@@ -0,0 +1,56 @@\n+\"\"\"Regression: empty-body HTTP 4xx errors must still surface a real provider message.\n+\n+Reported on Windows (#36109): an LLM API call returned HTTP 400 with an *empty*\n+parsed SDK ``body`` ({}), so ``_summarize_api_error`` fell through to the bare\n+``str(error)`` path and the user saw only \"HTTP 400\" with no provider detail.\n+The SDK leaves ``body`` empty in this case, but the underlying httpx\n+``response`` still carries the real payload in ``.text``. These tests lock the\n+contract: when ``body`` is empty, fall back to ``response.text`` (parsing a JSON\n+``error.message`` / ``message`` when present) so logs and CLI show the real\n+provider error. This is a diagnostic improvement and is platform-agnostic.\n+\"\"\"\n+\n+from types import SimpleNamespace\n+\n+from run_agent import AIAgent\n+\n+\n+def _make_empty_body_error(response_text: str, status_code: int = 400) -> Exception:\n+    \"\"\"Mimic an OpenAI-SDK error whose parsed body is empty but whose httpx\n+    response still holds the payload text.\"\"\"\n+    err = Exception(\"\")  # str(error) is empty/uninformative on this path\n+    err.status_code = status_code\n+    err.body = {}  # empty dict — the #36109 trigger\n+    err.response = SimpleNamespace(text=response_text)\n+    return err\n+\n+\n+def test_empty_body_falls_back_to_response_json_error_message():\n+    \"\"\"A JSON payload with error.message is surfaced (not a bare HTTP 400).\"\"\"\n+    err = _make_empty_body_error(\n+        '{\"error\": {\"message\": \"model `foo` does not exist\", \"type\": \"invalid_request_error\"}}'\n+    )\n+    summary = AIAgent._summarize_api_error(err)\n+    assert \"HTTP 400\" in summary\n+    assert \"model `foo` does not exist\" in summary\n+\n+\n+def test_empty_body_falls_back_to_raw_response_text_when_not_json():\n+    \"\"\"A non-JSON response body is surfaced verbatim (truncated), not dropped.\"\"\"\n+    err = _make_empty_body_error(\"upstream connect error or disconnect/reset before headers\")\n+    summary = AIAgent._summarize_api_error(err)\n+    assert \"HTTP 400\" in summary\n+    assert \"upstream connect error\" in summary\n+\n+\n+def test_empty_body_fallback_redacts_secrets(monkeypatch):\n+    \"\"\"The surfaced provider/proxy error body must pass through the secret\n+    redactor — a proxy echoing an API key in the error must not leak it into\n+    final_response/logs (the empty-body path previously hid it as bare HTTP 400).\"\"\"\n+    monkeypatch.setenv(\"HERMES_REDACT_SECRETS\", \"true\")\n+    err = _make_empty_body_error(\n+        '{\"error\": {\"message\": \"bad key: [redacted:openai-key]\"}}'\n+    )\n+    summary = AIAgent._summarize_api_error(err)\n+    assert \"[redacted:openai-key]\" not in summary\n+\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}