chore(diag): add /diag/openrouter endpoint + sidebar button
Browse filesOne-shot in-container probe to diagnose why /explain/* falls back
to template on HF Space. Shows key presence (length + 12-char prefix
only — never full secret), kill-switch state, and an 8-token probe
against the chain's first model. Surfaces 401/429/CONN errors as
JSON so deployment issues can be diagnosed without container shell
or HF logs API access.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- src/api/main.py +70 -0
- src/frontend/app.py +7 -0
src/api/main.py
CHANGED
|
@@ -30,3 +30,73 @@ app.include_router(experiments_router)
|
|
| 30 |
def health() -> HealthResponse:
|
| 31 |
"""Liveness probe — used by docker-compose health checks and Streamlit."""
|
| 32 |
return HealthResponse(status="ok", pipelines=["bbb", "eeg", "mri"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def health() -> HealthResponse:
|
| 31 |
"""Liveness probe — used by docker-compose health checks and Streamlit."""
|
| 32 |
return HealthResponse(status="ok", pipelines=["bbb", "eeg", "mri"])
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@app.get("/diag/openrouter")
|
| 36 |
+
def diag_openrouter() -> dict:
|
| 37 |
+
"""One-shot OpenRouter reachability probe — diagnostic only.
|
| 38 |
+
|
| 39 |
+
Reports whether the explainer can reach OpenRouter from this container.
|
| 40 |
+
Returns key presence (length + first 12 chars only — never the full
|
| 41 |
+
secret), kill-switch state, the first model in the chain, and the
|
| 42 |
+
HTTP/error status of an 8-token probe call against that model. Used
|
| 43 |
+
to diagnose why /explain/* falls back to template in production.
|
| 44 |
+
"""
|
| 45 |
+
import os as _os
|
| 46 |
+
from src.llm import explainer as _ex
|
| 47 |
+
|
| 48 |
+
key = _os.environ.get("OPENROUTER_API_KEY") or ""
|
| 49 |
+
chain = _ex._free_model_chain()
|
| 50 |
+
first_model = chain[0] if chain else None
|
| 51 |
+
|
| 52 |
+
out: dict = {
|
| 53 |
+
"has_key": bool(key),
|
| 54 |
+
"key_len": len(key),
|
| 55 |
+
"key_prefix": key[:12] if key else None,
|
| 56 |
+
"kill_switch_on": _os.environ.get("NEUROBRIDGE_DISABLE_LLM") == "1",
|
| 57 |
+
"should_use_llm": _ex._should_use_llm(),
|
| 58 |
+
"chain_len": len(chain),
|
| 59 |
+
"first_model": first_model,
|
| 60 |
+
"probe": None,
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if not key or not first_model:
|
| 64 |
+
out["probe"] = "skipped (no key or empty chain)"
|
| 65 |
+
return out
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
from openai import (
|
| 69 |
+
OpenAI,
|
| 70 |
+
APIStatusError,
|
| 71 |
+
APIConnectionError,
|
| 72 |
+
APITimeoutError,
|
| 73 |
+
RateLimitError,
|
| 74 |
+
)
|
| 75 |
+
except ImportError as e:
|
| 76 |
+
out["probe"] = f"openai SDK not importable: {e}"
|
| 77 |
+
return out
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
client = OpenAI(
|
| 81 |
+
base_url="https://openrouter.ai/api/v1",
|
| 82 |
+
api_key=key,
|
| 83 |
+
timeout=8.0,
|
| 84 |
+
)
|
| 85 |
+
c = client.chat.completions.create(
|
| 86 |
+
model=first_model,
|
| 87 |
+
messages=[{"role": "user", "content": "Reply with the single word OK."}],
|
| 88 |
+
max_tokens=8,
|
| 89 |
+
temperature=0,
|
| 90 |
+
)
|
| 91 |
+
text = (c.choices[0].message.content or "").strip()
|
| 92 |
+
out["probe"] = {"status": "OK", "preview": text[:60]}
|
| 93 |
+
except RateLimitError:
|
| 94 |
+
out["probe"] = {"status": "429", "note": "rate-limited"}
|
| 95 |
+
except APIStatusError as e:
|
| 96 |
+
out["probe"] = {"status": str(getattr(e, "status_code", "?")), "message": str(e)[:200]}
|
| 97 |
+
except (APIConnectionError, APITimeoutError) as e:
|
| 98 |
+
out["probe"] = {"status": "CONN", "exception": type(e).__name__}
|
| 99 |
+
except Exception as e:
|
| 100 |
+
out["probe"] = {"status": "ERR", "exception": type(e).__name__, "message": str(e)[:200]}
|
| 101 |
+
|
| 102 |
+
return out
|
src/frontend/app.py
CHANGED
|
@@ -1074,6 +1074,13 @@ def _render_sidebar(api_ok: bool, api_status: str) -> None:
|
|
| 1074 |
unsafe_allow_html=True,
|
| 1075 |
)
|
| 1076 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1077 |
st.markdown("### About")
|
| 1078 |
st.markdown(
|
| 1079 |
"<p style='font-size:0.86rem;color:var(--ng-text-secondary);"
|
|
|
|
| 1074 |
unsafe_allow_html=True,
|
| 1075 |
)
|
| 1076 |
|
| 1077 |
+
if st.button("🔧 Diagnose LLM", key="diag_llm_btn", help="Probe OpenRouter from this container"):
|
| 1078 |
+
try:
|
| 1079 |
+
diag = httpx.get(f"{_API_URL}/diag/openrouter", timeout=15.0).json()
|
| 1080 |
+
st.json(diag)
|
| 1081 |
+
except Exception as e:
|
| 1082 |
+
st.error(f"diag failed: {e!r}")
|
| 1083 |
+
|
| 1084 |
st.markdown("### About")
|
| 1085 |
st.markdown(
|
| 1086 |
"<p style='font-size:0.86rem;color:var(--ng-text-secondary);"
|