From 80793dc478f94c79f4b94af559efe4694b4c63e0 Mon Sep 17 00:00:00 2001 From: David Kristiansen Date: Mon, 13 Jul 2026 12:44:09 +0200 Subject: [PATCH] fix(llm): surface claude-cli errors reported on stdout The claude CLI reports usage-limit and auth errors on stdout with an empty stderr, so failures surfaced as a bare "claude -p exited 1:". Fall back to the stdout tail when stderr is empty. Co-Authored-By: Claude Opus 4.8 --- graphify/llm.py | 11 +++++++---- tests/test_llm_errors.py | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 tests/test_llm_errors.py diff --git a/graphify/llm.py b/graphify/llm.py index 560bbcb88..0d76f5a4a 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -1274,9 +1274,10 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo **_no_window_kwargs(), ) if proc.returncode != 0: - raise RuntimeError( - f"claude -p exited {proc.returncode}: {proc.stderr.strip()[:500]}" - ) + # The CLI writes usage-limit and auth errors to stdout, not stderr. + # Fall back to the stdout tail when stderr is empty. + detail = proc.stderr.strip()[:500] or proc.stdout.strip()[-500:] + raise RuntimeError(f"claude -p exited {proc.returncode}: {detail}") envelope = _claude_cli_envelope(proc.stdout) @@ -2076,7 +2077,9 @@ def _rec(inp, out) -> None: **_no_window_kwargs(), ) if proc.returncode != 0: - raise RuntimeError(f"claude -p exited {proc.returncode}: {proc.stderr.strip()[:500]}") + # See _call_claude_cli: the CLI writes these errors to stdout. + _detail = proc.stderr.strip()[:500] or proc.stdout.strip()[-500:] + raise RuntimeError(f"claude -p exited {proc.returncode}: {_detail}") envelope = _claude_cli_envelope(proc.stdout) cli_usage = envelope.get("usage") or {} if cli_usage: diff --git a/tests/test_llm_errors.py b/tests/test_llm_errors.py new file mode 100644 index 000000000..2162158a2 --- /dev/null +++ b/tests/test_llm_errors.py @@ -0,0 +1,41 @@ +"""claude-cli error surfacing when the CLI reports failures on stdout.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from graphify import llm as llmmod + + +def _proc(returncode=1, stdout="", stderr=""): + return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr) + + +def test_claude_cli_error_prefers_stderr(monkeypatch): + import shutil as _shutil + import subprocess as _subprocess + + monkeypatch.setattr(_shutil, "which", lambda _: "/usr/bin/claude") + monkeypatch.setattr( + _subprocess, "run", lambda *a, **k: _proc(stderr="real stderr", stdout="noise") + ) + with pytest.raises(RuntimeError, match="real stderr"): + llmmod._call_claude_cli("hi") + + +def test_claude_cli_error_falls_back_to_stdout(monkeypatch): + import shutil as _shutil + import subprocess as _subprocess + + monkeypatch.setattr(_shutil, "which", lambda _: "/usr/bin/claude") + monkeypatch.setattr( + _subprocess, + "run", + lambda *a, **k: _proc( + stdout='{"type":"result","result":"Claude AI usage limit reached|1752200000"}' + ), + ) + with pytest.raises(RuntimeError, match="usage limit reached"): + llmmod._call_claude_cli("hi")