Spaces:
Sleeping
Sleeping
Merge branch 'feat/error-diagnostics'
Browse filesShareable in-app error diagnostics: always-visible failure panel,
durable event log, provider request_id/HTTP-status capture, and a
redacted report users can download or send to the team.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- app/main.py +7 -1
- app/services/diagnostics.py +450 -0
- app/services/llm_client.py +162 -21
- app/ui/diagnostics_panel.py +229 -0
app/main.py
CHANGED
|
@@ -31,7 +31,7 @@ from app.ui import (
|
|
| 31 |
analysis_screen,
|
| 32 |
proposal_screen,
|
| 33 |
)
|
| 34 |
-
from app.ui import api_usage_panel, motion_runtime, theme
|
| 35 |
|
| 36 |
|
| 37 |
# (step key, sidebar label) in flow order.
|
|
@@ -253,6 +253,12 @@ def _render_sidebar(show_debug: bool) -> None:
|
|
| 253 |
st.session_state.theme_dark = dark_on
|
| 254 |
st.rerun()
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
# Developer-only API usage panel. Hidden unless SHOW_DEBUG_PANEL=true.
|
| 257 |
api_usage_panel.render(key_suffix="sidebar")
|
| 258 |
|
|
|
|
| 31 |
analysis_screen,
|
| 32 |
proposal_screen,
|
| 33 |
)
|
| 34 |
+
from app.ui import api_usage_panel, diagnostics_panel, motion_runtime, theme
|
| 35 |
|
| 36 |
|
| 37 |
# (step key, sidebar label) in flow order.
|
|
|
|
| 253 |
st.session_state.theme_dark = dark_on
|
| 254 |
st.rerun()
|
| 255 |
|
| 256 |
+
# Problem/diagnostics panel. NOT debug-gated: a user who hits an error is
|
| 257 |
+
# precisely the user who cannot flip SHOW_DEBUG_PANEL, so the explanation
|
| 258 |
+
# and the share button have to be reachable by default. Renders nothing
|
| 259 |
+
# when there are no failures, leaving a healthy run visually unchanged.
|
| 260 |
+
diagnostics_panel.render(key_suffix="sidebar")
|
| 261 |
+
|
| 262 |
# Developer-only API usage panel. Hidden unless SHOW_DEBUG_PANEL=true.
|
| 263 |
api_usage_panel.render(key_suffix="sidebar")
|
| 264 |
|
app/services/diagnostics.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Durable, shareable error diagnostics.
|
| 2 |
+
|
| 3 |
+
The session-state usage log (:mod:`app.services.llm_client`) is the right
|
| 4 |
+
surface for "what happened during *this* run", but it dies the moment the
|
| 5 |
+
browser reloads — which is a user's first instinct when something breaks,
|
| 6 |
+
so the evidence is usually gone before they think to report it. This
|
| 7 |
+
module keeps a **durable** copy on disk and turns it into a single,
|
| 8 |
+
redacted report a user can hand to the app team.
|
| 9 |
+
|
| 10 |
+
Three responsibilities
|
| 11 |
+
----------------------
|
| 12 |
+
1. :func:`record_event` — append one call record to a bounded JSONL file
|
| 13 |
+
the instant it happens (never at share time).
|
| 14 |
+
2. :func:`build_report` — assemble an envelope (app/runtime versions,
|
| 15 |
+
active provider/model, anonymous browser tag) plus the most recent
|
| 16 |
+
events into one JSON-serialisable dict.
|
| 17 |
+
3. :func:`upload_report` — optionally push that report to a private
|
| 18 |
+
Supabase bucket so support can pull it by ``report_id``.
|
| 19 |
+
|
| 20 |
+
What is never written
|
| 21 |
+
---------------------
|
| 22 |
+
The same contract :mod:`app.utils.logging_utils` already commits to:
|
| 23 |
+
**metadata only**. No dossier text, no screenshot bytes, no job
|
| 24 |
+
descriptions, no generated proposals, no API keys. Records arrive here
|
| 25 |
+
already sanitised by ``llm_client``; :func:`_scrub` re-applies
|
| 26 |
+
:func:`app.utils.redaction.redact` on the way in *and* on the way out
|
| 27 |
+
because it is idempotent and the cost of a double-scrub is zero next to
|
| 28 |
+
the cost of leaking a key into a file a user emails around.
|
| 29 |
+
|
| 30 |
+
Fail-safe
|
| 31 |
+
---------
|
| 32 |
+
Every public function swallows its own errors and degrades to a no-op or
|
| 33 |
+
an empty result. Diagnostics must never be the reason the app breaks —
|
| 34 |
+
a logger that crashes the program it is instrumenting is worse than no
|
| 35 |
+
logger at all.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
|
| 40 |
+
import json
|
| 41 |
+
import os
|
| 42 |
+
import platform
|
| 43 |
+
import secrets
|
| 44 |
+
import sys
|
| 45 |
+
from datetime import datetime, timezone
|
| 46 |
+
from typing import Any, Optional
|
| 47 |
+
|
| 48 |
+
from app.config import LOG_DIR
|
| 49 |
+
from app.utils.redaction import redact
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# Report format version — bump when the envelope/event shape changes so a
|
| 53 |
+
# support tool reading old reports can tell which schema it is looking at.
|
| 54 |
+
SCHEMA_VERSION = 1
|
| 55 |
+
|
| 56 |
+
# Where the durable log lives. Sits beside app.log in the same per-user
|
| 57 |
+
# state dir (repo root in dev, %APPDATA% when packaged) — see app.paths.
|
| 58 |
+
_EVENTS_FILENAME = "diagnostics.jsonl"
|
| 59 |
+
|
| 60 |
+
# Bounded on-disk history. A long session with retries would otherwise grow
|
| 61 |
+
# the file without limit. We append cheaply and only pay the rewrite cost
|
| 62 |
+
# when the file crosses the size ceiling.
|
| 63 |
+
_MAX_EVENTS_ON_DISK = 500
|
| 64 |
+
_TRIM_WHEN_BYTES_EXCEED = 1_000_000 # ~1 MB
|
| 65 |
+
|
| 66 |
+
# How many events a generated report carries. Deliberately smaller than the
|
| 67 |
+
# on-disk cap: a report is for a human to read and a support engineer to
|
| 68 |
+
# scan, not a full archive.
|
| 69 |
+
_REPORT_EVENT_LIMIT = 50
|
| 70 |
+
|
| 71 |
+
# Supabase bucket for uploaded reports. Separate from the `apikeys` bucket
|
| 72 |
+
# used by key_store — different data, different retention, different blast
|
| 73 |
+
# radius if either is ever misconfigured.
|
| 74 |
+
_BUCKET = "diagnostics"
|
| 75 |
+
_TIMEOUT = 8.0
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
# Paths
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
def events_path():
|
| 82 |
+
"""Absolute path to the durable JSONL event log."""
|
| 83 |
+
return LOG_DIR / _EVENTS_FILENAME
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
# Scrubbing
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
def _scrub(value: Any) -> Any:
|
| 90 |
+
"""Recursively redact secrets from any JSON-ish value.
|
| 91 |
+
|
| 92 |
+
``redact`` is idempotent, so applying it to already-clean records is
|
| 93 |
+
free insurance rather than duplicated work.
|
| 94 |
+
"""
|
| 95 |
+
if isinstance(value, str):
|
| 96 |
+
return redact(value)
|
| 97 |
+
if isinstance(value, dict):
|
| 98 |
+
return {k: _scrub(v) for k, v in value.items()}
|
| 99 |
+
if isinstance(value, (list, tuple)):
|
| 100 |
+
return [_scrub(v) for v in value]
|
| 101 |
+
return value
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
# Recording
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
def record_event(entry: dict) -> None:
|
| 108 |
+
"""Append one call record to the durable log. Never raises.
|
| 109 |
+
|
| 110 |
+
Called from ``llm_client._finalize`` for *every* call — success and
|
| 111 |
+
failure alike — so a report can show what worked right up to the point
|
| 112 |
+
something didn't.
|
| 113 |
+
"""
|
| 114 |
+
if not isinstance(entry, dict):
|
| 115 |
+
return
|
| 116 |
+
try:
|
| 117 |
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 118 |
+
path = events_path()
|
| 119 |
+
line = json.dumps(_scrub(entry), ensure_ascii=False, default=str)
|
| 120 |
+
with open(path, "a", encoding="utf-8") as fh:
|
| 121 |
+
fh.write(line + "\n")
|
| 122 |
+
_trim_if_large(path)
|
| 123 |
+
except Exception: # noqa: BLE001 - diagnostics must never break the app
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _trim_if_large(path) -> None:
|
| 128 |
+
"""Keep the newest ``_MAX_EVENTS_ON_DISK`` lines once the file is big.
|
| 129 |
+
|
| 130 |
+
Checking size first keeps the common append path O(1); the rewrite only
|
| 131 |
+
happens on the rare crossing of the ceiling.
|
| 132 |
+
"""
|
| 133 |
+
try:
|
| 134 |
+
if path.stat().st_size <= _TRIM_WHEN_BYTES_EXCEED:
|
| 135 |
+
return
|
| 136 |
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
| 137 |
+
lines = fh.readlines()
|
| 138 |
+
keep = lines[-_MAX_EVENTS_ON_DISK:]
|
| 139 |
+
with open(path, "w", encoding="utf-8") as fh:
|
| 140 |
+
fh.writelines(keep)
|
| 141 |
+
except Exception: # noqa: BLE001
|
| 142 |
+
return
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def load_events(limit: int = _REPORT_EVENT_LIMIT) -> list[dict]:
|
| 146 |
+
"""Return the most recent ``limit`` events, oldest first. Never raises.
|
| 147 |
+
|
| 148 |
+
Malformed lines (a half-written record from a hard kill) are skipped
|
| 149 |
+
rather than failing the whole read.
|
| 150 |
+
"""
|
| 151 |
+
try:
|
| 152 |
+
path = events_path()
|
| 153 |
+
if not path.exists():
|
| 154 |
+
return []
|
| 155 |
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
| 156 |
+
lines = fh.readlines()
|
| 157 |
+
except Exception: # noqa: BLE001
|
| 158 |
+
return []
|
| 159 |
+
|
| 160 |
+
out: list[dict] = []
|
| 161 |
+
for raw in lines[-max(1, limit):]:
|
| 162 |
+
raw = raw.strip()
|
| 163 |
+
if not raw:
|
| 164 |
+
continue
|
| 165 |
+
try:
|
| 166 |
+
parsed = json.loads(raw)
|
| 167 |
+
except ValueError:
|
| 168 |
+
continue
|
| 169 |
+
if isinstance(parsed, dict):
|
| 170 |
+
out.append(parsed)
|
| 171 |
+
return out
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def clear_events() -> bool:
|
| 175 |
+
"""Delete the durable log. Returns True when it is gone afterwards."""
|
| 176 |
+
try:
|
| 177 |
+
path = events_path()
|
| 178 |
+
if path.exists():
|
| 179 |
+
path.unlink()
|
| 180 |
+
return True
|
| 181 |
+
except Exception: # noqa: BLE001
|
| 182 |
+
return False
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
# Report assembly
|
| 187 |
+
# ---------------------------------------------------------------------------
|
| 188 |
+
def new_report_id() -> str:
|
| 189 |
+
"""Short, readable id a user can read aloud to support.
|
| 190 |
+
|
| 191 |
+
Format ``UPS-<YYYYMMDD>-<6 hex>`` — date-prefixed so support can find
|
| 192 |
+
the right day's uploads without opening every object.
|
| 193 |
+
"""
|
| 194 |
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
| 195 |
+
return f"UPS-{stamp}-{secrets.token_hex(3).upper()}"
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _package_version(name: str) -> Optional[str]:
|
| 199 |
+
try:
|
| 200 |
+
from importlib.metadata import version
|
| 201 |
+
|
| 202 |
+
return version(name)
|
| 203 |
+
except Exception: # noqa: BLE001 - package absent or metadata unreadable
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _app_revision() -> Optional[str]:
|
| 208 |
+
"""Best-effort build identifier.
|
| 209 |
+
|
| 210 |
+
Prefers an explicit ``APP_REVISION`` env var (which a deploy can set to
|
| 211 |
+
the commit SHA); falls back to reading ``.git/HEAD`` in a dev checkout.
|
| 212 |
+
Returns None when neither is available — a packaged build with no env
|
| 213 |
+
var simply reports no revision rather than guessing.
|
| 214 |
+
"""
|
| 215 |
+
explicit = (os.getenv("APP_REVISION") or "").strip()
|
| 216 |
+
if explicit:
|
| 217 |
+
return explicit[:40]
|
| 218 |
+
try:
|
| 219 |
+
from app.paths import resource_base
|
| 220 |
+
|
| 221 |
+
head = resource_base() / ".git" / "HEAD"
|
| 222 |
+
if not head.exists():
|
| 223 |
+
return None
|
| 224 |
+
ref = head.read_text(encoding="utf-8").strip()
|
| 225 |
+
if ref.startswith("ref:"):
|
| 226 |
+
target = resource_base() / ".git" / ref.split(" ", 1)[1].strip()
|
| 227 |
+
if target.exists():
|
| 228 |
+
return target.read_text(encoding="utf-8").strip()[:40]
|
| 229 |
+
return None
|
| 230 |
+
return ref[:40]
|
| 231 |
+
except Exception: # noqa: BLE001
|
| 232 |
+
return None
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _browser_tag() -> Optional[str]:
|
| 236 |
+
"""Anonymous, stable per-browser id — lets support correlate repeat
|
| 237 |
+
reports from one user without any login. Never identifies a person."""
|
| 238 |
+
try:
|
| 239 |
+
from app.services import browser_tag
|
| 240 |
+
|
| 241 |
+
return browser_tag.get_or_create_tag()
|
| 242 |
+
except Exception: # noqa: BLE001
|
| 243 |
+
return None
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _active_config() -> dict:
|
| 247 |
+
"""Provider/model context for the report. Never includes the key."""
|
| 248 |
+
try:
|
| 249 |
+
from app.config import get_settings
|
| 250 |
+
|
| 251 |
+
settings = get_settings()
|
| 252 |
+
return {
|
| 253 |
+
"provider": getattr(settings, "active_provider", None)
|
| 254 |
+
or getattr(settings, "llm_provider", None),
|
| 255 |
+
"model": getattr(settings, "active_model", None),
|
| 256 |
+
"vision_model": getattr(settings, "active_vision_model", None),
|
| 257 |
+
"has_api_key": bool(getattr(settings, "has_api_key", False)),
|
| 258 |
+
}
|
| 259 |
+
except Exception: # noqa: BLE001
|
| 260 |
+
return {}
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _environment() -> dict:
|
| 264 |
+
try:
|
| 265 |
+
packaged = False
|
| 266 |
+
try:
|
| 267 |
+
from app.paths import is_packaged
|
| 268 |
+
|
| 269 |
+
packaged = bool(is_packaged())
|
| 270 |
+
except Exception: # noqa: BLE001
|
| 271 |
+
pass
|
| 272 |
+
return {
|
| 273 |
+
"python": sys.version.split()[0],
|
| 274 |
+
"platform": platform.platform(),
|
| 275 |
+
"packaged": packaged,
|
| 276 |
+
"streamlit": _package_version("streamlit"),
|
| 277 |
+
"anthropic_sdk": _package_version("anthropic"),
|
| 278 |
+
"openai_sdk": _package_version("openai"),
|
| 279 |
+
"google_generativeai_sdk": _package_version("google-generativeai"),
|
| 280 |
+
}
|
| 281 |
+
except Exception: # noqa: BLE001
|
| 282 |
+
return {}
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def build_report(
|
| 286 |
+
*,
|
| 287 |
+
report_id: Optional[str] = None,
|
| 288 |
+
limit: int = _REPORT_EVENT_LIMIT,
|
| 289 |
+
session_events: Optional[list[dict]] = None,
|
| 290 |
+
) -> dict:
|
| 291 |
+
"""Assemble the full, redacted diagnostics report.
|
| 292 |
+
|
| 293 |
+
``session_events`` lets a caller pass the in-memory session log so a
|
| 294 |
+
report is complete even if the disk write was blocked (a read-only
|
| 295 |
+
volume, a locked file). Disk events win when both are present because
|
| 296 |
+
they span earlier sessions too.
|
| 297 |
+
"""
|
| 298 |
+
events = load_events(limit=limit)
|
| 299 |
+
if not events and session_events:
|
| 300 |
+
events = list(session_events)[-limit:]
|
| 301 |
+
|
| 302 |
+
report = {
|
| 303 |
+
"report_id": report_id or new_report_id(),
|
| 304 |
+
"schema_version": SCHEMA_VERSION,
|
| 305 |
+
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 306 |
+
"app_revision": _app_revision(),
|
| 307 |
+
"browser_tag": _browser_tag(),
|
| 308 |
+
"config": _active_config(),
|
| 309 |
+
"environment": _environment(),
|
| 310 |
+
"event_count": len(events),
|
| 311 |
+
"events": events,
|
| 312 |
+
"contains": (
|
| 313 |
+
"Metadata only — no API key, dossier text, screenshots, job "
|
| 314 |
+
"descriptions, or generated proposals."
|
| 315 |
+
),
|
| 316 |
+
}
|
| 317 |
+
return _scrub(report)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def report_to_json(report: dict) -> str:
|
| 321 |
+
"""Pretty-printed JSON for download / clipboard. Never raises."""
|
| 322 |
+
try:
|
| 323 |
+
return json.dumps(report, indent=2, ensure_ascii=False, default=str)
|
| 324 |
+
except Exception: # noqa: BLE001
|
| 325 |
+
return json.dumps({"error": "report serialization failed"}, indent=2)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def error_events(events: list[dict]) -> list[dict]:
|
| 329 |
+
"""Filter ``events`` down to the ones that represent a real failure.
|
| 330 |
+
|
| 331 |
+
``local_placeholder`` is excluded: it is a deliberate design choice
|
| 332 |
+
(a stage that ran without the API), not an error.
|
| 333 |
+
"""
|
| 334 |
+
ok_statuses = {"ok", "local_placeholder"}
|
| 335 |
+
return [
|
| 336 |
+
e
|
| 337 |
+
for e in events
|
| 338 |
+
if isinstance(e, dict) and str(e.get("status") or "") not in ok_statuses
|
| 339 |
+
]
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def unresolved_errors(events: list[dict]) -> list[dict]:
|
| 343 |
+
"""Failures that occurred *after* the last successful API call.
|
| 344 |
+
|
| 345 |
+
The durable log deliberately outlives the session, which creates a trap
|
| 346 |
+
the session-only log never had: a user who fixes a bad key would keep
|
| 347 |
+
seeing yesterday's failure forever, and a permanent warning is one
|
| 348 |
+
users learn to ignore. A successful call is the app's own evidence that
|
| 349 |
+
whatever was broken now works, so anything before it is history rather
|
| 350 |
+
than a live problem.
|
| 351 |
+
|
| 352 |
+
Position is used rather than timestamps because ``events`` is already
|
| 353 |
+
append-ordered — no parsing, and no dependence on clock consistency
|
| 354 |
+
across sessions.
|
| 355 |
+
"""
|
| 356 |
+
last_ok = -1
|
| 357 |
+
for idx, event in enumerate(events):
|
| 358 |
+
if isinstance(event, dict) and str(event.get("status") or "") == "ok":
|
| 359 |
+
last_ok = idx
|
| 360 |
+
return error_events(events[last_ok + 1:])
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
# ---------------------------------------------------------------------------
|
| 364 |
+
# Optional upload (same Supabase project as key_store, different bucket)
|
| 365 |
+
# ---------------------------------------------------------------------------
|
| 366 |
+
def _base_url() -> str:
|
| 367 |
+
return (os.getenv("SUPABASE_URL") or "").rstrip("/")
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def _service_key() -> str:
|
| 371 |
+
return os.getenv("SUPABASE_SERVICE_KEY") or ""
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def is_upload_configured() -> bool:
|
| 375 |
+
"""True only when Supabase credentials are present in the environment."""
|
| 376 |
+
return bool(_base_url() and _service_key())
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def _auth_headers(extra: Optional[dict] = None) -> dict:
|
| 380 |
+
key = _service_key()
|
| 381 |
+
headers = {"Authorization": f"Bearer {key}", "apikey": key}
|
| 382 |
+
if extra:
|
| 383 |
+
headers.update(extra)
|
| 384 |
+
return headers
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def _http(method: str, url: str, headers: dict, content: Optional[bytes] = None):
|
| 388 |
+
"""One HTTP request → ``(status_code, body_bytes)``.
|
| 389 |
+
|
| 390 |
+
Isolated so tests can replace it with an in-memory fake, mirroring
|
| 391 |
+
:mod:`app.services.key_store`.
|
| 392 |
+
"""
|
| 393 |
+
import httpx
|
| 394 |
+
|
| 395 |
+
with httpx.Client(timeout=_TIMEOUT) as client:
|
| 396 |
+
resp = client.request(method, url, headers=headers, content=content)
|
| 397 |
+
return resp.status_code, resp.content
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def _ensure_bucket() -> None:
|
| 401 |
+
"""Create the private bucket if missing. Idempotent; errors ignored."""
|
| 402 |
+
url = f"{_base_url()}/storage/v1/bucket"
|
| 403 |
+
body = json.dumps({"id": _BUCKET, "name": _BUCKET, "public": False}).encode()
|
| 404 |
+
try:
|
| 405 |
+
_http("POST", url, _auth_headers({"Content-Type": "application/json"}), body)
|
| 406 |
+
except Exception: # noqa: BLE001 - best-effort; upload surfaces real failure
|
| 407 |
+
pass
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def upload_report(report: dict) -> bool:
|
| 411 |
+
"""Upload ``report`` to the private diagnostics bucket. Never raises.
|
| 412 |
+
|
| 413 |
+
Returns False (rather than raising) when Supabase is not configured or
|
| 414 |
+
the network call fails, so the UI can fall back to "download and email
|
| 415 |
+
it instead" without any error handling of its own.
|
| 416 |
+
"""
|
| 417 |
+
if not is_upload_configured() or not isinstance(report, dict):
|
| 418 |
+
return False
|
| 419 |
+
try:
|
| 420 |
+
_ensure_bucket()
|
| 421 |
+
report_id = str(report.get("report_id") or new_report_id())
|
| 422 |
+
# Safe object name: the id is generated by us, but never trust a
|
| 423 |
+
# value straight into a URL path.
|
| 424 |
+
safe_id = "".join(ch for ch in report_id if ch.isalnum() or ch in "-_")
|
| 425 |
+
payload = report_to_json(report).encode("utf-8")
|
| 426 |
+
url = f"{_base_url()}/storage/v1/object/{_BUCKET}/{safe_id}.json"
|
| 427 |
+
status, _ = _http(
|
| 428 |
+
"POST",
|
| 429 |
+
url,
|
| 430 |
+
_auth_headers({"Content-Type": "application/json", "x-upsert": "true"}),
|
| 431 |
+
payload,
|
| 432 |
+
)
|
| 433 |
+
return 200 <= status < 300
|
| 434 |
+
except Exception: # noqa: BLE001 - fail-safe: never break the UI
|
| 435 |
+
return False
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
__all__ = [
|
| 439 |
+
"SCHEMA_VERSION",
|
| 440 |
+
"record_event",
|
| 441 |
+
"load_events",
|
| 442 |
+
"clear_events",
|
| 443 |
+
"events_path",
|
| 444 |
+
"build_report",
|
| 445 |
+
"report_to_json",
|
| 446 |
+
"error_events",
|
| 447 |
+
"new_report_id",
|
| 448 |
+
"is_upload_configured",
|
| 449 |
+
"upload_report",
|
| 450 |
+
]
|
app/services/llm_client.py
CHANGED
|
@@ -28,10 +28,17 @@ Every call returns an :class:`LLMCallResult` with::
|
|
| 28 |
status: str # "ok" | "no_api" | "invalid_key" | "quota" |
|
| 29 |
# "connection" | "model_missing" | "parse_error"
|
| 30 |
# | "unsupported_provider" | "skipped" | "exception"
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
``used_api`` is true only when bytes actually went over the wire (or were
|
| 33 |
attempted). Callers that fall back to local logic set it to ``False`` via
|
| 34 |
:func:`record_local_use`.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
"""
|
| 36 |
|
| 37 |
from __future__ import annotations
|
|
@@ -137,6 +144,15 @@ class LLMCallResult:
|
|
| 137 |
estimated_output_tokens: Optional[int] = None
|
| 138 |
status: str = STATUS_OK
|
| 139 |
duration_seconds: Optional[float] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
def to_log_entry(self) -> dict:
|
| 142 |
"""Return a dict that is safe to display in the UI / usage log."""
|
|
@@ -151,6 +167,9 @@ class LLMCallResult:
|
|
| 151 |
"duration_seconds": self.duration_seconds,
|
| 152 |
"estimated_input_tokens": self.estimated_input_tokens,
|
| 153 |
"estimated_output_tokens": self.estimated_output_tokens,
|
|
|
|
|
|
|
|
|
|
| 154 |
# response_text / response_json are intentionally NOT stored
|
| 155 |
# in the usage log — that log is metadata-only.
|
| 156 |
}
|
|
@@ -177,6 +196,21 @@ def _record_to_session(entry: dict) -> None:
|
|
| 177 |
return
|
| 178 |
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
def record_local_use(task_name: str, *, note: str = "") -> None:
|
| 181 |
"""Record a non-API stage so the UI can show LOCAL PLACEHOLDER clearly.
|
| 182 |
|
|
@@ -194,8 +228,16 @@ def record_local_use(task_name: str, *, note: str = "") -> None:
|
|
| 194 |
"duration_seconds": None,
|
| 195 |
"estimated_input_tokens": None,
|
| 196 |
"estimated_output_tokens": None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
}
|
| 198 |
_record_to_session(entry)
|
|
|
|
|
|
|
|
|
|
| 199 |
_logger.info(
|
| 200 |
"stage=%s used_api=false status=local_placeholder note=%s",
|
| 201 |
task_name,
|
|
@@ -386,6 +428,56 @@ def _classify_exception(exc: BaseException) -> str:
|
|
| 386 |
return STATUS_EXCEPTION
|
| 387 |
|
| 388 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
# Key/org-id patterns now live in app.utils.redaction (single source of
|
| 390 |
# truth, imported above as _API_KEY_PATTERNS / _ORG_ID_PATTERNS) so the
|
| 391 |
# sink-level logging Filter and this sanitizer can never drift apart.
|
|
@@ -548,6 +640,24 @@ def _prepare_vision_image(data: bytes, mime: str) -> tuple[bytes, str]:
|
|
| 548 |
# ---------------------------------------------------------------------------
|
| 549 |
|
| 550 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
def _anthropic_text(
|
| 552 |
*,
|
| 553 |
api_key: str,
|
|
@@ -556,7 +666,7 @@ def _anthropic_text(
|
|
| 556 |
user_prompt: str,
|
| 557 |
expected_json: bool,
|
| 558 |
max_tokens: int,
|
| 559 |
-
) -> tuple[str, Optional[int], Optional[int], Optional[str]]:
|
| 560 |
import anthropic # type: ignore
|
| 561 |
|
| 562 |
client = anthropic.Anthropic(api_key=api_key, timeout=_request_timeout())
|
|
@@ -577,7 +687,7 @@ def _anthropic_text(
|
|
| 577 |
in_tokens = getattr(usage, "input_tokens", None) if usage else None
|
| 578 |
out_tokens = getattr(usage, "output_tokens", None) if usage else None
|
| 579 |
finish = getattr(response, "stop_reason", None)
|
| 580 |
-
return out_text, in_tokens, out_tokens, finish
|
| 581 |
|
| 582 |
|
| 583 |
def _openai_text(
|
|
@@ -588,7 +698,7 @@ def _openai_text(
|
|
| 588 |
user_prompt: str,
|
| 589 |
expected_json: bool,
|
| 590 |
max_tokens: int,
|
| 591 |
-
) -> tuple[str, Optional[int], Optional[int], Optional[str]]:
|
| 592 |
from openai import OpenAI # type: ignore
|
| 593 |
|
| 594 |
client = OpenAI(api_key=api_key, timeout=_request_timeout())
|
|
@@ -608,7 +718,7 @@ def _openai_text(
|
|
| 608 |
in_tokens = getattr(usage, "prompt_tokens", None) if usage else None
|
| 609 |
out_tokens = getattr(usage, "completion_tokens", None) if usage else None
|
| 610 |
finish = getattr(response.choices[0], "finish_reason", None)
|
| 611 |
-
return out_text, in_tokens, out_tokens, finish
|
| 612 |
|
| 613 |
|
| 614 |
def _groq_text(
|
|
@@ -619,7 +729,7 @@ def _groq_text(
|
|
| 619 |
user_prompt: str,
|
| 620 |
expected_json: bool,
|
| 621 |
max_tokens: int,
|
| 622 |
-
) -> tuple[str, Optional[int], Optional[int], Optional[str]]:
|
| 623 |
"""Groq text completion (text-only — Groq has no image input).
|
| 624 |
|
| 625 |
Groq exposes an OpenAI-compatible Chat Completions API. We prefer the
|
|
@@ -656,7 +766,7 @@ def _groq_text(
|
|
| 656 |
in_tokens = getattr(usage, "prompt_tokens", None) if usage else None
|
| 657 |
out_tokens = getattr(usage, "completion_tokens", None) if usage else None
|
| 658 |
finish = getattr(response.choices[0], "finish_reason", None)
|
| 659 |
-
return out_text, in_tokens, out_tokens, finish
|
| 660 |
|
| 661 |
|
| 662 |
def _gemini_response_text(response: Any) -> str:
|
|
@@ -770,7 +880,7 @@ def _anthropic_vision(
|
|
| 770 |
images: list[tuple[bytes, str]],
|
| 771 |
expected_json: bool,
|
| 772 |
max_tokens: int,
|
| 773 |
-
) -> tuple[str, Optional[int], Optional[int], Optional[str]]:
|
| 774 |
import anthropic # type: ignore
|
| 775 |
|
| 776 |
client = anthropic.Anthropic(api_key=api_key, timeout=_request_timeout())
|
|
@@ -805,7 +915,7 @@ def _anthropic_vision(
|
|
| 805 |
in_tokens = getattr(usage, "input_tokens", None) if usage else None
|
| 806 |
out_tokens = getattr(usage, "output_tokens", None) if usage else None
|
| 807 |
finish = getattr(response, "stop_reason", None)
|
| 808 |
-
return out_text, in_tokens, out_tokens, finish
|
| 809 |
|
| 810 |
|
| 811 |
def _openai_vision(
|
|
@@ -817,7 +927,7 @@ def _openai_vision(
|
|
| 817 |
images: list[tuple[bytes, str]],
|
| 818 |
expected_json: bool,
|
| 819 |
max_tokens: int,
|
| 820 |
-
) -> tuple[str, Optional[int], Optional[int], Optional[str]]:
|
| 821 |
from openai import OpenAI # type: ignore
|
| 822 |
|
| 823 |
client = OpenAI(api_key=api_key, timeout=_request_timeout())
|
|
@@ -848,7 +958,7 @@ def _openai_vision(
|
|
| 848 |
in_tokens = getattr(usage, "prompt_tokens", None) if usage else None
|
| 849 |
out_tokens = getattr(usage, "completion_tokens", None) if usage else None
|
| 850 |
finish = getattr(response.choices[0], "finish_reason", None)
|
| 851 |
-
return out_text, in_tokens, out_tokens, finish
|
| 852 |
|
| 853 |
|
| 854 |
def _gemini_vision(
|
|
@@ -889,20 +999,29 @@ def _gemini_vision(
|
|
| 889 |
# ---------------------------------------------------------------------------
|
| 890 |
|
| 891 |
|
| 892 |
-
def _normalize_adapter_return(
|
| 893 |
-
|
|
|
|
|
|
|
| 894 |
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
|
|
|
|
|
|
|
|
|
| 898 |
"""
|
|
|
|
|
|
|
|
|
|
| 899 |
if isinstance(ret, tuple) and len(ret) == 4:
|
| 900 |
-
|
|
|
|
| 901 |
if isinstance(ret, tuple) and len(ret) == 3:
|
| 902 |
text, in_toks, out_toks = ret
|
| 903 |
-
return text, in_toks, out_toks, None
|
| 904 |
# Unexpected shape — treat as an empty text response.
|
| 905 |
-
return str(ret or ""), None, None, None
|
| 906 |
|
| 907 |
|
| 908 |
def _invoke_with_retry(adapter, *, api_key: str, **kwargs):
|
|
@@ -1012,7 +1131,7 @@ def call_text_llm(
|
|
| 1012 |
"groq": _groq_text,
|
| 1013 |
"gemini": _gemini_text,
|
| 1014 |
}[provider]
|
| 1015 |
-
text, in_toks, out_toks, finish_reason = _invoke_with_retry(
|
| 1016 |
_text_adapter,
|
| 1017 |
api_key=api_key,
|
| 1018 |
model=model,
|
|
@@ -1035,6 +1154,7 @@ def call_text_llm(
|
|
| 1035 |
return result
|
| 1036 |
except Exception as exc: # noqa: BLE001
|
| 1037 |
status = _classify_exception(exc)
|
|
|
|
| 1038 |
result = LLMCallResult(
|
| 1039 |
success=False,
|
| 1040 |
task_name=task_name,
|
|
@@ -1045,6 +1165,9 @@ def call_text_llm(
|
|
| 1045 |
f"{exc.__class__.__name__}: {exc}", secrets=(api_key,)
|
| 1046 |
)[:200],
|
| 1047 |
status=status,
|
|
|
|
|
|
|
|
|
|
| 1048 |
)
|
| 1049 |
_finalize(result, started)
|
| 1050 |
return result
|
|
@@ -1074,6 +1197,7 @@ def call_text_llm(
|
|
| 1074 |
estimated_output_tokens=out_toks if out_toks is not None
|
| 1075 |
else _estimate_tokens(text),
|
| 1076 |
status=STATUS_TRUNCATED if truncated else STATUS_PARSE_ERROR,
|
|
|
|
| 1077 |
)
|
| 1078 |
_finalize(result, started)
|
| 1079 |
return result
|
|
@@ -1091,6 +1215,7 @@ def call_text_llm(
|
|
| 1091 |
estimated_output_tokens=out_toks if out_toks is not None
|
| 1092 |
else _estimate_tokens(text),
|
| 1093 |
status=STATUS_OK,
|
|
|
|
| 1094 |
)
|
| 1095 |
_finalize(result, started)
|
| 1096 |
return result
|
|
@@ -1195,7 +1320,7 @@ def call_vision_llm(
|
|
| 1195 |
"openai": _openai_vision,
|
| 1196 |
"gemini": _gemini_vision,
|
| 1197 |
}[provider]
|
| 1198 |
-
text, in_toks, out_toks, finish_reason = _invoke_with_retry(
|
| 1199 |
_vision_adapter,
|
| 1200 |
api_key=api_key,
|
| 1201 |
model=model,
|
|
@@ -1219,6 +1344,7 @@ def call_vision_llm(
|
|
| 1219 |
return result
|
| 1220 |
except Exception as exc: # noqa: BLE001
|
| 1221 |
status = _classify_exception(exc)
|
|
|
|
| 1222 |
result = LLMCallResult(
|
| 1223 |
success=False,
|
| 1224 |
task_name=task_name,
|
|
@@ -1229,6 +1355,9 @@ def call_vision_llm(
|
|
| 1229 |
f"{exc.__class__.__name__}: {exc}", secrets=(api_key,)
|
| 1230 |
)[:200],
|
| 1231 |
status=status,
|
|
|
|
|
|
|
|
|
|
| 1232 |
)
|
| 1233 |
_finalize(result, started)
|
| 1234 |
return result
|
|
@@ -1256,6 +1385,7 @@ def call_vision_llm(
|
|
| 1256 |
estimated_output_tokens=out_toks if out_toks is not None
|
| 1257 |
else _estimate_tokens(text),
|
| 1258 |
status=STATUS_TRUNCATED if truncated else STATUS_PARSE_ERROR,
|
|
|
|
| 1259 |
)
|
| 1260 |
_finalize(result, started)
|
| 1261 |
return result
|
|
@@ -1273,6 +1403,7 @@ def call_vision_llm(
|
|
| 1273 |
estimated_output_tokens=out_toks if out_toks is not None
|
| 1274 |
else _estimate_tokens(text),
|
| 1275 |
status=STATUS_OK,
|
|
|
|
| 1276 |
)
|
| 1277 |
_finalize(result, started)
|
| 1278 |
return result
|
|
@@ -1291,8 +1422,15 @@ def _finalize(result: LLMCallResult, started_monotonic: float) -> None:
|
|
| 1291 |
result.error_message = sanitize_error_message(result.error_message)
|
| 1292 |
entry = result.to_log_entry()
|
| 1293 |
_record_to_session(entry)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1294 |
_logger.info(
|
| 1295 |
-
"stage=%s used_api=%s status=%s provider=%s model=%s in_tokens=%s
|
|
|
|
| 1296 |
result.task_name,
|
| 1297 |
str(result.used_api).lower(),
|
| 1298 |
result.status,
|
|
@@ -1300,6 +1438,9 @@ def _finalize(result: LLMCallResult, started_monotonic: float) -> None:
|
|
| 1300 |
result.model or "-",
|
| 1301 |
result.estimated_input_tokens if result.estimated_input_tokens is not None else "-",
|
| 1302 |
result.estimated_output_tokens if result.estimated_output_tokens is not None else "-",
|
|
|
|
|
|
|
|
|
|
| 1303 |
)
|
| 1304 |
|
| 1305 |
|
|
|
|
| 28 |
status: str # "ok" | "no_api" | "invalid_key" | "quota" |
|
| 29 |
# "connection" | "model_missing" | "parse_error"
|
| 30 |
# | "unsupported_provider" | "skipped" | "exception"
|
| 31 |
+
request_id: str | None # provider-side call id (req_…), for support
|
| 32 |
+
http_status: int | None # exact HTTP status from the SDK exception
|
| 33 |
+
error_type: str | None # provider's machine-readable error code
|
| 34 |
|
| 35 |
``used_api`` is true only when bytes actually went over the wire (or were
|
| 36 |
attempted). Callers that fall back to local logic set it to ``False`` via
|
| 37 |
:func:`record_local_use`.
|
| 38 |
+
|
| 39 |
+
Every finished call is mirrored to the durable diagnostics log (see
|
| 40 |
+
:mod:`app.services.diagnostics`) so a failure survives the page reload that
|
| 41 |
+
usually follows it and can be handed to the app team as one redacted report.
|
| 42 |
"""
|
| 43 |
|
| 44 |
from __future__ import annotations
|
|
|
|
| 144 |
estimated_output_tokens: Optional[int] = None
|
| 145 |
status: str = STATUS_OK
|
| 146 |
duration_seconds: Optional[float] = None
|
| 147 |
+
# Provider-side correlation fields. ``request_id`` is the single most
|
| 148 |
+
# useful value when escalating to a provider's support (Anthropic and
|
| 149 |
+
# OpenAI both return one per call); ``http_status`` and ``error_type``
|
| 150 |
+
# come straight from the SDK exception instead of being guessed from
|
| 151 |
+
# the message text, so "wrong key" (401) and "wrong model" (404) are
|
| 152 |
+
# distinguishable with certainty.
|
| 153 |
+
request_id: Optional[str] = None
|
| 154 |
+
http_status: Optional[int] = None
|
| 155 |
+
error_type: Optional[str] = None
|
| 156 |
|
| 157 |
def to_log_entry(self) -> dict:
|
| 158 |
"""Return a dict that is safe to display in the UI / usage log."""
|
|
|
|
| 167 |
"duration_seconds": self.duration_seconds,
|
| 168 |
"estimated_input_tokens": self.estimated_input_tokens,
|
| 169 |
"estimated_output_tokens": self.estimated_output_tokens,
|
| 170 |
+
"request_id": self.request_id,
|
| 171 |
+
"http_status": self.http_status,
|
| 172 |
+
"error_type": self.error_type,
|
| 173 |
# response_text / response_json are intentionally NOT stored
|
| 174 |
# in the usage log — that log is metadata-only.
|
| 175 |
}
|
|
|
|
| 196 |
return
|
| 197 |
|
| 198 |
|
| 199 |
+
def _record_to_disk(entry: dict) -> None:
|
| 200 |
+
"""Mirror ``entry`` into the durable diagnostics log.
|
| 201 |
+
|
| 202 |
+
Imported lazily — same pattern as the streamlit import above — so this
|
| 203 |
+
module keeps working in a bare unit-test process where the diagnostics
|
| 204 |
+
service (and its config-dependent log directory) may not be importable.
|
| 205 |
+
"""
|
| 206 |
+
try:
|
| 207 |
+
from app.services import diagnostics
|
| 208 |
+
|
| 209 |
+
diagnostics.record_event(entry)
|
| 210 |
+
except Exception: # pragma: no cover - diagnostics must never break a call
|
| 211 |
+
return
|
| 212 |
+
|
| 213 |
+
|
| 214 |
def record_local_use(task_name: str, *, note: str = "") -> None:
|
| 215 |
"""Record a non-API stage so the UI can show LOCAL PLACEHOLDER clearly.
|
| 216 |
|
|
|
|
| 228 |
"duration_seconds": None,
|
| 229 |
"estimated_input_tokens": None,
|
| 230 |
"estimated_output_tokens": None,
|
| 231 |
+
# Keep the record shape identical to to_log_entry() so a report
|
| 232 |
+
# renders one uniform table instead of a ragged one.
|
| 233 |
+
"request_id": None,
|
| 234 |
+
"http_status": None,
|
| 235 |
+
"error_type": None,
|
| 236 |
}
|
| 237 |
_record_to_session(entry)
|
| 238 |
+
# Persisted too: knowing a stage fell back to local logic is part of the
|
| 239 |
+
# sequence support needs, even though it is not itself an error.
|
| 240 |
+
_record_to_disk(entry)
|
| 241 |
_logger.info(
|
| 242 |
"stage=%s used_api=false status=local_placeholder note=%s",
|
| 243 |
task_name,
|
|
|
|
| 428 |
return STATUS_EXCEPTION
|
| 429 |
|
| 430 |
|
| 431 |
+
def _error_metadata(exc: BaseException) -> dict:
|
| 432 |
+
"""Pull structured correlation fields off a provider SDK exception.
|
| 433 |
+
|
| 434 |
+
Complements :func:`_classify_exception`, which has to guess from
|
| 435 |
+
substrings. These fields are exact:
|
| 436 |
+
|
| 437 |
+
* ``request_id`` — Anthropic (``req_…``) and OpenAI both attach one to
|
| 438 |
+
both responses and errors. It is what a provider's support team asks
|
| 439 |
+
for first, and without it a bug report can say "a call failed" but
|
| 440 |
+
not *which* call.
|
| 441 |
+
* ``http_status`` — 401 (bad key) vs 403 (no access to that model) vs
|
| 442 |
+
404 (model does not exist) vs 400 (bad parameter) are four different
|
| 443 |
+
user-facing problems that all read the same in a message string.
|
| 444 |
+
* ``error_type`` — the provider's own machine-readable code, e.g.
|
| 445 |
+
``authentication_error`` / ``not_found_error`` / ``invalid_request_error``.
|
| 446 |
+
|
| 447 |
+
Everything is read defensively: SDKs differ, and a missing attribute
|
| 448 |
+
must degrade to ``None`` rather than mask the original error.
|
| 449 |
+
"""
|
| 450 |
+
meta: dict = {"request_id": None, "http_status": None, "error_type": None}
|
| 451 |
+
try:
|
| 452 |
+
request_id = getattr(exc, "request_id", None)
|
| 453 |
+
if request_id:
|
| 454 |
+
meta["request_id"] = str(request_id)[:64]
|
| 455 |
+
|
| 456 |
+
status_code = getattr(exc, "status_code", None)
|
| 457 |
+
if isinstance(status_code, int):
|
| 458 |
+
meta["http_status"] = status_code
|
| 459 |
+
else:
|
| 460 |
+
# google-api-core exposes the numeric status as `code`.
|
| 461 |
+
code = getattr(exc, "code", None)
|
| 462 |
+
if isinstance(code, int):
|
| 463 |
+
meta["http_status"] = code
|
| 464 |
+
|
| 465 |
+
# Anthropic/OpenAI put a machine-readable code inside the parsed
|
| 466 |
+
# JSON body: {"error": {"type": "...", "message": "..."}}.
|
| 467 |
+
body = getattr(exc, "body", None)
|
| 468 |
+
if isinstance(body, dict):
|
| 469 |
+
err = body.get("error")
|
| 470 |
+
if isinstance(err, dict) and err.get("type"):
|
| 471 |
+
meta["error_type"] = str(err["type"])[:64]
|
| 472 |
+
if not meta["error_type"]:
|
| 473 |
+
# Fall back to the SDK's own exception class name, which is
|
| 474 |
+
# still more precise than the free-text message.
|
| 475 |
+
meta["error_type"] = exc.__class__.__name__[:64]
|
| 476 |
+
except Exception: # noqa: BLE001 - never let diagnostics mask the real error
|
| 477 |
+
pass
|
| 478 |
+
return meta
|
| 479 |
+
|
| 480 |
+
|
| 481 |
# Key/org-id patterns now live in app.utils.redaction (single source of
|
| 482 |
# truth, imported above as _API_KEY_PATTERNS / _ORG_ID_PATTERNS) so the
|
| 483 |
# sink-level logging Filter and this sanitizer can never drift apart.
|
|
|
|
| 640 |
# ---------------------------------------------------------------------------
|
| 641 |
|
| 642 |
|
| 643 |
+
def _response_meta(response: Any) -> dict:
|
| 644 |
+
"""Correlation fields from a *successful* provider response.
|
| 645 |
+
|
| 646 |
+
Both the Anthropic and OpenAI SDKs expose the HTTP request id as
|
| 647 |
+
``response._request_id`` — public despite the underscore, and the value
|
| 648 |
+
provider support asks for when investigating a specific call. Captured
|
| 649 |
+
on success as well as failure so a report can point at the exact call
|
| 650 |
+
that returned a bad-looking result, not only at calls that raised.
|
| 651 |
+
"""
|
| 652 |
+
try:
|
| 653 |
+
request_id = getattr(response, "_request_id", None)
|
| 654 |
+
if request_id:
|
| 655 |
+
return {"request_id": str(request_id)[:64]}
|
| 656 |
+
except Exception: # noqa: BLE001
|
| 657 |
+
pass
|
| 658 |
+
return {}
|
| 659 |
+
|
| 660 |
+
|
| 661 |
def _anthropic_text(
|
| 662 |
*,
|
| 663 |
api_key: str,
|
|
|
|
| 666 |
user_prompt: str,
|
| 667 |
expected_json: bool,
|
| 668 |
max_tokens: int,
|
| 669 |
+
) -> tuple[str, Optional[int], Optional[int], Optional[str], dict]:
|
| 670 |
import anthropic # type: ignore
|
| 671 |
|
| 672 |
client = anthropic.Anthropic(api_key=api_key, timeout=_request_timeout())
|
|
|
|
| 687 |
in_tokens = getattr(usage, "input_tokens", None) if usage else None
|
| 688 |
out_tokens = getattr(usage, "output_tokens", None) if usage else None
|
| 689 |
finish = getattr(response, "stop_reason", None)
|
| 690 |
+
return out_text, in_tokens, out_tokens, finish, _response_meta(response)
|
| 691 |
|
| 692 |
|
| 693 |
def _openai_text(
|
|
|
|
| 698 |
user_prompt: str,
|
| 699 |
expected_json: bool,
|
| 700 |
max_tokens: int,
|
| 701 |
+
) -> tuple[str, Optional[int], Optional[int], Optional[str], dict]:
|
| 702 |
from openai import OpenAI # type: ignore
|
| 703 |
|
| 704 |
client = OpenAI(api_key=api_key, timeout=_request_timeout())
|
|
|
|
| 718 |
in_tokens = getattr(usage, "prompt_tokens", None) if usage else None
|
| 719 |
out_tokens = getattr(usage, "completion_tokens", None) if usage else None
|
| 720 |
finish = getattr(response.choices[0], "finish_reason", None)
|
| 721 |
+
return out_text, in_tokens, out_tokens, finish, _response_meta(response)
|
| 722 |
|
| 723 |
|
| 724 |
def _groq_text(
|
|
|
|
| 729 |
user_prompt: str,
|
| 730 |
expected_json: bool,
|
| 731 |
max_tokens: int,
|
| 732 |
+
) -> tuple[str, Optional[int], Optional[int], Optional[str], dict]:
|
| 733 |
"""Groq text completion (text-only — Groq has no image input).
|
| 734 |
|
| 735 |
Groq exposes an OpenAI-compatible Chat Completions API. We prefer the
|
|
|
|
| 766 |
in_tokens = getattr(usage, "prompt_tokens", None) if usage else None
|
| 767 |
out_tokens = getattr(usage, "completion_tokens", None) if usage else None
|
| 768 |
finish = getattr(response.choices[0], "finish_reason", None)
|
| 769 |
+
return out_text, in_tokens, out_tokens, finish, _response_meta(response)
|
| 770 |
|
| 771 |
|
| 772 |
def _gemini_response_text(response: Any) -> str:
|
|
|
|
| 880 |
images: list[tuple[bytes, str]],
|
| 881 |
expected_json: bool,
|
| 882 |
max_tokens: int,
|
| 883 |
+
) -> tuple[str, Optional[int], Optional[int], Optional[str], dict]:
|
| 884 |
import anthropic # type: ignore
|
| 885 |
|
| 886 |
client = anthropic.Anthropic(api_key=api_key, timeout=_request_timeout())
|
|
|
|
| 915 |
in_tokens = getattr(usage, "input_tokens", None) if usage else None
|
| 916 |
out_tokens = getattr(usage, "output_tokens", None) if usage else None
|
| 917 |
finish = getattr(response, "stop_reason", None)
|
| 918 |
+
return out_text, in_tokens, out_tokens, finish, _response_meta(response)
|
| 919 |
|
| 920 |
|
| 921 |
def _openai_vision(
|
|
|
|
| 927 |
images: list[tuple[bytes, str]],
|
| 928 |
expected_json: bool,
|
| 929 |
max_tokens: int,
|
| 930 |
+
) -> tuple[str, Optional[int], Optional[int], Optional[str], dict]:
|
| 931 |
from openai import OpenAI # type: ignore
|
| 932 |
|
| 933 |
client = OpenAI(api_key=api_key, timeout=_request_timeout())
|
|
|
|
| 958 |
in_tokens = getattr(usage, "prompt_tokens", None) if usage else None
|
| 959 |
out_tokens = getattr(usage, "completion_tokens", None) if usage else None
|
| 960 |
finish = getattr(response.choices[0], "finish_reason", None)
|
| 961 |
+
return out_text, in_tokens, out_tokens, finish, _response_meta(response)
|
| 962 |
|
| 963 |
|
| 964 |
def _gemini_vision(
|
|
|
|
| 999 |
# ---------------------------------------------------------------------------
|
| 1000 |
|
| 1001 |
|
| 1002 |
+
def _normalize_adapter_return(
|
| 1003 |
+
ret: Any,
|
| 1004 |
+
) -> tuple[str, Optional[int], Optional[int], Optional[str], dict]:
|
| 1005 |
+
"""Accept a 3-, 4- or 5-tuple from a provider adapter.
|
| 1006 |
|
| 1007 |
+
Current adapters return ``(text, in_tokens, out_tokens, finish_reason,
|
| 1008 |
+
meta)`` where ``meta`` carries provider correlation fields such as
|
| 1009 |
+
``request_id``. Adapters that predate ``meta`` return the 4-tuple, and
|
| 1010 |
+
older/monkeypatched fakes return the legacy 3-tuple; both keep working
|
| 1011 |
+
and simply report no request id. Widening here rather than at each call
|
| 1012 |
+
site is what lets a fake stay a three-liner.
|
| 1013 |
"""
|
| 1014 |
+
if isinstance(ret, tuple) and len(ret) == 5:
|
| 1015 |
+
text, in_toks, out_toks, finish, meta = ret
|
| 1016 |
+
return text, in_toks, out_toks, finish, meta if isinstance(meta, dict) else {}
|
| 1017 |
if isinstance(ret, tuple) and len(ret) == 4:
|
| 1018 |
+
text, in_toks, out_toks, finish = ret
|
| 1019 |
+
return text, in_toks, out_toks, finish, {}
|
| 1020 |
if isinstance(ret, tuple) and len(ret) == 3:
|
| 1021 |
text, in_toks, out_toks = ret
|
| 1022 |
+
return text, in_toks, out_toks, None, {}
|
| 1023 |
# Unexpected shape — treat as an empty text response.
|
| 1024 |
+
return str(ret or ""), None, None, None, {}
|
| 1025 |
|
| 1026 |
|
| 1027 |
def _invoke_with_retry(adapter, *, api_key: str, **kwargs):
|
|
|
|
| 1131 |
"groq": _groq_text,
|
| 1132 |
"gemini": _gemini_text,
|
| 1133 |
}[provider]
|
| 1134 |
+
text, in_toks, out_toks, finish_reason, call_meta = _invoke_with_retry(
|
| 1135 |
_text_adapter,
|
| 1136 |
api_key=api_key,
|
| 1137 |
model=model,
|
|
|
|
| 1154 |
return result
|
| 1155 |
except Exception as exc: # noqa: BLE001
|
| 1156 |
status = _classify_exception(exc)
|
| 1157 |
+
err_meta = _error_metadata(exc)
|
| 1158 |
result = LLMCallResult(
|
| 1159 |
success=False,
|
| 1160 |
task_name=task_name,
|
|
|
|
| 1165 |
f"{exc.__class__.__name__}: {exc}", secrets=(api_key,)
|
| 1166 |
)[:200],
|
| 1167 |
status=status,
|
| 1168 |
+
request_id=err_meta.get("request_id"),
|
| 1169 |
+
http_status=err_meta.get("http_status"),
|
| 1170 |
+
error_type=err_meta.get("error_type"),
|
| 1171 |
)
|
| 1172 |
_finalize(result, started)
|
| 1173 |
return result
|
|
|
|
| 1197 |
estimated_output_tokens=out_toks if out_toks is not None
|
| 1198 |
else _estimate_tokens(text),
|
| 1199 |
status=STATUS_TRUNCATED if truncated else STATUS_PARSE_ERROR,
|
| 1200 |
+
request_id=call_meta.get("request_id"),
|
| 1201 |
)
|
| 1202 |
_finalize(result, started)
|
| 1203 |
return result
|
|
|
|
| 1215 |
estimated_output_tokens=out_toks if out_toks is not None
|
| 1216 |
else _estimate_tokens(text),
|
| 1217 |
status=STATUS_OK,
|
| 1218 |
+
request_id=call_meta.get("request_id"),
|
| 1219 |
)
|
| 1220 |
_finalize(result, started)
|
| 1221 |
return result
|
|
|
|
| 1320 |
"openai": _openai_vision,
|
| 1321 |
"gemini": _gemini_vision,
|
| 1322 |
}[provider]
|
| 1323 |
+
text, in_toks, out_toks, finish_reason, call_meta = _invoke_with_retry(
|
| 1324 |
_vision_adapter,
|
| 1325 |
api_key=api_key,
|
| 1326 |
model=model,
|
|
|
|
| 1344 |
return result
|
| 1345 |
except Exception as exc: # noqa: BLE001
|
| 1346 |
status = _classify_exception(exc)
|
| 1347 |
+
err_meta = _error_metadata(exc)
|
| 1348 |
result = LLMCallResult(
|
| 1349 |
success=False,
|
| 1350 |
task_name=task_name,
|
|
|
|
| 1355 |
f"{exc.__class__.__name__}: {exc}", secrets=(api_key,)
|
| 1356 |
)[:200],
|
| 1357 |
status=status,
|
| 1358 |
+
request_id=err_meta.get("request_id"),
|
| 1359 |
+
http_status=err_meta.get("http_status"),
|
| 1360 |
+
error_type=err_meta.get("error_type"),
|
| 1361 |
)
|
| 1362 |
_finalize(result, started)
|
| 1363 |
return result
|
|
|
|
| 1385 |
estimated_output_tokens=out_toks if out_toks is not None
|
| 1386 |
else _estimate_tokens(text),
|
| 1387 |
status=STATUS_TRUNCATED if truncated else STATUS_PARSE_ERROR,
|
| 1388 |
+
request_id=call_meta.get("request_id"),
|
| 1389 |
)
|
| 1390 |
_finalize(result, started)
|
| 1391 |
return result
|
|
|
|
| 1403 |
estimated_output_tokens=out_toks if out_toks is not None
|
| 1404 |
else _estimate_tokens(text),
|
| 1405 |
status=STATUS_OK,
|
| 1406 |
+
request_id=call_meta.get("request_id"),
|
| 1407 |
)
|
| 1408 |
_finalize(result, started)
|
| 1409 |
return result
|
|
|
|
| 1422 |
result.error_message = sanitize_error_message(result.error_message)
|
| 1423 |
entry = result.to_log_entry()
|
| 1424 |
_record_to_session(entry)
|
| 1425 |
+
# Durable copy, written the instant the call finishes rather than when
|
| 1426 |
+
# the user clicks "share". A browser reload is the usual first reaction
|
| 1427 |
+
# to a failure, and it wipes session state — so a report assembled only
|
| 1428 |
+
# at share time would routinely arrive empty. Fail-safe by contract:
|
| 1429 |
+
# `record_event` swallows its own errors.
|
| 1430 |
+
_record_to_disk(entry)
|
| 1431 |
_logger.info(
|
| 1432 |
+
"stage=%s used_api=%s status=%s provider=%s model=%s in_tokens=%s "
|
| 1433 |
+
"out_tokens=%s http_status=%s error_type=%s request_id=%s",
|
| 1434 |
result.task_name,
|
| 1435 |
str(result.used_api).lower(),
|
| 1436 |
result.status,
|
|
|
|
| 1438 |
result.model or "-",
|
| 1439 |
result.estimated_input_tokens if result.estimated_input_tokens is not None else "-",
|
| 1440 |
result.estimated_output_tokens if result.estimated_output_tokens is not None else "-",
|
| 1441 |
+
result.http_status if result.http_status is not None else "-",
|
| 1442 |
+
result.error_type or "-",
|
| 1443 |
+
result.request_id or "-",
|
| 1444 |
)
|
| 1445 |
|
| 1446 |
|
app/ui/diagnostics_panel.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Always-visible error surface + one-click diagnostics hand-off.
|
| 2 |
+
|
| 3 |
+
Companion to :mod:`app.ui.api_usage_panel`, and deliberately governed by a
|
| 4 |
+
different rule:
|
| 5 |
+
|
| 6 |
+
* **API Usage Status** is a developer view of *everything* that happened —
|
| 7 |
+
token counts, per-stage API-vs-local badges, the raw call table. It stays
|
| 8 |
+
behind ``SHOW_DEBUG_PANEL`` because it is noise for a normal user.
|
| 9 |
+
* **This panel** shows only *failures*, and it is **never** debug-gated. A
|
| 10 |
+
user who hits an error is exactly the user who cannot turn on a debug
|
| 11 |
+
flag, and hiding the explanation from them is what turns "I picked a
|
| 12 |
+
retired model" into a support ticket that reads "the app is broken".
|
| 13 |
+
|
| 14 |
+
It renders nothing at all when there are no failures, so a healthy run is
|
| 15 |
+
visually unchanged.
|
| 16 |
+
|
| 17 |
+
Privacy
|
| 18 |
+
-------
|
| 19 |
+
Everything shown and shared comes from the metadata-only records in
|
| 20 |
+
:mod:`app.services.diagnostics` — no API key, dossier text, screenshot,
|
| 21 |
+
job description, or generated proposal. The full payload is shown to the
|
| 22 |
+
user *before* any upload, because a share button people cannot inspect is
|
| 23 |
+
a share button people do not press.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
from typing import Optional
|
| 29 |
+
|
| 30 |
+
import streamlit as st
|
| 31 |
+
|
| 32 |
+
from app.services import diagnostics
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# Plain-English meaning per status code, with the actionable next step. The
|
| 36 |
+
# wording deliberately separates "no model is set" (a configuration gap)
|
| 37 |
+
# from "the model you set was rejected" (a wrong/retired name) — collapsing
|
| 38 |
+
# those two into one message is what previously sent users to re-check a
|
| 39 |
+
# dropdown that already had a value in it.
|
| 40 |
+
_STATUS_HELP: dict[str, tuple[str, str]] = {
|
| 41 |
+
"invalid_key": (
|
| 42 |
+
"API key rejected",
|
| 43 |
+
"Re-copy the whole key and confirm it belongs to the provider "
|
| 44 |
+
"selected in Setup (an OpenAI key will not work with Anthropic).",
|
| 45 |
+
),
|
| 46 |
+
"model_missing": (
|
| 47 |
+
"Model not accepted by the provider",
|
| 48 |
+
"The model name was not recognised — it may be retired, misspelled, "
|
| 49 |
+
"or unavailable on your plan. Pick a different model in Setup.",
|
| 50 |
+
),
|
| 51 |
+
"quota": (
|
| 52 |
+
"Out of credit or rate limited",
|
| 53 |
+
"Add credit in your provider dashboard, or wait a moment and retry.",
|
| 54 |
+
),
|
| 55 |
+
"context_overflow": (
|
| 56 |
+
"Too much input for this model",
|
| 57 |
+
"Shorten the dossier or screenshot text and try again.",
|
| 58 |
+
),
|
| 59 |
+
"connection": (
|
| 60 |
+
"Could not reach the provider",
|
| 61 |
+
"Check your internet connection, VPN, or firewall, then retry.",
|
| 62 |
+
),
|
| 63 |
+
"parse_error": (
|
| 64 |
+
"Unreadable reply from the model",
|
| 65 |
+
"The model did not return valid JSON. Retrying usually fixes it; if "
|
| 66 |
+
"it repeats, try a different model.",
|
| 67 |
+
),
|
| 68 |
+
"truncated": (
|
| 69 |
+
"Reply cut off by the token limit",
|
| 70 |
+
"Raise the output-token limit in Setup and retry.",
|
| 71 |
+
),
|
| 72 |
+
"no_api": (
|
| 73 |
+
"No API key configured",
|
| 74 |
+
"Add your key in Setup and save the configuration.",
|
| 75 |
+
),
|
| 76 |
+
"unsupported_provider": (
|
| 77 |
+
"Provider not configured",
|
| 78 |
+
"Choose a supported provider in Setup.",
|
| 79 |
+
),
|
| 80 |
+
"exception": (
|
| 81 |
+
"Unexpected provider error",
|
| 82 |
+
"Share the diagnostics below with the app team.",
|
| 83 |
+
),
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
# HTTP status refines the story where the SDK gave us one. These are exact
|
| 87 |
+
# signals, unlike the message-text heuristics used to bucket the status.
|
| 88 |
+
_HTTP_HELP: dict[int, str] = {
|
| 89 |
+
400: "The provider rejected the request itself (bad parameter) — this is "
|
| 90 |
+
"an app-side issue, not something you configured wrong.",
|
| 91 |
+
401: "The key was not accepted at all.",
|
| 92 |
+
403: "The key is valid but is not allowed to use this model.",
|
| 93 |
+
404: "The model name does not exist for this provider.",
|
| 94 |
+
429: "Rate limit or credit limit hit.",
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _describe(event: dict) -> tuple[str, str]:
|
| 99 |
+
"""Return ``(headline, next_step)`` for one failed call."""
|
| 100 |
+
status = str(event.get("status") or "exception")
|
| 101 |
+
headline, action = _STATUS_HELP.get(
|
| 102 |
+
status, ("Something went wrong", "Share the diagnostics with the app team.")
|
| 103 |
+
)
|
| 104 |
+
http_status = event.get("http_status")
|
| 105 |
+
if isinstance(http_status, int) and http_status in _HTTP_HELP:
|
| 106 |
+
action = f"{_HTTP_HELP[http_status]} {action}"
|
| 107 |
+
return headline, action
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _stage_label(event: dict) -> str:
|
| 111 |
+
task = str(event.get("task_name") or "unknown")
|
| 112 |
+
return task.replace("_", " ").strip().capitalize()
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def render(*, key_suffix: str = "", limit: int = 50) -> None:
|
| 116 |
+
"""Render the diagnostics panel, or nothing when there is no failure.
|
| 117 |
+
|
| 118 |
+
Pass a unique ``key_suffix`` when calling from more than one screen so
|
| 119 |
+
Streamlit widget keys stay unique.
|
| 120 |
+
"""
|
| 121 |
+
try:
|
| 122 |
+
session_events = list(st.session_state.get("api_usage_log") or [])
|
| 123 |
+
except Exception: # noqa: BLE001 - outside a script run
|
| 124 |
+
session_events = []
|
| 125 |
+
|
| 126 |
+
# Disk first: it survives the page reload that usually follows a failure
|
| 127 |
+
# and spans earlier sessions. Session state is the fallback for the case
|
| 128 |
+
# where the disk write was blocked (read-only volume, locked file).
|
| 129 |
+
events = diagnostics.load_events(limit=limit) or session_events
|
| 130 |
+
# Only failures since the last successful call: a success is proof the
|
| 131 |
+
# problem is over, and a warning that never clears is one users learn
|
| 132 |
+
# to ignore.
|
| 133 |
+
failures = diagnostics.unresolved_errors(events)
|
| 134 |
+
if not failures:
|
| 135 |
+
return
|
| 136 |
+
|
| 137 |
+
latest = failures[-1]
|
| 138 |
+
headline, action = _describe(latest)
|
| 139 |
+
|
| 140 |
+
with st.expander(f"⚠️ Problem detected — {headline}", expanded=False):
|
| 141 |
+
st.markdown(f"**{headline}**")
|
| 142 |
+
st.caption(action)
|
| 143 |
+
|
| 144 |
+
st.markdown(
|
| 145 |
+
f"- **Stage:** {_stage_label(latest)}\n"
|
| 146 |
+
f"- **Provider / model:** `{latest.get('provider') or '—'}`"
|
| 147 |
+
f" · `{latest.get('model') or '—'}`\n"
|
| 148 |
+
f"- **When:** {latest.get('timestamp') or '—'}"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
# Correlation ids: meaningless to the user, decisive for support.
|
| 152 |
+
req_id = latest.get("request_id")
|
| 153 |
+
http_status = latest.get("http_status")
|
| 154 |
+
err_type = latest.get("error_type")
|
| 155 |
+
if req_id or http_status or err_type:
|
| 156 |
+
bits = []
|
| 157 |
+
if http_status:
|
| 158 |
+
bits.append(f"HTTP `{http_status}`")
|
| 159 |
+
if err_type:
|
| 160 |
+
bits.append(f"type `{err_type}`")
|
| 161 |
+
if req_id:
|
| 162 |
+
bits.append(f"request `{req_id}`")
|
| 163 |
+
st.caption("Reference: " + " · ".join(bits))
|
| 164 |
+
|
| 165 |
+
if len(failures) > 1:
|
| 166 |
+
st.caption(f"{len(failures)} failed calls since the last success.")
|
| 167 |
+
|
| 168 |
+
st.divider()
|
| 169 |
+
_render_share(session_events, key_suffix=key_suffix)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _render_share(session_events: list[dict], *, key_suffix: str = "") -> None:
|
| 173 |
+
"""Build-and-share controls: inspect, download, and optional upload."""
|
| 174 |
+
st.markdown("**Share with the app team**")
|
| 175 |
+
st.caption(
|
| 176 |
+
"Metadata only — no API key, dossier text, screenshots, job posts, "
|
| 177 |
+
"or generated proposals."
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
# One report id per session so the download, the upload, and whatever the
|
| 181 |
+
# user quotes in an email all refer to the same thing.
|
| 182 |
+
id_key = f"diag_report_id_{key_suffix}"
|
| 183 |
+
if id_key not in st.session_state:
|
| 184 |
+
st.session_state[id_key] = diagnostics.new_report_id()
|
| 185 |
+
report_id: str = st.session_state[id_key]
|
| 186 |
+
|
| 187 |
+
report = diagnostics.build_report(
|
| 188 |
+
report_id=report_id, session_events=session_events
|
| 189 |
+
)
|
| 190 |
+
payload = diagnostics.report_to_json(report)
|
| 191 |
+
|
| 192 |
+
st.download_button(
|
| 193 |
+
"Download diagnostics",
|
| 194 |
+
data=payload,
|
| 195 |
+
file_name=f"{report_id}.json",
|
| 196 |
+
mime="application/json",
|
| 197 |
+
use_container_width=True,
|
| 198 |
+
key=f"diag_dl_{key_suffix}",
|
| 199 |
+
help="Save the report and attach it to an email or chat message.",
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
# st.code ships a native copy button, so there is no need for custom JS
|
| 203 |
+
# (which the app's CSP-tight embed would complicate anyway).
|
| 204 |
+
with st.expander("View / copy report", expanded=False):
|
| 205 |
+
st.code(payload, language="json")
|
| 206 |
+
|
| 207 |
+
if diagnostics.is_upload_configured():
|
| 208 |
+
sent_key = f"diag_sent_{key_suffix}"
|
| 209 |
+
if st.button(
|
| 210 |
+
"Send to support",
|
| 211 |
+
use_container_width=True,
|
| 212 |
+
key=f"diag_send_{key_suffix}",
|
| 213 |
+
help="Uploads the report above so the team can look it up by id.",
|
| 214 |
+
):
|
| 215 |
+
st.session_state[sent_key] = diagnostics.upload_report(report)
|
| 216 |
+
|
| 217 |
+
sent: Optional[bool] = st.session_state.get(sent_key)
|
| 218 |
+
if sent is True:
|
| 219 |
+
st.success(f"Sent. Quote reference **{report_id}** to the team.")
|
| 220 |
+
elif sent is False:
|
| 221 |
+
st.warning(
|
| 222 |
+
"Upload failed. Use **Download diagnostics** above and send "
|
| 223 |
+
"the file instead."
|
| 224 |
+
)
|
| 225 |
+
else:
|
| 226 |
+
st.caption(f"Reference id: **{report_id}**")
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
__all__ = ["render"]
|