# SPDX-License-Identifier: MIT """llm_request must surface server errors instead of hiding them. Before: an HTTP error status (e.g. llama-server's 400 "request exceeds the available context size") was reported as "Cannot reach llama-server" and retried twice; an error event inside the SSE stream, or a plain JSON reply from a server that ignored "stream": true, became a silent empty answer. """ import json import os import sys import threading from http.server import BaseHTTPRequestHandler, HTTPServer import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) import trashclaw @pytest.fixture def fake_server(monkeypatch): state = {"status": 200, "ctype": "text/event-stream", "body": b"", "posts": 0} class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_POST(self): self.rfile.read(int(self.headers.get("Content-Length", 0))) state["posts"] += 1 body = state["body"] self.send_response(state["status"]) self.send_header("Content-Type", state["ctype"]) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) server = HTTPServer(("127.0.0.1", 0), Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() monkeypatch.setattr(trashclaw, "LLAMA_URL", "http://127.0.0.1:%d" % server.server_address[1]) monkeypatch.setattr(trashclaw, "LLM_RETRY_DELAY", 0) monkeypatch.setattr(trashclaw, "_INTERRUPTED", False) yield state server.shutdown() server.server_close() MSGS = [{"role": "user", "content": "hi"}] def test_http_400_reports_server_message_and_is_not_retried(fake_server): fake_server.update(status=400, ctype="application/json", body=json.dumps( {"error": {"code": 400, "message": "the request exceeds the available context size"}} ).encode()) result = trashclaw.llm_request_with_retry(MSGS) assert "error" in result assert "Cannot reach" not in result["error"] assert "HTTP 400" in result["error"] assert "exceeds the available context size" in result["error"] assert fake_server["posts"] == 1 def test_http_503_is_retried(fake_server): fake_server.update(status=503, ctype="application/json", body=b'{"error": {"message": "Loading model"}}') result = trashclaw.llm_request_with_retry(MSGS) assert "HTTP 503" in result["error"] assert "Loading model" in result["error"] assert fake_server["posts"] == trashclaw.LLM_RETRY_ATTEMPTS + 1 def test_error_event_in_stream_is_reported(fake_server): fake_server.update(body=b'data: {"error": {"message": "model crashed"}}\n\n') result = trashclaw.llm_request(MSGS) assert "error" in result assert "model crashed" in result["error"] def test_plain_json_reply_is_used(fake_server): fake_server.update(ctype="application/json", body=json.dumps({ "choices": [{"message": {"content": "hello there"}, "finish_reason": "stop"}] }).encode()) result = trashclaw.llm_request(MSGS) assert result["choices"][0]["message"]["content"] == "hello there" def test_plain_json_error_is_reported(fake_server): fake_server.update(ctype="application/json", body=b'{"error": "bad model name"}') result = trashclaw.llm_request(MSGS) assert "bad model name" in result.get("error", "") def test_normal_stream_still_works(fake_server): chunks = [ {"choices": [{"delta": {"content": "Hel"}}]}, {"choices": [{"delta": {"content": "lo"}, "finish_reason": "stop"}]}, ] body = ": keep-alive\n\n" + "".join("data: %s\n\n" % json.dumps(c) for c in chunks) + "data: [DONE]\n\n" fake_server.update(body=body.encode()) result = trashclaw.llm_request(MSGS) assert result["choices"][0]["message"]["content"] == "Hello" assert result["choices"][0]["finish_reason"] == "stop" def test_plain_json_reply_updates_stats(fake_server, monkeypatch): monkeypatch.setattr(trashclaw, "LAST_GENERATION_STATS", {}) monkeypatch.setattr(trashclaw, "SESSION_STATS", dict(trashclaw.SESSION_STATS, turns=0, total_tokens=0, total_seconds=0.0)) fake_server.update(ctype="application/json", body=json.dumps({ "choices": [{"message": {"content": "x" * 40}, "finish_reason": "stop"}] }).encode()) trashclaw.llm_request(MSGS) assert trashclaw.LAST_GENERATION_STATS["tokens"] == 10 assert trashclaw.SESSION_STATS["turns"] == 1