From ebf7c3a5af012641644daaa27e15f45ad61cf2f8 Mon Sep 17 00:00:00 2001 From: Brice Vandeputte Date: Wed, 21 Oct 2026 14:05:22 +0200 Subject: [PATCH] feat: add optional provider health and long-request feedback (#25) Keep the Opper probe unauthenticated and opt-in on /health only. Preserve the long request deadline while reporting periodic progress and sanitized provider failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .env.coolify.template | 3 + .env.template | 4 + bot/docker-compose.yml | 1 + bot/telegram/AGENTS.md | 3 + bot/telegram/bloc_bridge/bridge.py | 73 ++++- bot/telegram/bloc_bridge/bridge_config.py | 2 + bot/telegram/bloc_bridge/opencode/api.py | 24 +- bot/telegram/bloc_bridge/opper_health.py | 41 +++ docs/diagrams/bloc_bridge-exchanges.puml | 13 +- docs/provider.md | 19 ++ docs/usage.md | 10 + tests/test_telegram_bridge.py | 4 +- tests/test_telegram_health_feedback.py | 312 ++++++++++++++++++++++ 13 files changed, 485 insertions(+), 24 deletions(-) create mode 100644 bot/telegram/bloc_bridge/opper_health.py create mode 100644 tests/test_telegram_health_feedback.py diff --git a/.env.coolify.template b/.env.coolify.template index aeb71fa..6755876 100644 --- a/.env.coolify.template +++ b/.env.coolify.template @@ -27,3 +27,6 @@ TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here # Comma-separated Telegram chat IDs allowed to use the bot. # Replace this example ID with your own numeric chat ID(s). TELEGRAM_ALLOWED_CHAT_IDS=123456789 + +# Optional /health Opper API reachability check, without credentials or inference. +TELEGRAM_HEALTH_OPPER_ENABLED=false diff --git a/.env.template b/.env.template index 3ae47ae..1377ba5 100644 --- a/.env.template +++ b/.env.template @@ -12,6 +12,10 @@ TELEGRAM_ALLOWED_CHAT_IDS= # Keep disabled normally; output is written to /data/cost-diagnostics.jsonl in the bridge volume. TELEGRAM_COST_DIAGNOSTICS=false +# Optional /health reachability check from the bridge to Opper (no key or inference). +# Does not validate OpenCode's provider credentials, credits, or model availability. +TELEGRAM_HEALTH_OPPER_ENABLED=false + # Opper AI provider (https://opper.ai). OPPER_API_KEY= diff --git a/bot/docker-compose.yml b/bot/docker-compose.yml index ef8b6e0..b5e9f5b 100644 --- a/bot/docker-compose.yml +++ b/bot/docker-compose.yml @@ -48,6 +48,7 @@ services: TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_ALLOWED_CHAT_IDS: ${TELEGRAM_ALLOWED_CHAT_IDS:-} TELEGRAM_COST_DIAGNOSTICS: ${TELEGRAM_COST_DIAGNOSTICS:-false} + TELEGRAM_HEALTH_OPPER_ENABLED: ${TELEGRAM_HEALTH_OPPER_ENABLED:-false} OPENCODE_URL: http://opencode:4096 OPENCODE_MODEL: opper/${OPPER_MODEL:?OPPER_MODEL is required}#${OPPER_VARIANT:-medium} depends_on: diff --git a/bot/telegram/AGENTS.md b/bot/telegram/AGENTS.md index f4835cb..315f2f3 100644 --- a/bot/telegram/AGENTS.md +++ b/bot/telegram/AGENTS.md @@ -18,6 +18,7 @@ Docker runs `python -m bloc_bridge`. Its Dockerfile copies the entire package. - `opencode/cost_reporting.py`: usage aggregation and sanitized diagnostics. - `bridge_runtime.py`: daemon workers, shutdown context, and cooperative cancellation. - `http_api.py`: shared HTTP transport, Telegram error classification, and polling lock. +- `opper_health.py`: isolated, optional, unauthenticated Opper reachability check for `/health` only. - `bridge_config.py` and `session_state.py`: environment settings and persisted chat sessions. For an overview, see [message flow](../../docs/diagrams/bloc_bridge-overview.puml). @@ -36,6 +37,8 @@ For details, see [exchanges and shutdown](../../docs/diagrams/bloc_bridge-exchan persist mappings with atomic file replacement. - Slash commands never submit a model prompt. `/new`, `/health`, and `/cost` can call OpenCode APIs; `/start`, `/help`, and unknown commands are handled locally. +- `/health` contacts Opper only when `TELEGRAM_HEALTH_OPPER_ENABLED=true`; this is a + bridge-side reachability check, not proof of authentication or working inference. - Correlate assistant responses to the generated prompt message ID. SSE is optional progress information, not proof of completion; status and history determine completion. - Costs come from OpenCode usage, including descendant sessions, not price estimates. diff --git a/bot/telegram/bloc_bridge/bridge.py b/bot/telegram/bloc_bridge/bridge.py index 881d100..360c5f2 100644 --- a/bot/telegram/bloc_bridge/bridge.py +++ b/bot/telegram/bloc_bridge/bridge.py @@ -11,15 +11,17 @@ import urllib.parse import uuid -from . import bridge_runtime, http_api, session_state +from . import bridge_runtime, http_api, opper_health, session_state from .opencode import cost_reporting, api as opencode_api from .telegram import markdown_renderer, polling as telegram_polling from .bridge_config import ( ALLOWED, + HEALTH_OPPER_ENABLED, MODEL, OC, OPENCODE_HTTP_TIMEOUT_SECONDS, OPENCODE_STATUS_TIMEOUT_SECONDS, + OPENCODE_PROGRESS_INTERVAL_SECONDS, SYSTEM, TELEGRAM_HTTP_TIMEOUT_SECONDS, TELEGRAM_LONG_POLL_TIMEOUT_SECONDS, @@ -244,6 +246,7 @@ def answer_async(chat_id, prompt, sessions, reference, set_phase, progress): set_phase("response_wait") idle_samples = 0 last_progress = None + last_progress_at = time.monotonic() response_messages = [] while True: bridge_runtime.check_shutdown() @@ -264,11 +267,14 @@ def answer_async(chat_id, prompt, sessions, reference, set_phase, progress): if time.monotonic() >= deadline: raise OpenCodeRequestTimeout("OpenCode response deadline exceeded") correlated = opencode_api.assistant_messages_for_prompt(response_messages, prompt_message_id) + for message in correlated: + opencode_api.raise_response_error(message) assistant_ids = { message.get("info", {}).get("id") for message in correlated if message.get("info", {}).get("id") } + stage = None while True: try: event_message_id, part_type = event_queue.get_nowait() @@ -276,13 +282,12 @@ def answer_async(chat_id, prompt, sessions, reference, set_phase, progress): break if event_message_id not in assistant_ids: continue - stage = { + event_stage = { "reasoning": "OpenCode is thinking…", "tool": "OpenCode is working with a tool…", }.get(part_type) - if stage and stage != last_progress: - progress(stage) - last_progress = stage + if event_stage: + stage = event_stage if ( correlated @@ -291,6 +296,21 @@ def answer_async(chat_id, prompt, sessions, reference, set_phase, progress): and idle_samples >= 2 ): break + now = time.monotonic() + if ( + (stage and last_progress is None) + or now - last_progress_at >= OPENCODE_PROGRESS_INTERVAL_SECONDS + ): + elapsed = int(now - (deadline - OPENCODE_HTTP_TIMEOUT_SECONDS)) + text = stage or "Still waiting for OpenCode" + if last_progress is not None or stage is None: + text = f"{text} ({elapsed}s elapsed)." + progress(text) + last_progress = stage or "waiting" + last_progress_at = time.monotonic() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise OpenCodeRequestTimeout("OpenCode response deadline exceeded") bridge_runtime.wait(min(1, max(0, remaining))) set_phase("history_after") @@ -358,7 +378,7 @@ def help_response(): "/start — welcome message", "/help — list available commands", "/new — start a new session; the previous session is kept", - "/health — check bridge, OpenCode API, and session status", + "/health — check bridge, OpenCode API, session, and optional provider reachability", "/cost — show cost and usage for this chat's current session", ) ) @@ -408,6 +428,11 @@ def health_response(chat_id, sessions): lines.append("This chat's session: available" if available else "This chat's session: unavailable") except Exception: lines.append("This chat's session: unavailable") + lines.append( + opper_health.health_status() + if HEALTH_OPPER_ENABLED + else "Opper API: not checked (disabled)" + ) return "\n".join(lines) @@ -473,6 +498,8 @@ def _safe_error_detail(error): else None ) suffix = f" error_code={error_code}" if error_code else "" + if error.status_code is not None: + suffix += f" http_status={error.status_code}" return f"response_error={error_name}{suffix}" if isinstance(error, urllib.error.URLError): reason = error.reason @@ -508,6 +535,31 @@ def _is_timeout_error(error): return isinstance(error, http_api.OpenCodeHTTPError) and error.status in (408, 504) +def _opencode_failure_text(error, reference): + if _is_timeout_error(error): + summary = "OpenCode timed out." + elif isinstance(error, opencode_api.OpenCodeResponseError): + status = error.status_code + if error.error_name == "ProviderAuthError" or status in (401, 403): + summary = "OpenCode reported a provider authentication/access failure. Ask the operator to check credentials." + elif status == 429: + summary = "OpenCode reported a provider rate/quota limit. Ask the operator to check provider limits." + elif status is not None and status >= 500: + summary = "OpenCode reported a provider service error. Ask the operator to check provider availability." + else: + summary = "OpenCode reported an assistant/provider error." + elif isinstance(error, (urllib.error.URLError, ConnectionError)): + summary = "OpenCode API is unreachable. Ask the operator to check the service and network." + elif isinstance(error, http_api.OpenCodeHTTPError): + summary = f"OpenCode API rejected the request (HTTP {error.status})." + else: + summary = "OpenCode request failed." + return ( + f"{summary} The request may still be running; please check before retrying. " + f"Please share this reference with the operator: {reference}" + ) + + def _log_request_error(reference, update_id, phase, started, error): if phase.startswith("telegram_"): client = "telegram" @@ -556,6 +608,7 @@ def progress(text): edit_reply(chat_id, acknowledgement_id, f"{text}\n\nRef: {reference}") except Exception as error: _log_request_error(reference, update_id, state["phase"], started, error) + finally: set_phase("response_wait") try: @@ -603,13 +656,7 @@ def progress(text): "response_wait", "history_after", ): - if _is_timeout_error(error): - error_text = ( - "OpenCode timed out. The request may still be running; " - f"please check before retrying. Ref: {reference}" - ) - else: - error_text = f"OpenCode request failed. Please share this reference with the operator: {reference}" + error_text = _opencode_failure_text(error, reference) else: error_text = f"Request failed. Please share this reference with the operator: {reference}" edit_reply( diff --git a/bot/telegram/bloc_bridge/bridge_config.py b/bot/telegram/bloc_bridge/bridge_config.py index 0ac09fa..8c81f5f 100644 --- a/bot/telegram/bloc_bridge/bridge_config.py +++ b/bot/telegram/bloc_bridge/bridge_config.py @@ -12,6 +12,7 @@ OPENCODE_HTTP_TIMEOUT_SECONDS = 300 OPENCODE_STATUS_TIMEOUT_SECONDS = 10 OPENCODE_EVENT_TIMEOUT_SECONDS = 15 +OPENCODE_PROGRESS_INTERVAL_SECONDS = 30 TELEGRAM_HTTP_TIMEOUT_SECONDS = 70 TELEGRAM_LONG_POLL_TIMEOUT_SECONDS = 50 @@ -19,6 +20,7 @@ POLLING_LOCK_PATH = "/data/telegram-polling.lock" COST_DIAGNOSTICS_ENABLED = os.getenv("TELEGRAM_COST_DIAGNOSTICS", "false").strip().lower() == "true" COST_DIAGNOSTICS_PATH = "/data/cost-diagnostics.jsonl" +HEALTH_OPPER_ENABLED = os.getenv("TELEGRAM_HEALTH_OPPER_ENABLED", "false").strip().lower() == "true" SYSTEM = ( "Never guess: rely on a test with an official binary, " diff --git a/bot/telegram/bloc_bridge/opencode/api.py b/bot/telegram/bloc_bridge/opencode/api.py index f02c5c1..cf4b52d 100644 --- a/bot/telegram/bloc_bridge/opencode/api.py +++ b/bot/telegram/bloc_bridge/opencode/api.py @@ -10,13 +10,28 @@ class OpenCodeResponseError(RuntimeError): """OpenCode returned an assistant error; keep user/provider text out of logs.""" - def __init__(self, error_name, error_code=None): + def __init__(self, error_name, error_code=None, status_code=None): self.error_name = error_name if isinstance(error_name, str) else "unknown" self.error_code = error_code if isinstance(error_code, (str, int)) else None + self.status_code = status_code if type(status_code) is int and 100 <= status_code <= 599 else None super().__init__("OpenCode returned an error response") +def raise_response_error(response): + """Raise on an assistant error, retaining only structured diagnostic fields.""" + error = response.get("info", {}).get("error") + if error is None: + return + if not isinstance(error, dict): + raise OpenCodeResponseError("unknown") + details = error.get("data", {}) + if not isinstance(details, dict): + details = {} + raise OpenCodeResponseError(error.get("name"), details.get("code"), details.get("statusCode")) + + def text_from_response(response): + raise_response_error(response) text = "\n".join( part.get("text", "") for part in response.get("parts", []) @@ -25,13 +40,6 @@ def text_from_response(response): if text: return text - error = response.get("info", {}).get("error", {}) - details = error.get("data", {}) if isinstance(error, dict) else {} - detail_message = details.get("message") if isinstance(details, dict) else None - message = detail_message or error.get("name") if isinstance(error, dict) else None - if message: - error_code = details.get("code") if isinstance(details, dict) else None - raise OpenCodeResponseError(error.get("name"), error_code) raise RuntimeError("OpenCode returned no text") diff --git a/bot/telegram/bloc_bridge/opper_health.py b/bot/telegram/bloc_bridge/opper_health.py new file mode 100644 index 0000000..d87aca3 --- /dev/null +++ b/bot/telegram/bloc_bridge/opper_health.py @@ -0,0 +1,41 @@ +"""Optional, unauthenticated Opper API reachability check; never requests inference.""" + +import urllib.error +import urllib.request + +from . import bridge_runtime + + +URL = "https://api.opper.ai/v3/compat/models" +TIMEOUT_SECONDS = 5 + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def health_status(): + """Check the API response headers only, without credentials, redirects, or retries.""" + bridge_runtime.check_shutdown() + request = urllib.request.Request(URL, method="GET") + opener = urllib.request.build_opener(_NoRedirect()) + try: + with opener.open(request, timeout=TIMEOUT_SECONDS) as response: + status = response.status + except urllib.error.HTTPError as error: + status = error.code + error.close() + except (TimeoutError, urllib.error.URLError, OSError) as error: + reason = error.reason if isinstance(error, urllib.error.URLError) else error + if isinstance(reason, TimeoutError): + return "Opper API: reachability check timed out" + return "Opper API: unreachable from the bridge (check network, DNS, and TLS)" + finally: + bridge_runtime.check_shutdown() + + if status in (200, 401): + return "Opper API: reachable (authentication and inference not checked)" + if status == 429: + return "Opper API: rate limited (HTTP 429; inference not checked)" + return f"Opper API: reachability check failed (HTTP {status}; inference not checked)" diff --git a/docs/diagrams/bloc_bridge-exchanges.puml b/docs/diagrams/bloc_bridge-exchanges.puml index dde4835..1931457 100644 --- a/docs/diagrams/bloc_bridge-exchanges.puml +++ b/docs/diagrams/bloc_bridge-exchanges.puml @@ -12,6 +12,7 @@ participant "Request worker\nbridge._request_worker" as worker database "Session mapping\nsession_state + volume" as state participant "OpenCode API\nvia opencode.api" as oc participant "Event watcher\nDaemon thread" as events +participant "Opper API\noptional, no credentials" as opper note over poll,oc Network calls use http_api; Telegram replies use telegram.markdown_renderer. @@ -43,6 +44,10 @@ else Allowed text message opt /new worker -> state : Persist selected session end + opt /health and TELEGRAM_HEALTH_OPPER_ENABLED=true + worker -> opper : GET /v3/compat/models (5 s socket timeout) + opper --> worker : Reachability only; no inference + end end else Regular prompt opt No session for this chat @@ -63,7 +68,13 @@ else Allowed text message opt Correlated reasoning/tool event available oc --> events : SSE event events -> worker : Bounded event queue (128) - worker -> tg : Edit acknowledgement with safe progress + worker -> worker : Select safe progress stage + end + opt Assistant error in correlated history + worker -> worker : Fail promptly with sanitized status + reference + end + opt First progress event or 30 s since last progress + worker -> tg : Edit acknowledgement with safe progress or elapsed wait\nKeep original deadline end end worker -> oc : Read session tree history diff --git a/docs/provider.md b/docs/provider.md index 4f52824..236c9cf 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -32,3 +32,22 @@ make health For Coolify, configure these values as resource environment variables; see the [Coolify deployment guide](coolify_deploy.md). Never commit or share a real API key. + +## Optional reachability check + +Set `TELEGRAM_HEALTH_OPPER_ENABLED=true` in the deployment environment to include an +Opper check in Telegram's `/health`. It is disabled by default and can be removed from +the command by setting the variable to `false` and recreating the bridge. + +The isolated `bloc_bridge/opper_health.py` module sends an unauthenticated +`GET https://api.opper.ai/v3/compat/models` from the bridge, with a five-second socket +timeout, no redirects, no retries, and no response-body processing. DNS resolution can +depend on the system resolver's timeout. No key is passed to the bridge and no model is +invoked, so there is no inference charge. A `401` is an expected reachability result; +`200` is also accepted. Other HTTP statuses and transport failures are reported explicitly. +The endpoint is documented in Opper's [List Models API](https://docs.opper.ai/v3-api-reference/compatibility/list-models). + +This checks only the bridge's network path to the Opper API, not OpenCode's network +path, its credentials, available credits, or the ability of a model to generate a response. +It is not an end-to-end LLM health check. It is never run automatically before a prompt, +and `make health` continues to check only the OpenCode server. diff --git a/docs/usage.md b/docs/usage.md index d7d1947..02e46b3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -15,6 +15,8 @@ The Telegram commands are: are kept and remain available in OpenCode/TUI. `/cost` then reports usage for the new session, starting at zero. - `/health` reports a sanitized bridge, OpenCode API, and current-session status. + Set `TELEGRAM_HEALTH_OPPER_ENABLED=true` to also check Opper API reachability without + credentials or inference; it is disabled by default. See [Provider](provider.md#optional-reachability-check). - `/cost` reports the current session's total and its total for the current fixed UTC hour. Costs are based on usage reported by OpenCode, not an estimate from provider prices, and are not account-wide totals. Replies include a cost line when OpenCode provides usage @@ -24,6 +26,14 @@ An unknown slash command returns an error directing you to `/help`; it is not se model. Normal messages are still sent to OpenCode. `/health` and `/cost` do not send a prompt to the model. +While a normal request is running, the acknowledgement is updated with sanitized +reasoning/tool progress or a waiting message approximately every 30 seconds when no +progress is available. Network calls can delay updates. These updates do not reset the +300-second OpenCode deadline. Failures include a reference for the operator and, when +OpenCode supplies a structured error, distinguish provider authentication/access, +rate/quota limits, and provider service errors. Do not automatically retry: an interrupted +request may still be running in OpenCode. + Telegram replies support this Markdown subset: - headings: `# Heading`; diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index e32d984..1f02f66 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -698,7 +698,7 @@ def test_health_command_reports_only_sanitized_status_without_a_model_call(self) self.assertEqual( report, - "Bridge: healthy\nOpenCode API: healthy\nThis chat's session: available", + "Bridge: healthy\nOpenCode API: healthy\nThis chat's session: available\nOpper API: not checked (disabled)", ) self.assertNotIn("private-session-id", report) self.assertNotIn("private title", report) @@ -719,7 +719,7 @@ def test_health_command_does_not_require_a_session(self): self.assertEqual( report, - "Bridge: healthy\nOpenCode API: unavailable\nThis chat's session: not initialized", + "Bridge: healthy\nOpenCode API: unavailable\nThis chat's session: not initialized\nOpper API: not checked (disabled)", ) get_session.assert_not_called() diff --git a/tests/test_telegram_health_feedback.py b/tests/test_telegram_health_feedback.py new file mode 100644 index 0000000..3acbb73 --- /dev/null +++ b/tests/test_telegram_health_feedback.py @@ -0,0 +1,312 @@ +"""Offline checks for optional provider reachability and long-request feedback.""" + +import io +import os +import runpy +import socket +import sys +import threading +import time +import urllib.error +import unittest +from contextlib import ExitStack +from types import SimpleNamespace +from unittest.mock import Mock, patch + + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bot", "telegram")) +with patch.dict(os.environ, {"TELEGRAM_BOT_TOKEN": "test-token", "TELEGRAM_ALLOWED_CHAT_IDS": "123"}): + from bloc_bridge import bridge, bridge_runtime, http_api, opper_health + from bloc_bridge.opencode import api as opencode_api + + +class OpperHealthTests(unittest.TestCase): + def test_environment_flag_defaults_to_disabled_and_explicitly_enables_probe(self): + config_path = os.path.join( + os.path.dirname(__file__), "..", "bot", "telegram", "bloc_bridge", "bridge_config.py", + ) + for value, expected in ((None, False), ("false", False), ("true", True), (" TRUE ", True)): + with self.subTest(value=value): + with patch.dict(os.environ, { + "TELEGRAM_BOT_TOKEN": "test-token", "TELEGRAM_ALLOWED_CHAT_IDS": "123", + }): + os.environ.pop("TELEGRAM_HEALTH_OPPER_ENABLED", None) + if value is not None: + os.environ["TELEGRAM_HEALTH_OPPER_ENABLED"] = value + self.assertEqual(runpy.run_path(config_path)["HEALTH_OPPER_ENABLED"], expected) + + def test_disabled_health_never_contacts_opper(self): + with patch.object(bridge, "HEALTH_OPPER_ENABLED", False), \ + patch.object(http_api, "http", return_value={"healthy": True}), \ + patch.object(opper_health, "health_status") as probe: + result = bridge.authorized_response(123, "/health", {}) + probe.assert_not_called() + self.assertIn("Opper API: not checked (disabled)", result) + + def test_enabled_health_includes_probe_even_when_opencode_is_unavailable(self): + with patch.object(bridge, "HEALTH_OPPER_ENABLED", True), \ + patch.object(http_api, "http", side_effect=ConnectionRefusedError()), \ + patch.object(opper_health, "health_status", return_value="Opper API: reachable") as probe, \ + patch.object(opencode_api, "submit_async") as submit: + result = bridge.authorized_response(123, "/health", {}) + probe.assert_called_once_with() + submit.assert_not_called() + self.assertIn("OpenCode API: unavailable", result) + self.assertIn("Opper API: reachable", result) + + def test_probe_uses_no_credentials_body_redirects_or_retries(self): + response = Mock(status=200) + response.__enter__ = Mock(return_value=response) + response.__exit__ = Mock(return_value=False) + opener = Mock() + opener.open.return_value = response + with patch.object(opper_health.urllib.request, "build_opener", return_value=opener) as build: + result = opper_health.health_status() + + opener.open.assert_called_once() + request = opener.open.call_args.args[0] + self.assertEqual(request.full_url, "https://api.opper.ai/v3/compat/models") + self.assertEqual(request.get_method(), "GET") + self.assertEqual(request.headers, {}) + self.assertIsNone(request.data) + self.assertEqual(opener.open.call_args.kwargs, {"timeout": 5}) + response.read.assert_not_called() + self.assertIsInstance(build.call_args.args[0], opper_health._NoRedirect) + self.assertIsNone(build.call_args.args[0].redirect_request(None, None, 302, "", {}, "https://invalid")) + self.assertIn("reachable (authentication and inference not checked)", result) + + def test_http_statuses_are_explicit_and_error_bodies_are_not_read(self): + for status, expected in ( + (401, "reachable (authentication and inference not checked)"), + (403, "check failed (HTTP 403"), + (302, "check failed (HTTP 302"), + (429, "rate limited (HTTP 429"), + (503, "check failed (HTTP 503"), + ): + with self.subTest(status=status): + body = Mock() + error = urllib.error.HTTPError(opper_health.URL, status, "private detail", {}, body) + opener = Mock() + opener.open.side_effect = error + with patch.object(opper_health.urllib.request, "build_opener", return_value=opener): + result = opper_health.health_status() + self.assertIn(expected, result) + self.assertNotIn("private detail", result) + body.read.assert_not_called() + body.close.assert_called_once() + opener.open.assert_called_once() + + def test_transport_errors_are_sanitized_and_not_retried(self): + for error, expected in ( + (TimeoutError("private"), "timed out"), + (urllib.error.URLError(TimeoutError("private")), "timed out"), + (urllib.error.URLError("private DNS detail"), "unreachable from the bridge"), + (ConnectionRefusedError("private"), "unreachable from the bridge"), + ): + with self.subTest(error=type(error).__name__): + opener = Mock() + opener.open.side_effect = error + with patch.object(opper_health.urllib.request, "build_opener", return_value=opener): + result = opper_health.health_status() + self.assertIn(expected, result) + self.assertNotIn("private", result) + opener.open.assert_called_once() + + def test_shutdown_is_not_swallowed(self): + opener = Mock() + opener.open.side_effect = bridge_runtime.ShutdownRequested() + with patch.object(opper_health.urllib.request, "build_opener", return_value=opener): + with self.assertRaises(bridge_runtime.ShutdownRequested): + opper_health.health_status() + + def test_unresponsive_api_times_out_without_waiting_for_response_body(self): + stop = threading.Event() + with socket.socket() as server: + server.bind(("127.0.0.1", 0)) + server.listen(1) + server.settimeout(2) + + def stall(): + connection, _ = server.accept() + with connection: + connection.recv(4096) + stop.wait(2) + + thread = threading.Thread(target=stall, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.getsockname()[1]}/models" + with patch.object(opper_health, "URL", url), \ + patch.object(opper_health, "TIMEOUT_SECONDS", 0.1), \ + patch.dict(os.environ, {"no_proxy": "*", "NO_PROXY": "*"}): + started = time.monotonic() + result = opper_health.health_status() + elapsed = time.monotonic() - started + self.assertIn("timed out", result) + self.assertLess(elapsed, 1) + finally: + stop.set() + thread.join(timeout=3) + self.assertFalse(thread.is_alive()) + + +class RequestFeedbackTests(unittest.TestCase): + def setUp(self): + self.clock = 0 + self.progress = [] + self.stack = ExitStack() + self.addCleanup(self.stack.close) + self.stack.enter_context(patch.object(bridge.time, "monotonic", side_effect=lambda: self.clock)) + self.stack.enter_context(patch.object(bridge_runtime, "wait", side_effect=self.advance)) + self.stack.enter_context(patch.object(bridge.uuid, "uuid4", return_value=SimpleNamespace(hex="prompt"))) + self.stack.enter_context(patch.object(opencode_api, "session_tree_messages", return_value=[])) + self.stack.enter_context(patch.object(opencode_api, "submit_async")) + self.stack.enter_context(patch.object(bridge, "_watch_events")) + self.probe = self.stack.enter_context(patch.object(opper_health, "health_status")) + self.status = self.stack.enter_context(patch.object(opencode_api, "session_status", return_value={})) + self.messages = self.stack.enter_context(patch.object(opencode_api, "session_messages", return_value=[])) + + def advance(self, seconds): + self.clock += seconds + + def answer(self, progress=None): + return bridge.answer_async( + 123, "private prompt", {"123": "private-session"}, "REF12345", + lambda _phase: None, progress or (lambda text: self.progress.append((self.clock, text))), + ) + + def completed(self): + return { + "info": { + "role": "assistant", "id": "assistant", "parentID": "msg_prompt", + "time": {"completed": 123}, + }, + "parts": [{"type": "text", "text": "Done"}], + } + + def test_silent_request_emits_periodic_feedback_and_succeeds_near_deadline(self): + self.messages.side_effect = lambda *_args, **_kwargs: [self.completed()] if self.clock >= 299 else [] + result = self.answer() + self.assertIn("Done", result) + self.assertEqual(self.clock, 299) + self.assertEqual([when for when, _ in self.progress], list(range(30, 300, 30))) + self.assertIn("270s elapsed", self.progress[-1][1]) + self.probe.assert_not_called() + + def test_heartbeat_and_delivery_time_never_reset_deadline(self): + def slow_progress(text): + self.progress.append((self.clock, text)) + self.advance(5) + + with self.assertRaises(bridge.OpenCodeRequestTimeout): + self.answer(slow_progress) + self.assertEqual(self.clock, 300) + self.assertGreater(len(self.progress), 1) + self.assertTrue(all(when < 300 for when, _ in self.progress)) + self.probe.assert_not_called() + self.assertTrue(all(0 < item.args[0] <= 10 for item in self.status.call_args_list)) + + def test_repeated_progress_events_are_throttled_and_do_not_extend_deadline(self): + event_queues = [] + + def watch(_session, _reference, events, _stop): + event_queues.append(events) + + def statuses(_timeout): + if event_queues: + event_queues[0].put(("assistant", "reasoning" if self.clock % 2 else "tool")) + return {"private-session": {"type": "busy"}} + + self.messages.return_value = [{ + "info": {"role": "assistant", "id": "assistant", "parentID": "msg_prompt"}, + "parts": [], + }] + self.status.side_effect = statuses + # Run the watcher synchronously so event ordering is deterministic. + thread = Mock() + with patch.object(bridge.threading, "Thread", return_value=thread) as create_thread: + def start(): + kwargs = create_thread.call_args.kwargs + watch(*kwargs["args"]) + thread.start.side_effect = start + with self.assertRaises(bridge.OpenCodeRequestTimeout): + self.answer() + + self.assertEqual(self.clock, 300) + self.assertEqual([when for when, _ in self.progress], list(range(0, 300, 30))) + self.assertTrue(all("OpenCode is" in text for _, text in self.progress)) + self.assertEqual(len({text for _, text in self.progress}), len(self.progress)) + + def test_correlated_provider_failure_surfaces_before_completion_or_idle(self): + self.status.return_value = {"private-session": {"type": "busy"}} + self.messages.return_value = [{ + "info": { + "role": "assistant", "id": "assistant", "parentID": "msg_prompt", + "error": {"name": "APIError", "data": { + "statusCode": 503, "message": "private credentials and prompt", + "responseBody": "private response", + }}, + }, + "parts": [], + }] + with self.assertRaises(opencode_api.OpenCodeResponseError) as caught: + self.answer() + self.assertEqual(self.clock, 0) + self.assertEqual(caught.exception.status_code, 503) + self.assertNotIn("private", str(caught.exception)) + self.assertIn("http_status=503", bridge._safe_error_detail(caught.exception)) + + def test_unrelated_assistant_error_is_ignored(self): + other = {"info": { + "role": "assistant", "id": "other", "parentID": "other-prompt", + "error": {"name": "ProviderAuthError", "data": {"message": "private"}}, + }} + self.messages.return_value = [other, self.completed()] + self.assertIn("Done", self.answer()) + + def test_response_with_partial_text_and_error_is_not_reported_as_success(self): + message = self.completed() + message["info"]["error"] = {"name": "ProviderAuthError", "data": {"message": "private"}} + with self.assertRaises(opencode_api.OpenCodeResponseError): + opencode_api.text_from_response(message) + + def test_error_categories_reach_user_with_reference_not_provider_text(self): + for error, expected in ( + (opencode_api.OpenCodeResponseError("ProviderAuthError"), "authentication/access"), + (opencode_api.OpenCodeResponseError("APIError", status_code=401), "authentication/access"), + (opencode_api.OpenCodeResponseError("APIError", status_code=429), "rate/quota"), + (opencode_api.OpenCodeResponseError("APIError", status_code=503), "provider service error"), + (opencode_api.OpenCodeResponseError("private-provider-text"), "assistant/provider error"), + (urllib.error.URLError("private-network-detail"), "API is unreachable"), + (http_api.OpenCodeHTTPError(400), "HTTP 400"), + (bridge.OpenCodeRequestTimeout(), "timed out"), + ): + with self.subTest(expected=expected): + output = io.StringIO() + with patch.object(bridge, "answer_async", side_effect=error), \ + patch.object(bridge, "edit_reply") as edit, \ + patch("sys.stderr", output): + bridge._request_worker(123, "private-prompt", {}, 123, "REF12345", 456) + reply = edit.call_args.args[2] + self.assertIn(expected, reply) + self.assertIn("REF12345", reply) + self.assertNotIn("private", reply) + self.assertNotIn("private-prompt", output.getvalue()) + + def test_successful_progress_edit_restores_opencode_phase(self): + def exchange(_chat, _prompt, _sessions, _ref, _phase, progress): + progress("Still waiting") + raise bridge.OpenCodeRequestTimeout() + + output = io.StringIO() + with patch.object(bridge, "answer_async", side_effect=exchange), \ + patch.object(bridge, "edit_reply") as edit, \ + patch("sys.stderr", output): + bridge._request_worker(123, "Hi", {}, 123, "REF12345", 456) + self.assertEqual(edit.call_count, 2) + self.assertIn("timed out", edit.call_args.args[2]) + self.assertIn("phase=response_wait", output.getvalue()) + + +if __name__ == "__main__": + unittest.main()