ananyajoshi Claude Opus 5 (1M context) commited on
Commit
a722c74
·
2 Parent(s): 48fd5c722b38d8

Merge branch 'feat/error-diagnostics'

Browse files

Shareable 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 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(ret: Any) -> tuple[str, Optional[int], Optional[int], Optional[str]]:
893
- """Accept a 3- or 4-tuple from a provider adapter.
 
 
894
 
895
- Real adapters return ``(text, in_tokens, out_tokens, finish_reason)``;
896
- older/monkeypatched fakes return the legacy 3-tuple. This keeps both
897
- working without forcing every test to add a finish reason.
 
 
 
898
  """
 
 
 
899
  if isinstance(ret, tuple) and len(ret) == 4:
900
- return ret
 
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 out_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"]