AlessandroAmodioNGI Claude Opus 5.5 commited on
Commit
78c40dc
·
1 Parent(s): df7e469

Add daily developer report: API key health + 24h token usage push

Browse files

POST /admin/daily_report (trigger-secret gated, fails closed) makes a real
1-token call per provider with the app's own keys and pushes a summary of
the last 24h of llm_usage_log (calls, tokens, estimated cost per provider)
to DEV_REPORT_USER_ID. Meant to be hit once a day by a scheduled Fly
Machine, so a dead key is noticed within a day instead of by chance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +19 -0
  2. util_daily_report.py +148 -0
app.py CHANGED
@@ -367,6 +367,25 @@ async def proactive_run(req: Request, background_tasks: BackgroundTasks):
367
  return {"scheduled": True}
368
 
369
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  @APP.post("/proactive/mark_read")
371
  async def proactive_mark_read(req: Request):
372
  """Mark the last unread proactive message as read for the authenticated user."""
 
367
  return {"scheduled": True}
368
 
369
 
370
+ @APP.post("/admin/daily_report")
371
+ def admin_daily_report(req: Request):
372
+ """
373
+ Daily developer report: checks the app's own Anthropic/OpenAI keys with a
374
+ real 1-token call and pushes a 24h token-usage summary to DEV_REPORT_USER_ID.
375
+ Called once a day by a scheduled Fly Machine. Unlike /proactive/run this
376
+ fails closed — it spends (a tiny amount of) LLM credit and returns usage
377
+ data, so it must never run unauthenticated if the secret is missing.
378
+ """
379
+ trigger_secret = os.environ.get("PROACTIVE_TRIGGER_SECRET", "")
380
+ if not trigger_secret or req.headers.get("x-trigger-secret", "") != trigger_secret:
381
+ raise HTTPException(status_code=403, detail="Invalid trigger secret")
382
+
383
+ # Sync endpoint: FastAPI runs it in a worker thread, so the ~2 LLM pings
384
+ # and the usage query never block the event loop / live chat traffic.
385
+ from util_daily_report import run_daily_report
386
+ return run_daily_report()
387
+
388
+
389
  @APP.post("/proactive/mark_read")
390
  async def proactive_mark_read(req: Request):
391
  """Mark the last unread proactive message as read for the authenticated user."""
util_daily_report.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # util_daily_report.py
2
+ # Daily developer report, pushed to the developer's phone: whether the app's
3
+ # own Anthropic/OpenAI keys still work, and the last 24h of LLM token usage.
4
+ #
5
+ # Triggered once a day by a scheduled Fly Machine calling POST /admin/daily_report
6
+ # (same pattern as the hourly /proactive/run trigger). Recipient is the user id
7
+ # in the DEV_REPORT_USER_ID secret; delivery reuses the app's Expo push path.
8
+ #
9
+ # Why a real completion call and not an auth-only check: a key can authenticate
10
+ # fine on free endpoints (e.g. /v1/models) while being unable to bill — that is
11
+ # exactly how a broken OpenAI key once went unnoticed. And the 2026-09-29/30
12
+ # Anthropic outage went unnoticed for a day because failed calls are swallowed
13
+ # by the pipelines, so nothing user-visible flagged it.
14
+
15
+ import os
16
+ from collections import defaultdict
17
+ from datetime import datetime, timedelta, timezone
18
+
19
+ import litellm
20
+
21
+ from db_client import supabase
22
+ from util_push import send_expo_push_notification
23
+
24
+ # Cheapest model per provider — the check only needs to prove the key can bill.
25
+ _KEY_CHECKS = {
26
+ "Anthropic": ("ANTHROPIC_API_KEY", "anthropic/claude-haiku-4-5"),
27
+ "OpenAI": ("OPENAI_API_KEY", "gpt-4.1-nano"),
28
+ }
29
+
30
+
31
+ def check_provider_keys() -> dict:
32
+ """{provider: None if working, else a short error string}. Calls litellm
33
+ directly (not llm_client) so the check never uses a user's own key and
34
+ never lands in llm_usage_log. Never includes any part of the key itself."""
35
+ results = {}
36
+ for provider, (env_name, model) in _KEY_CHECKS.items():
37
+ key = os.environ.get(env_name, "")
38
+ if not key:
39
+ results[provider] = f"{env_name} not set"
40
+ continue
41
+ try:
42
+ litellm.completion(
43
+ model=model,
44
+ messages=[{"role": "user", "content": "ping"}],
45
+ max_tokens=1,
46
+ api_key=key,
47
+ )
48
+ results[provider] = None
49
+ except Exception as e:
50
+ msg = str(e).replace(key, "***")
51
+ results[provider] = f"{type(e).__name__}: {msg[:120]}"
52
+ return results
53
+
54
+
55
+ def _provider_of(model: str) -> str:
56
+ return "Anthropic" if "claude" in model.lower() else "OpenAI"
57
+
58
+
59
+ def summarize_usage(hours: int = 24) -> dict:
60
+ """Token totals and estimated USD cost from llm_usage_log over the last
61
+ `hours`, grouped by provider and by model. Cost is litellm's price table,
62
+ an estimate — the provider consoles are the billing truth. Includes calls
63
+ made with users' own (BYOK) keys, which are billed to them, not the app."""
64
+ since = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
65
+ rows, page = [], 1000
66
+ while True:
67
+ batch = (
68
+ supabase.table("llm_usage_log")
69
+ .select("model,prompt_tokens,completion_tokens,total_tokens")
70
+ .gte("created_at", since)
71
+ .range(len(rows), len(rows) + page - 1)
72
+ .execute()
73
+ .data
74
+ ) or []
75
+ rows.extend(batch)
76
+ if len(batch) < page:
77
+ break
78
+
79
+ by_model = defaultdict(lambda: {"calls": 0, "tokens": 0, "cost": 0.0})
80
+ for r in rows:
81
+ model = r.get("model") or "unknown"
82
+ pt, ct = r.get("prompt_tokens") or 0, r.get("completion_tokens") or 0
83
+ m = by_model[model]
84
+ m["calls"] += 1
85
+ m["tokens"] += r.get("total_tokens") or (pt + ct)
86
+ try:
87
+ p_cost, c_cost = litellm.cost_per_token(model=model, prompt_tokens=pt, completion_tokens=ct)
88
+ m["cost"] += p_cost + c_cost
89
+ except Exception:
90
+ pass # unpriced model: tokens still counted, cost omitted
91
+
92
+ by_provider = defaultdict(lambda: {"calls": 0, "tokens": 0, "cost": 0.0})
93
+ for model, m in by_model.items():
94
+ p = by_provider[_provider_of(model)]
95
+ for k in ("calls", "tokens", "cost"):
96
+ p[k] += m[k]
97
+
98
+ return {
99
+ "calls": len(rows),
100
+ "tokens": sum(m["tokens"] for m in by_model.values()),
101
+ "cost": sum(m["cost"] for m in by_model.values()),
102
+ "by_provider": dict(by_provider),
103
+ "by_model": dict(by_model),
104
+ }
105
+
106
+
107
+ def _fmt_tokens(n: int) -> str:
108
+ if n >= 1_000_000:
109
+ return f"{n / 1_000_000:.1f}M"
110
+ if n >= 1_000:
111
+ return f"{n / 1_000:.0f}k"
112
+ return str(n)
113
+
114
+
115
+ def build_report(keys: dict, usage: dict) -> tuple[str, str]:
116
+ """(title, body) for the push notification."""
117
+ failing = [p for p, err in keys.items() if err]
118
+ title = ("⚠️ Key failing: " + ", ".join(failing)) if failing else "✅ Daily report: keys OK"
119
+
120
+ lines = [f"{p} key FAILING — {keys[p]}" for p in failing]
121
+ lines.append(
122
+ f"24h: {usage['calls']} calls · {_fmt_tokens(usage['tokens'])} tokens · ≈${usage['cost']:.2f}"
123
+ )
124
+ for provider in ("Anthropic", "OpenAI"):
125
+ p = usage["by_provider"].get(provider)
126
+ if p:
127
+ lines.append(f"{provider}: {_fmt_tokens(p['tokens'])} tokens · ≈${p['cost']:.2f}")
128
+ return title, "\n".join(lines)
129
+
130
+
131
+ def run_daily_report() -> dict:
132
+ keys = check_provider_keys()
133
+ try:
134
+ usage = summarize_usage()
135
+ except Exception as e:
136
+ print(f"[daily_report] usage summary failed: {e}")
137
+ usage = {"calls": 0, "tokens": 0, "cost": 0.0, "by_provider": {}, "by_model": {}}
138
+ title, body = build_report(keys, usage)
139
+ print(f"[daily_report] {title} | {body!r}")
140
+
141
+ recipient = os.environ.get("DEV_REPORT_USER_ID", "")
142
+ sent = bool(recipient) and send_expo_push_notification(
143
+ user_id=recipient, title=title, body=body, data={"type": "dev_report"}
144
+ )
145
+ if not recipient:
146
+ print("[daily_report] DEV_REPORT_USER_ID not set — report logged only")
147
+ return {"title": title, "body": body, "pushed": sent,
148
+ "keys_ok": not any(keys.values()), "by_model": usage["by_model"]}