Skip to content

Commit d5f1d21

Browse files
Pigbibicodex
andcommitted
Clarify dashboard health and quota usage
Co-Authored-By: Codex <noreply@openai.com>
1 parent f95d1dd commit d5f1d21

5 files changed

Lines changed: 109 additions & 5 deletions

File tree

cloudflare/ai-gateway-dash/src/index.mjs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,10 @@ function loading(title,text){return '<div class="loading"><div><div class="spinn
168168
function progressClass(p){return p>80?"err":p>50?"warn":"ok"}
169169
async function fetchJSON(path){const r=await fetch(API+path);let data=null;try{data=await r.json()}catch(e){}if(!r.ok||data&&data.status==="error"){const detail=data&&data.error?": "+data.error:"";throw new Error(path+": "+r.status+detail)}if(!data)throw new Error(path+": invalid JSON");return data}
170170
async function refresh(){const requests=[["/v1/ai/health","health","服务健康加载失败",renderHealth],["/v1/ai/quota","quota","配额消耗加载失败",d=>renderQuota(d.quota||d)],["/v1/ai/changes/effectiveness?days=90","effectiveness","有效性报告加载失败",d=>renderEffectiveness(d.report||d)],["/v1/ai/feedback/shadow","shadow","影子审计分歧加载失败",d=>renderShadow(d.disagreements||[])],["/v1/ai/changes?days=7","changes","最近变更加载失败",d=>renderChanges((d.changes||[]).slice(0,15))]],results=await Promise.allSettled(requests.map(r=>fetchJSON(r[0]))),failures=[];results.forEach((result,i)=>{const item=requests[i];try{if(result.status==="fulfilled")item[3](result.value);else throw result.reason}catch(e){failures.push(publicErrorMessage(e));panelError(item[1],item[2],e)}});document.getElementById("last-refresh").textContent=new Date().toLocaleTimeString();if(failures.length){setStatus("error");showToast("API: "+failures.slice(0,2).join(";"))}}
171-
function renderHealth(h){setStatus(h.status||"unknown");setUpdated("health-updated");const target=document.getElementById("health"),endpoints=h.endpoints||[],total=endpoints.reduce((s,i)=>s+(Number(i.total)||0),0),errors=endpoints.reduce((s,i)=>s+(Number(i.errors)||0),0),p95=Math.max(0,...endpoints.map(i=>Number(i.p95_ms)||0));clear(target);append(target,append(el("div","metric-line"),el("div","big ",statusText(h.status||"unknown")),el("span","delta "+clsStatus(h.status||"unknown"),fmtNum(Math.round((h.uptime_seconds||0)/3600))+" h")),append(el("div",""),statRow("接口数量",endpoints.length,"info"),statRow("总请求",fmtNum(total),""),statRow("错误",fmtNum(errors),errors?"err":"ok"),statRow("最高 P95",fmtNum(p95)+" ms",p95>30000?"err":p95>10000?"warn":"ok")))}
172-
function renderQuota(q){setUpdated("quota-updated");const target=document.getElementById("quota"),repos=q.repos||{};clear(target);if(!Object.keys(repos).length){empty(target,"暂无用量","当前窗口内没有记录到配额消耗。当前默认日预算 "+fmtUSD(q.default_daily_budget||0));return}const list=el("div","quota-list");for(const name of Object.keys(repos)){const d=repos[name]||{},used=Number(d.total_cost_usd)||0,budget=Number(d.daily_budget)||0,p=budget?used/budget*100:0,c=progressClass(p),item=el("div","quota-item"),meta=el("div",""),bar=el("div","bar"),fill=el("div","bar-fill "+c);fill.style.width=Math.max(0,Math.min(p,100))+"%";append(meta,el("div","quota-name",String(name).split("/").pop()||name),el("div","quota-meta",fmtUSD(used)+" / "+fmtUSD(budget)));append(bar,fill);append(item,meta,bar,el("div","bar-label",Math.round(p)+"%"));list.appendChild(item)}target.appendChild(list)}
171+
function healthReasonText(r){const path=r.path||"endpoint";if(r.reason==="error_rate")return path+" 错误率 "+fmtPct(r.value||0)+" ≥ "+fmtPct(r.threshold||0);if(r.reason==="p95_latency_ms")return path+" P95 "+fmtNum(r.value||0)+" ms ≥ "+fmtNum(r.threshold||0)+" ms";return path+" "+(r.reason||"degraded")}
172+
function renderHealth(h){setStatus(h.status||"unknown");setUpdated("health-updated");const target=document.getElementById("health"),endpoints=h.endpoints||[],reasons=h.degradation_reasons||[],total=endpoints.reduce((s,i)=>s+(Number(i.total)||0),0),errors=endpoints.reduce((s,i)=>s+(Number(i.errors)||0),0),p95=Math.max(0,...endpoints.map(i=>Number(i.p95_ms)||0)),details=el("div","");append(details,statRow("接口数量",endpoints.length,"info"),statRow("总请求",fmtNum(total),""),statRow("错误",fmtNum(errors),errors?"err":"ok"),statRow("最高 P95",fmtNum(p95)+" ms",p95>30000?"err":p95>10000?"warn":"ok"));if(reasons.length){append(details,statRow("降级原因",healthReasonText(reasons[0]),clsStatus(reasons[0].severity||h.status)))}else if(h.last_error&&h.last_error.message){append(details,statRow("最近错误",String(h.last_error.message).slice(0,80),"warn"))}clear(target);append(target,append(el("div","metric-line"),el("div","big ",statusText(h.status||"unknown")),el("span","delta "+clsStatus(h.status||"unknown"),fmtNum(Math.round((h.uptime_seconds||0)/3600))+" h")),details)}
173+
function quotaBucket(title,d){const used=Number(d&&d.total_cost_usd)||0,budget=Number(d&&d.daily_budget)||0,p=budget?used/budget*100:0,c=progressClass(p),item=el("div","quota-item"),meta=el("div",""),bar=el("div","bar"),fill=el("div","bar-fill "+c),calls=Number(d&&d.calls)||0,tokens=Number(d&&d.tokens_input)||0;fill.style.width=Math.max(0,Math.min(p,100))+"%";append(meta,el("div","quota-name",title),el("div","quota-meta",fmtUSD(used)+" / "+fmtUSD(budget)+" · "+fmtNum(calls)+" 次"+(tokens?" · "+fmtNum(tokens)+" input tokens":"")));append(bar,fill);append(item,meta,bar,el("div","bar-label",Math.round(p)+"%"));return item}
174+
function renderQuota(q){setUpdated("quota-updated");const target=document.getElementById("quota"),repos=q.repos||{},summary=q.summary||null;clear(target);if(summary){const list=el("div","quota-list");list.appendChild(quotaBucket("API Key 估算",summary.api_key||{}));list.appendChild(quotaBucket("Codex 估算",summary.codex||{}));target.appendChild(list);target.appendChild(el("div","subtle","这里是内部用量估算,不等同于 OpenAI/Anthropic/Codex 账户后台实时余额。"));return}if(!Object.keys(repos).length){empty(target,"暂无用量","当前窗口内没有记录到配额消耗。API Key 与 Codex 均无用量记录。");return}const list=el("div","quota-list");for(const name of Object.keys(repos)){const d=repos[name]||{},used=Number(d.total_cost_usd)||0,budget=Number(d.daily_budget)||0,p=budget?used/budget*100:0,c=progressClass(p),item=el("div","quota-item"),meta=el("div",""),bar=el("div","bar"),fill=el("div","bar-fill "+c);fill.style.width=Math.max(0,Math.min(p,100))+"%";append(meta,el("div","quota-name",String(name).split("/").pop()||name),el("div","quota-meta",fmtUSD(used)+" / "+fmtUSD(budget)));append(bar,fill);append(item,meta,bar,el("div","bar-label",Math.round(p)+"%"));list.appendChild(item)}target.appendChild(list)}
173175
function renderEffectiveness(e){setUpdated("effectiveness-updated");const target=document.getElementById("effectiveness"),rate=Number(e.improvement_rate)||0;clear(target);append(target,append(el("div","metric-line"),el("div","big blue",fmtPct(rate)),el("span","delta "+(rate>0.7?"ok":rate>0.4?"warn":"info"),"改善率")),append(el("div",""),statRow("已评估",e.evaluated||0,"info"),statRow("改善",e.improved||0,"ok"),statRow("退化",e.degraded||0,"err"),statRow("持平",e.neutral||0,""),statRow("待评估",e.pending||0,"info")))}
174176
function renderShadow(items){setUpdated("shadow-updated");const target=document.getElementById("shadow"),total=items.reduce((sum,item)=>sum+(Number(item.disagreement_count)||0),0);clear(target);if(!items.length){empty(target,"无活跃分歧","影子审计当前没有待复核分歧。");return}append(target,append(el("div","metric-line"),el("div","big violet",fmtNum(total)),el("span","delta warn","待复核")));for(const d of items.slice(0,5)){const row=el("div","stat-row"),value=el("span","stat-value warn");append(value,badge("warn",(d.disagreement_count||0)+"x"),document.createTextNode(" "+(d.ai_verdict||"")));append(row,el("span","stat-label",d.plugin||d.repo||"shadow"),value);target.appendChild(row)}}
175177
function renderChanges(items){setUpdated("changes-updated");const target=document.getElementById("changes");clear(target);if(!items.length){empty(target,"窗口内无变更","最近 7 天没有登记的变更记录。 ");return}const table=el("table",""),thead=el("thead",""),tr=el("tr","");for(const h of ["仓库","操作","效果","置信度","时间"])tr.appendChild(el("th","",h));thead.appendChild(tr);const tbody=el("tbody","");for(const c of items){const effect=c.effect||"pending",effectClass=effect==="improved"?"ok":effect==="degraded"?"err":"info",actionClass=c.action==="auto_merge"?"ok":c.action==="auto_pr"?"info":c.action?"violet":"warn",dt=c.created_at?new Date(c.created_at*1000).toLocaleDateString():"—",row=el("tr",""),repoName=String(c.repo||"").split("/").pop()||"—",repoCell=el("td","mono");if(c.external_url){const a=el("a","",repoName);a.href=c.external_url;a.target="_blank";a.rel="noreferrer";a.style.color="#93c5fd";a.style.textDecoration="none";repoCell.appendChild(a)}else repoCell.textContent=repoName;append(row,repoCell,append(el("td",""),badge(actionClass,c.action||"change")),el("td","stat-value "+effectClass,effect),el("td","mono",fmtPct(c.confidence||0)),el("td","mono",dt));tbody.appendChild(row)}append(table,thead,tbody);target.appendChild(table)}

service/health.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,45 @@ def status(self) -> str:
157157
return "degraded"
158158
return "healthy"
159159

160+
@property
161+
def degradation_reasons(self) -> list[dict[str, Any]]:
162+
"""Structured reasons explaining degraded or unhealthy status."""
163+
reasons: list[dict[str, Any]] = []
164+
for m in list(self._metrics.values()):
165+
if m.recent_error_rate >= ERROR_RATE_UNHEALTHY:
166+
reasons.append({
167+
"path": m.path,
168+
"reason": "error_rate",
169+
"severity": "unhealthy",
170+
"value": round(m.recent_error_rate, 4),
171+
"threshold": ERROR_RATE_UNHEALTHY,
172+
})
173+
elif m.recent_error_rate >= ERROR_RATE_DEGRADED:
174+
reasons.append({
175+
"path": m.path,
176+
"reason": "error_rate",
177+
"severity": "degraded",
178+
"value": round(m.recent_error_rate, 4),
179+
"threshold": ERROR_RATE_DEGRADED,
180+
})
181+
if m.p95_latency >= LATENCY_P95_UNHEALTHY:
182+
reasons.append({
183+
"path": m.path,
184+
"reason": "p95_latency_ms",
185+
"severity": "unhealthy",
186+
"value": round(m.p95_latency * 1000, 1),
187+
"threshold": round(LATENCY_P95_UNHEALTHY * 1000, 1),
188+
})
189+
elif m.p95_latency >= LATENCY_P95_DEGRADED:
190+
reasons.append({
191+
"path": m.path,
192+
"reason": "p95_latency_ms",
193+
"severity": "degraded",
194+
"value": round(m.p95_latency * 1000, 1),
195+
"threshold": round(LATENCY_P95_DEGRADED * 1000, 1),
196+
})
197+
return reasons
198+
160199
@property
161200
def uptime_seconds(self) -> float:
162201
return time.time() - self._started_at
@@ -179,6 +218,7 @@ def snapshot(self) -> dict[str, Any]:
179218
"status": self.status,
180219
"uptime_seconds": self.uptime_seconds,
181220
"endpoints": endpoints,
221+
"degradation_reasons": self.degradation_reasons,
182222
"last_error": self.last_error,
183223
}
184224

service/quota.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,24 @@ class QuotaRecord:
6464
repo: str
6565
tokens_input: int = 0
6666
tokens_output: int = 0
67+
api_calls: int = 0
6768
codex_calls: int = 0
6869
total_cost_usd: float = 0.0
6970
last_reset_daily: float = field(default_factory=time.time)
7071
last_reset_weekly: float = field(default_factory=time.time)
7172

7273
def to_dict(self) -> dict[str, Any]:
74+
codex_cost_usd = DEFAULT_MODEL_COSTS.get("codex-cli", {}).get("flat", 0.05) * self.codex_calls
75+
api_key_cost_usd = max(0.0, self.total_cost_usd - codex_cost_usd)
7376
return {
7477
"repo": self.repo,
7578
"tokens_input": self.tokens_input,
7679
"tokens_output": self.tokens_output,
80+
"api_calls": self.api_calls,
7781
"codex_calls": self.codex_calls,
7882
"total_cost_usd": round(self.total_cost_usd, 4),
83+
"api_key_cost_usd": round(api_key_cost_usd, 4),
84+
"codex_cost_usd": round(codex_cost_usd, 4),
7985
"last_reset_daily": self.last_reset_daily,
8086
"last_reset_weekly": self.last_reset_weekly,
8187
}
@@ -86,6 +92,7 @@ def from_dict(cls, d: dict[str, Any]) -> "QuotaRecord":
8692
repo=str(d.get("repo", "")),
8793
tokens_input=int(d.get("tokens_input", 0)),
8894
tokens_output=int(d.get("tokens_output", 0)),
95+
api_calls=int(d.get("api_calls", 0)),
8996
codex_calls=int(d.get("codex_calls", 0)),
9097
total_cost_usd=float(d.get("total_cost_usd", 0.0)),
9198
last_reset_daily=float(d.get("last_reset_daily", time.time())),
@@ -270,6 +277,8 @@ def record(self, repo: str, model: str, prompt: str, output: str = "") -> None:
270277
record.tokens_output += tokens_output
271278
if model == "codex-cli":
272279
record.codex_calls += 1
280+
else:
281+
record.api_calls += 1
273282
record.total_cost_usd += cost
274283
self._records[repo] = record
275284
self._save_records_locked()
@@ -287,22 +296,49 @@ def record_execute(self, repo: str) -> None:
287296
self._records[repo] = record
288297
self._save_records_locked()
289298

299+
def _summary_from_statuses(self, statuses: dict[str, dict[str, Any]]) -> dict[str, Any]:
300+
total_budget = sum(float(item.get("daily_budget", 0.0)) for item in statuses.values()) or self._daily_budget
301+
api_key_cost = sum(float(item.get("api_key_cost_usd", 0.0)) for item in statuses.values())
302+
codex_cost = sum(float(item.get("codex_cost_usd", 0.0)) for item in statuses.values())
303+
return {
304+
"quota_source": "internal_estimate",
305+
"api_key": {
306+
"label": "API key",
307+
"calls": sum(int(item.get("api_calls", 0)) for item in statuses.values()),
308+
"tokens_input": sum(int(item.get("tokens_input", 0)) for item in statuses.values()),
309+
"tokens_output": sum(int(item.get("tokens_output", 0)) for item in statuses.values()),
310+
"total_cost_usd": round(api_key_cost, 4),
311+
"daily_budget": round(total_budget, 4),
312+
"remaining_daily": round(max(0.0, total_budget - api_key_cost), 4),
313+
},
314+
"codex": {
315+
"label": "Codex",
316+
"calls": sum(int(item.get("codex_calls", 0)) for item in statuses.values()),
317+
"total_cost_usd": round(codex_cost, 4),
318+
"daily_budget": round(total_budget, 4),
319+
"remaining_daily": round(max(0.0, total_budget - codex_cost), 4),
320+
},
321+
}
322+
290323
def status(self, repo: str = "") -> dict[str, Any]:
291324
"""Get quota status for a repo or all repos."""
292325
with self._lock:
293326
if repo:
294327
record = self._records.get(repo)
295328
if not record:
296-
return {"repo": repo, "total_cost_usd": 0.0, "daily_budget": self.get_daily_budget(repo)}
329+
empty = QuotaRecord(repo=repo).to_dict()
330+
return {**empty, "daily_budget": self.get_daily_budget(repo), "weekly_budget": self.get_weekly_budget(repo)}
297331
record = self._reset_if_needed(record)
298332
return {
299333
**record.to_dict(),
300334
"daily_budget": self.get_daily_budget(repo),
301335
"weekly_budget": self.get_weekly_budget(repo),
302336
"remaining_daily": self.remaining_daily(repo),
303337
}
338+
repos = {r: self.status(r) for r in self._records}
304339
return {
305-
"repos": {r: self.status(r) for r in self._records},
340+
"repos": repos,
341+
"summary": self._summary_from_statuses(repos),
306342
"default_daily_budget": self._daily_budget,
307343
}
308344

tests/test_health.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def test_status_degraded_on_high_error_rate(self) -> None:
103103
for _ in range(int((1 - ERROR_RATE_DEGRADED) * 100) - 1):
104104
self.monitor.record("/v1/ai/analyze", latency=0.1, success=True)
105105
self.assertEqual(self.monitor.status, "degraded")
106+
self.assertEqual(self.monitor.degradation_reasons[0]["reason"], "error_rate")
106107

107108
def test_status_unhealthy_on_very_high_error_rate(self) -> None:
108109
for _ in range(int(ERROR_RATE_UNHEALTHY * 100)):
@@ -131,8 +132,15 @@ def test_snapshot_returns_all_fields(self) -> None:
131132
self.assertIn("status", snap)
132133
self.assertIn("uptime_seconds", snap)
133134
self.assertIn("endpoints", snap)
135+
self.assertIn("degradation_reasons", snap)
134136
self.assertIn("last_error", snap)
135137

138+
def test_snapshot_explains_latency_degradation(self) -> None:
139+
self.monitor.record("/v1/ai/execute/jobs", latency=31.0, success=True)
140+
snap = self.monitor.snapshot()
141+
self.assertEqual(snap["status"], "degraded")
142+
self.assertEqual(snap["degradation_reasons"][0]["reason"], "p95_latency_ms")
143+
136144
def test_multiple_endpoints_tracked_independently(self) -> None:
137145
self.monitor.record("/v1/ai/analyze", latency=0.5, success=True)
138146
self.monitor.record("/v1/ai/execute/jobs", latency=2.0, success=False)

tests/test_quota.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,15 @@ class TestQuotaRecord(unittest.TestCase):
7171
"""QuotaRecord serialization."""
7272

7373
def test_to_dict_includes_all_fields(self) -> None:
74-
record = QuotaRecord(repo="owner/repo", tokens_input=100, tokens_output=50, total_cost_usd=0.5)
74+
record = QuotaRecord(repo="owner/repo", tokens_input=100, tokens_output=50, api_calls=1, codex_calls=2, total_cost_usd=0.5)
7575
d = record.to_dict()
7676
self.assertEqual(d["repo"], "owner/repo")
7777
self.assertEqual(d["tokens_input"], 100)
7878
self.assertEqual(d["tokens_output"], 50)
79+
self.assertEqual(d["api_calls"], 1)
80+
self.assertEqual(d["codex_calls"], 2)
81+
self.assertGreater(d["api_key_cost_usd"], 0)
82+
self.assertGreater(d["codex_cost_usd"], 0)
7983
self.assertAlmostEqual(d["total_cost_usd"], 0.5)
8084

8185
def test_from_dict_roundtrip(self) -> None:
@@ -122,11 +126,25 @@ def test_status_returns_repo_info(self) -> None:
122126
self.assertEqual(status["repo"], "test/repo")
123127
self.assertIn("daily_budget", status)
124128
self.assertIn("remaining_daily", status)
129+
self.assertEqual(status["api_calls"], 1)
125130

126131
def test_status_returns_empty_if_no_records(self) -> None:
127132
status = self.manager.status("unknown/repo")
128133
self.assertIn("repo", status)
129134
self.assertEqual(status["total_cost_usd"], 0.0)
135+
self.assertEqual(status["api_calls"], 0)
136+
self.assertEqual(status["codex_calls"], 0)
137+
138+
def test_status_summary_splits_api_key_and_codex_usage(self) -> None:
139+
self.manager.record("test/repo", "claude-sonnet-4-6", "A" * 4000, "B" * 1000)
140+
self.manager.record_execute("test/repo")
141+
status = self.manager.status()
142+
summary = status["summary"]
143+
self.assertEqual(summary["quota_source"], "internal_estimate")
144+
self.assertEqual(summary["api_key"]["calls"], 1)
145+
self.assertEqual(summary["codex"]["calls"], 1)
146+
self.assertGreater(summary["api_key"]["total_cost_usd"], 0)
147+
self.assertGreater(summary["codex"]["total_cost_usd"], 0)
130148

131149
def test_daily_budget_resets(self) -> None:
132150
"""Quick test: budget resets when last_reset is old."""

0 commit comments

Comments
 (0)