Skip to content

Commit 37628ad

Browse files
committed
feat: 端到端传播 Copilot usage,dashboard 新增 Budget 面板与每行 Usage 列
- copilot_usage 字段:copilot 后端 → Responses/Anthropic 翻译层 → router state (totalNanoAiuSinceStart, historyNanoById) → SSE history_update 事件 → dashboard Route History Usage 列 + Total $ header - 4139 新增 Budget Review & Prediction 面板:聚合 reporting instance u/t,按 pace vs 日预算染色 (>1.1× 红 / >0.9× 黄 / 其余 绿) - DEFAULT_INSTANCE_COOLDOWN_MS 60min → 5h,让 402/429 真正冷却 dead quota - 测试与修复:3 type 收窄 (in guard, ?? null, as Uint8Array)、 2 test stale assertion (copilot_usage: null、WS spy setup)、 3 proxy.test 数字同步 3_600_000 → 18_000_000
1 parent 80663a9 commit 37628ad

16 files changed

Lines changed: 833 additions & 21 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,4 @@ docs/plans/
5959
# MemPalace per-project files (issue #185)
6060
mempalace.yaml
6161
entities.json
62+
.omo/run-continuation/

router/dashboard.html

Lines changed: 268 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,38 @@
155155
white-space: nowrap;
156156
font-variant-numeric: tabular-nums;
157157
}
158+
.usage-summary {
159+
color: var(--muted);
160+
cursor: pointer;
161+
user-select: none;
162+
white-space: nowrap;
163+
}
164+
.usage-summary:hover {
165+
color: var(--text);
166+
}
167+
.usage-details {
168+
min-width: 420px;
169+
}
170+
.usage-details pre {
171+
margin: 8px 0 0;
172+
padding: 8px 10px;
173+
border: 1px solid var(--border);
174+
border-radius: 8px;
175+
background: rgba(255,255,255,.03);
176+
overflow: auto;
177+
max-height: 240px;
178+
white-space: pre-wrap;
179+
word-break: break-word;
180+
}
181+
182+
.budget-cell { font-variant-numeric: tabular-nums; }
183+
184+
.budget-good { color: var(--good); }
185+
186+
.budget-warn { color: var(--warn); }
187+
188+
.budget-danger { color: var(--bad); }
189+
158190
</style>
159191
</head>
160192
<body>
@@ -168,6 +200,51 @@ <h1>Sticky Router Dashboard</h1>
168200
</div>
169201

170202
<div class="grid">
203+
<section class="panel" id="budget-panel">
204+
205+
<div class="panel-head">
206+
207+
<h2>Budget · <span id="budget-month"></span></h2>
208+
209+
<span class="muted" id="budget-snapshot-note"></span>
210+
211+
</div>
212+
213+
<div class="scroll">
214+
215+
<table>
216+
217+
<thead>
218+
219+
<tr><th>Used (USD)</th><th>Total (USD)</th><th>Used %</th><th>Pace (USD/day)</th><th>Month-end forecast (USD)</th></tr>
220+
221+
</thead>
222+
223+
<tbody>
224+
225+
<tr>
226+
227+
<td class="budget-cell" id="budget-used">-</td>
228+
229+
<td class="budget-cell" id="budget-total">-</td>
230+
231+
<td class="budget-cell" id="budget-pct">-</td>
232+
233+
<td class="budget-cell" id="budget-pace">-</td>
234+
235+
<td class="budget-cell" id="budget-forecast">-</td>
236+
237+
</tr>
238+
239+
</tbody>
240+
241+
</table>
242+
243+
</div>
244+
245+
</section>
246+
247+
171248
<section class="panel">
172249
<h2>Instances</h2>
173250
<div class="stats">
@@ -202,13 +279,13 @@ <h2>Active Bindings</h2>
202279

203280
<section class="panel">
204281
<div class="panel-head">
205-
<h2>Route History</h2>
282+
<h2>Route History <span class="muted" id="history-total-usd">(Total: $0.000000)</span></h2>
206283
<button id="clear-history" class="btn" type="button">Clear history</button>
207284
</div>
208285
<div class="scroll">
209286
<table>
210287
<thead>
211-
<tr><th>Time</th><th>Session</th><th>Agent</th><th>Model</th><th>Target</th><th>Reason</th></tr>
288+
<tr><th>Time</th><th>Session</th><th>Agent</th><th>Model</th><th>Target</th><th>Reason</th><th>Usage</th></tr>
212289
</thead>
213290
<tbody id="history-body"></tbody>
214291
</table>
@@ -218,7 +295,8 @@ <h2>Route History</h2>
218295
</div>
219296

220297
<script>
221-
const state = { history: [] }
298+
const state = { history: [], totalNanoAiuSinceStart: 0 }
299+
const HISTORY_DISPLAY_LIMIT = 50
222300
const LAST_ACTIVE_REFRESH_INTERVAL_MS = 5000
223301
const byId = (id) => document.getElementById(id)
224302
const cut = (value, size = 24) => {
@@ -361,6 +439,100 @@ <h2>Route History</h2>
361439
}).join('')
362440
}
363441

442+
function renderBudget(instances) {
443+
444+
const setText = (id, val) => { const el = byId(id); if (el) el.textContent = val }
445+
446+
const setClass = (id, val) => { const el = byId(id); if (el) el.className = val }
447+
448+
const reporting = (instances || []).filter((instance) => {
449+
450+
const usage = instance?.headerSnapshot?.premiumUsage
451+
452+
return usage && Number.isFinite(Number(usage.used)) && Number.isFinite(Number(usage.total))
453+
454+
})
455+
456+
const total = reporting.length
457+
458+
const now = new Date()
459+
460+
const monthName = now.toLocaleString('en-US', { month: 'long', year: 'numeric' })
461+
462+
setText('budget-month', monthName)
463+
464+
setText('budget-snapshot-note', `${total}/${(instances || []).length} instances reporting`)
465+
466+
if (total === 0) {
467+
468+
setText('budget-used', '-')
469+
470+
setText('budget-total', '-')
471+
472+
setText('budget-pct', '-')
473+
474+
setText('budget-pace', '-')
475+
476+
setText('budget-forecast', '-')
477+
478+
setClass('budget-forecast', 'budget-cell')
479+
480+
return
481+
482+
}
483+
484+
const totalUsedCredits = reporting.reduce((sum, instance) => sum + Number(instance.headerSnapshot.premiumUsage.used), 0)
485+
486+
const totalCapCredits = reporting.reduce((sum, instance) => sum + Number(instance.headerSnapshot.premiumUsage.total), 0)
487+
488+
const CREDIT_TO_USD = 0.01
489+
490+
const totalUsedUsd = totalUsedCredits * CREDIT_TO_USD
491+
492+
const totalCapUsd = totalCapCredits * CREDIT_TO_USD
493+
494+
const elapsed = now.getDate()
495+
496+
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()
497+
498+
const remaining = Math.max(0, daysInMonth - elapsed)
499+
500+
const paceUsd = elapsed > 0 ? totalUsedUsd / elapsed : 0
501+
502+
const forecastUsd = paceUsd * daysInMonth
503+
504+
const pct = totalCapUsd > 0 ? (totalUsedUsd / totalCapUsd) * 100 : 0
505+
506+
const dailyBudgetUsd = remaining > 0 ? (totalCapUsd - totalUsedUsd) / remaining : 0
507+
508+
const ratio = dailyBudgetUsd > 0 ? paceUsd / dailyBudgetUsd : Infinity
509+
510+
let statusClass = 'budget-good'
511+
512+
let statusIcon = '✓'
513+
514+
if (ratio > 1.1) { statusClass = 'budget-danger'; statusIcon = '🔴' }
515+
516+
else if (ratio > 0.9) { statusClass = 'budget-warn'; statusIcon = '⚠' }
517+
518+
const fmtUsd = (n) => `$${Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
519+
520+
setText('budget-used', fmtUsd(totalUsedUsd))
521+
522+
setText('budget-total', fmtUsd(totalCapUsd))
523+
524+
setText('budget-pct', `${pct.toFixed(1)}%`)
525+
526+
setText('budget-pace', paceUsd > 0 ? fmtUsd(paceUsd) : '-')
527+
528+
setText('budget-forecast', forecastUsd > 0 ? `${fmtUsd(forecastUsd)} ${statusIcon}` : '-')
529+
530+
setClass('budget-forecast', `budget-cell ${statusClass}`)
531+
532+
}
533+
534+
535+
364536
function renderBindings(bindings) {
365537
const entries = Object.entries(bindings || {})
366538
byId('binding-count').textContent = String(entries.length)
@@ -377,6 +549,68 @@ <h2>Route History</h2>
377549
`).join('')
378550
}
379551

552+
function toPrettyJson(value) {
553+
return value == null ? 'null' : JSON.stringify(value, null, 2)
554+
}
555+
556+
function usageSummary(item) {
557+
const usage = item.usage
558+
const copilotUsage = item.copilotUsage
559+
if (!usage && !copilotUsage) return '-'
560+
const cached =
561+
usage?.input_tokens_details?.cached_tokens
562+
?? usage?.cache_read_input_tokens
563+
const nano = copilotUsage?.total_nano_aiu
564+
const parts = []
565+
const usd = Number.isFinite(Number(nano)) ? Number(nano) / 100000000000 : null
566+
const tokenDetails = Array.isArray(copilotUsage?.token_details) ? copilotUsage.token_details : []
567+
const typeOrder = ["input", "cache_read", "cache_write", "output"]
568+
const typeLabel = {
569+
input: "in",
570+
cache_read: "cache",
571+
cache_write: "cache_write",
572+
output: "out",
573+
}
574+
const typeCosts = {}
575+
for (const detail of tokenDetails) {
576+
if (!detail || typeof detail !== "object") continue
577+
const tokenType = typeof detail.token_type === "string" ? detail.token_type : ""
578+
const tokenCount = Number(detail.token_count)
579+
const batchSize = Number(detail.batch_size)
580+
const costPerBatch = Number(detail.cost_per_batch)
581+
if (!tokenType || !Number.isFinite(tokenCount) || !Number.isFinite(batchSize) || !Number.isFinite(costPerBatch) || batchSize <= 0) continue
582+
const nanoCost = (tokenCount / batchSize) * costPerBatch
583+
if (!Number.isFinite(nanoCost)) continue
584+
typeCosts[tokenType] = (typeCosts[tokenType] || 0) + nanoCost
585+
}
586+
if (cached !== undefined) parts.push(`cached=${cached}`)
587+
if (usd !== null) parts.push(`$${usd.toFixed(6)}`)
588+
for (const tokenType of typeOrder) {
589+
const typeNano = typeCosts[tokenType]
590+
if (!Number.isFinite(typeNano)) continue
591+
const typeUsd = typeNano / 100000000000
592+
parts.push(`${typeLabel[tokenType] || tokenType}=$${typeUsd.toFixed(6)}`)
593+
}
594+
return parts.length ? parts.join(' · ') : 'details'
595+
}
596+
597+
function usageCell(item) {
598+
if (!item.usage && !item.copilotUsage) return '-'
599+
return `
600+
<details class="usage-details">
601+
<summary class="usage-summary">${usageSummary(item)}</summary>
602+
<pre>usage:\n${escapeHtml(toPrettyJson(item.usage))}\n\ncopilot_usage:\n${escapeHtml(toPrettyJson(item.copilotUsage))}</pre>
603+
</details>
604+
`
605+
}
606+
607+
function escapeHtml(value) {
608+
return String(value)
609+
.replaceAll('&', '&amp;')
610+
.replaceAll('<', '&lt;')
611+
.replaceAll('>', '&gt;')
612+
}
613+
380614
function historyRow(item) {
381615
return `
382616
<tr>
@@ -386,24 +620,40 @@ <h2>Route History</h2>
386620
<td>${cut(item.model, 28)}</td>
387621
<td><strong>${item.port || '-'}</strong></td>
388622
<td>${item.reason || '-'}</td>
623+
<td>${usageCell(item)}</td>
389624
</tr>
390625
`
391626
}
392627

393628
function renderHistory(history) {
394-
state.history = Array.isArray(history) ? [...history].sort((a, b) => (b.ts || '').localeCompare(a.ts || '')).slice(0, 50) : []
629+
state.history = Array.isArray(history) ? [...history].sort((a, b) => (b.ts || '').localeCompare(a.ts || '')).slice(0, HISTORY_DISPLAY_LIMIT) : []
395630
byId('history-count').textContent = String(state.history.length)
396631
const body = byId('history-body')
397632
if (!state.history.length) {
398-
body.innerHTML = '<tr><td colspan="6" class="empty">No routes yet.</td></tr>'
633+
body.innerHTML = '<tr><td colspan="7" class="empty">No routes yet.</td></tr>'
399634
return
400635
}
401636
body.innerHTML = state.history.map(historyRow).join('')
402637
}
403638

639+
function renderHistoryTotal(totalNanoAiuSinceStart) {
640+
const nano = Number(totalNanoAiuSinceStart)
641+
const usd = Number.isFinite(nano) ? nano / 100000000000 : 0
642+
byId('history-total-usd').textContent = `(Total: $${usd.toFixed(6)})`
643+
}
644+
404645
function prependHistory(item) {
405646
state.history.unshift(item)
406-
state.history = state.history.slice(0, 50)
647+
state.history = state.history.slice(0, HISTORY_DISPLAY_LIMIT)
648+
renderHistory(state.history)
649+
}
650+
651+
function updateHistoryItem(item) {
652+
const historyId = item?.historyId
653+
if (!historyId) return
654+
const index = state.history.findIndex((entry) => entry.historyId === historyId)
655+
if (index === -1) return
656+
state.history[index] = item
407657
renderHistory(state.history)
408658
}
409659

@@ -437,7 +687,11 @@ <h2>Route History</h2>
437687
const response = await fetch('/api/status')
438688
const payload = await response.json()
439689
renderInstances(payload.instances || [])
690+
renderBudget(payload.instances || [])
691+
440692
renderBindings(payload.sessionBindings || {})
693+
state.totalNanoAiuSinceStart = Number(payload.totalNanoAiuSinceStart || 0)
694+
renderHistoryTotal(state.totalNanoAiuSinceStart)
441695
if (typeof payload.routeHistorySize === 'number') {
442696
byId('history-count').textContent = String(payload.routeHistorySize)
443697
}
@@ -472,6 +726,14 @@ <h2>Route History</h2>
472726
events.addEventListener('reset', async () => {
473727
await refreshAll()
474728
})
729+
events.addEventListener('history_update', async (event) => {
730+
try {
731+
updateHistoryItem(JSON.parse(event.data))
732+
await loadStatus()
733+
} catch (error) {
734+
console.error('failed to parse history_update payload', error)
735+
}
736+
})
475737
}
476738

477739
byId('clear-bindings').addEventListener('click', () => clearData('bindings'))

0 commit comments

Comments
 (0)