codex-copilot-dx 0.9.4 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/dashboard/index.html +26 -10
- package/src/dashboard/ui.css +42 -1
- package/src/dashboard/ui.js +211 -24
- package/src/dashboard-api.mjs +19 -3
- package/src/usage-analytics.mjs +77 -0
- package/src/usage.mjs +11 -3
package/README.md
CHANGED
|
@@ -107,7 +107,7 @@ This performs a fresh, read-only GitHub Copilot model-directory lookup without s
|
|
|
107
107
|
|
|
108
108
|
Interactive terminals use aligned tables for `models`, `auth status`, and `usage`. Redirected or piped output retains the plain-text layout for scripts; use `--format table` or `--format plain` when a command offers an explicit format override.
|
|
109
109
|
|
|
110
|
-
`ccdx usage` and the dashboard Usage table show token cache hit rate for each model and the aggregate: cached input tokens divided by input tokens, weighted by token count rather than averaging request percentages. This describes the upstream prompt cache; the local history and image-transform caches are separate. A rate is `—` when input tokens are zero
|
|
110
|
+
`ccdx usage` and the dashboard Usage table show token cache hit rate for each model and the aggregate: recorded cached input tokens divided by recorded input tokens, weighted by token count rather than averaging request percentages. This describes the upstream prompt cache; the local history and image-transform caches are separate. Incomplete historical records do not hide a computable ratio: `~` marks a rate derived from incomplete counts. A rate is `—` when input tokens are zero, usable totals are missing, or token counts are invalid. Older logs may omit explicit zero cache counts; new Responses usage records retain reported zeros. CLI tables keep existing token columns on narrow terminals and show rates below the table when the extra column does not fit.
|
|
111
111
|
|
|
112
112
|
### Auto-review model
|
|
113
113
|
|
|
@@ -236,6 +236,10 @@ The dashboard loads local status, a live GitHub Copilot model directory, and a s
|
|
|
236
236
|
|
|
237
237
|
The transparent, single-color double-peak favicon follows the dashboard's saved light/dark choice without a separate network request.
|
|
238
238
|
|
|
239
|
+
Usage Analytics is collapsed by default. Opening it adds daily input/output token bars (7/30/90 days) and a 365-day calendar of recorded usage calls or tokens, grouped in the browser's time zone. Cache-read tokens are already part of input and are not added again. Model and range filters use the current snapshot without additional requests; selecting a day shows its filtered model totals in the Usage table, and All history restores the original table. Analytics shares the existing current/rotated-log scan and exposes only bounded metadata (365 days, up to 100 model filters). Days before retained history are hatched, not presented as zero; the oldest retained day may be partial. Counts describe retained usage records, not messages, session time, or all HTTP requests. Refresh explicitly to update the snapshot; there is no background collection or polling.
|
|
240
|
+
|
|
241
|
+
Terminal Animation is collapsed at the bottom of the page, with an Animation shortcut in the header. Preview frames load only when opened; closing it, hiding the page, or requesting reduced motion stops the preview timer. Unsaved choices survive folding and reopening. Recent upstream failures expand to existing diagnostic fields (time, model, code, message, response/request IDs, and retry policy) with a Copy diagnostic control. These are the last 10 retained `response.failed` events, not complete session histories; a retry attempt does not prove recovery. Diagnostic prose is redacted by the existing recorder but can still contain sensitive context: review before copying or sharing.
|
|
242
|
+
|
|
239
243
|
Inspect only the local Codex configuration:
|
|
240
244
|
|
|
241
245
|
```bash
|
package/package.json
CHANGED
package/src/dashboard/index.html
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<div class="shell">
|
|
15
15
|
<header class="topbar">
|
|
16
16
|
<div class="brand">CCDX <span class="brand-detail">/ LOCAL STATUS</span></div>
|
|
17
|
-
<div class="topbar-actions"><span id="connection" class="badge neutral" role="status">READING</span><button id="theme-toggle" class="icon-button" type="button" aria-label="Switch to light mode" title="Switch to light mode"><svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg><svg class="icon-moon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 3A9 9 0 1 0 21 12 7 7 0 0 1 12 3Z"/></svg></button><button id="refresh" type="button">Refresh</button></div>
|
|
17
|
+
<div class="topbar-actions"><span id="connection" class="badge neutral" role="status">READING</span><button id="theme-toggle" class="icon-button" type="button" aria-label="Switch to light mode" title="Switch to light mode"><svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg><svg class="icon-moon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 3A9 9 0 1 0 21 12 7 7 0 0 1 12 3Z"/></svg></button><button id="open-animation" type="button" aria-controls="animation-settings" aria-expanded="false">Animation</button><button id="refresh" type="button">Refresh</button></div>
|
|
18
18
|
</header>
|
|
19
19
|
|
|
20
20
|
<main>
|
|
@@ -47,12 +47,6 @@
|
|
|
47
47
|
</article>
|
|
48
48
|
</section>
|
|
49
49
|
|
|
50
|
-
<section class="panel dashboard-section" aria-label="Terminal animation">
|
|
51
|
-
<div class="panel-heading"><h2>TERMINAL ANIMATION</h2><span id="animation-state">READING</span></div>
|
|
52
|
-
<div id="animation-options" class="animation-options" role="group" aria-label="Animation theme"></div>
|
|
53
|
-
<div class="section-actions"><button id="save-animation" type="button" disabled>Save selection</button><span id="animation-note" role="status">Applies on next ccdx start.</span></div>
|
|
54
|
-
</section>
|
|
55
|
-
|
|
56
50
|
<section class="panel dashboard-section" aria-label="Live upstream models">
|
|
57
51
|
<div class="panel-heading"><h2>UPSTREAM GPT MODELS</h2><div class="heading-actions"><span id="models-state">CHECKING LIVE</span><button id="refresh-models" type="button">Refresh</button></div></div>
|
|
58
52
|
<p id="models-note" class="table-note">Querying GitHub Copilot…</p>
|
|
@@ -61,17 +55,39 @@
|
|
|
61
55
|
</section>
|
|
62
56
|
|
|
63
57
|
<section class="panel dashboard-section" aria-label="Usage by model">
|
|
64
|
-
<div class="panel-heading"><h2>USAGE</h2><div class="heading-actions"><span id="usage-state">READING LOG</span><button id="refresh-usage" type="button">Refresh</button></div></div>
|
|
58
|
+
<div class="panel-heading"><h2>USAGE</h2><div class="heading-actions"><span id="usage-state">READING LOG</span><button id="clear-usage-day" type="button" hidden>All history</button><button id="refresh-usage" type="button">Refresh</button></div></div>
|
|
65
59
|
<p id="usage-note" class="table-note">Aggregating local usage metadata…</p>
|
|
66
60
|
<div class="table-wrap"><table class="usage-table"><thead><tr><th scope="col">MODEL</th><th scope="col">CALLS</th><th scope="col">INPUT</th><th scope="col">CACHE READ</th><th scope="col">OUTPUT</th><th scope="col">TOTAL</th><th scope="col">CACHE HIT</th></tr></thead><tbody id="usage-body"><tr><td colspan="7">Loading…</td></tr></tbody></table></div>
|
|
67
|
-
<p class="hint">Cache hit = cached / input tokens.
|
|
61
|
+
<p class="hint">Cache hit = recorded cached / input tokens. ~ = incomplete history. — = unavailable or invalid counts.</p>
|
|
62
|
+
<details id="usage-analytics" class="analytics-section">
|
|
63
|
+
<summary>USAGE ANALYTICS</summary>
|
|
64
|
+
<div class="analytics-controls">
|
|
65
|
+
<label>Model <select id="analytics-model"><option value="">All models</option></select></label>
|
|
66
|
+
<label>Daily range <select id="analytics-range"><option value="7">7 days</option><option value="30" selected>30 days</option><option value="90">90 days</option></select></label>
|
|
67
|
+
<label>Activity <select id="analytics-metric"><option value="requests">Calls</option><option value="tokens">Tokens</option></select></label>
|
|
68
|
+
</div>
|
|
69
|
+
<p id="analytics-note" class="table-note" role="status">Open to read retained usage history.</p>
|
|
70
|
+
<div class="chart-heading"><h3>DAILY TOKENS</h3><span>INPUT + OUTPUT / CACHE INCLUDED IN INPUT</span></div>
|
|
71
|
+
<div id="daily-bars" class="daily-bars" aria-label="Daily token usage"></div>
|
|
72
|
+
<div class="daily-axis"><span id="daily-from"></span><span id="daily-to"></span></div>
|
|
73
|
+
<div class="chart-legend"><span class="legend-input">Input</span><span class="legend-output">Output</span></div>
|
|
74
|
+
<div class="chart-heading"><h3>REQUEST ACTIVITY · 365 DAYS</h3><span id="activity-scale">—</span></div>
|
|
75
|
+
<div class="calendar-scroll"><div class="calendar-grid"><div id="calendar-months" class="calendar-months" aria-hidden="true"></div><div id="activity-calendar" class="activity-calendar" aria-label="Daily recorded request activity"></div></div></div>
|
|
76
|
+
<p id="analytics-selection" class="hint" role="status">Select a day to inspect its model totals. No history is distinct from zero recorded calls.</p>
|
|
77
|
+
</details>
|
|
68
78
|
</section>
|
|
69
79
|
|
|
70
80
|
<section class="lower" aria-label="Recent failures">
|
|
71
|
-
<div class="panel-heading"><h2>RECENT UPSTREAM FAILURES</h2><span>response.failed</span></div>
|
|
81
|
+
<div class="panel-heading"><h2>RECENT UPSTREAM FAILURES</h2><span id="failure-count">response.failed</span></div>
|
|
72
82
|
<ol id="failures"><li class="empty">Reading…</li></ol>
|
|
73
83
|
<p class="hint">Upstream messages may contain sensitive text.</p>
|
|
74
84
|
</section>
|
|
85
|
+
|
|
86
|
+
<details id="animation-settings" class="panel dashboard-section settings-panel">
|
|
87
|
+
<summary class="panel-heading"><h2>TERMINAL ANIMATION</h2><span id="animation-state">READING</span></summary>
|
|
88
|
+
<div id="animation-options" class="animation-options" role="group" aria-label="Animation theme"></div>
|
|
89
|
+
<div class="section-actions"><button id="save-animation" type="button" disabled>Save selection</button><span id="animation-note" role="status">Applies on next ccdx start.</span></div>
|
|
90
|
+
</details>
|
|
75
91
|
</main>
|
|
76
92
|
|
|
77
93
|
<footer>LOCAL ONLY / NO BACKGROUND POLLING</footer>
|
package/src/dashboard/ui.css
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
:root { color-scheme: dark; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --page: #0b110d; --surface: #111a13; --text: #e0e9df; --strong: #f0f6ec; --muted: #9aada0; --badge: #a9b9aa; --subtle: #78917d; --quiet: #6d8371; --footer: #617966; --border: #2b3b2f; --top-border: #2a3a2e; --accent: #a6f5a8; --accent-soft: #84e99e; --heading: #83b98b; --bad: #ff947e; --failed: #ff9f91; --incomplete: #e3c77c; --button-border: #45634b; --button-bg: #152219; --button-fg: #b2f1bd; --button-hover: #203526; --selected-bg: #192a1d; --option-hover: #1a2a1e; --table-text: #d8e2d8; --preview: #657d69; --ansi-white: #f5fff5; --ansi-159: #9af5e9; --ansi-123: #75e4d9; --ansi-87: #5ad0d4; --ansi-51: #42b9d8; --ansi-45: #3ca2d0; --ansi-39: #4389c2; --ansi-33: #4775b2; }
|
|
2
2
|
:root[data-theme="light"] { color-scheme: light; --page: #f5f8f3; --surface: #ffffff; --text: #1b2b20; --strong: #122219; --muted: #475d4e; --badge: #475d4e; --subtle: #4b6652; --quiet: #55715c; --footer: #59735f; --border: #c4d2c5; --top-border: #b8c9ba; --accent: #176b3a; --accent-soft: #176b3a; --heading: #235f37; --bad: #a33625; --failed: #a33625; --incomplete: #835600; --button-border: #79a085; --button-bg: #ebf4ec; --button-fg: #185b32; --button-hover: #dbeedd; --selected-bg: #e4f3e7; --option-hover: #edf6ee; --table-text: #233b2b; --preview: #596f60; --ansi-white: #234231; --ansi-159: #116b64; --ansi-123: #0d7771; --ansi-87: #116b75; --ansi-51: #14697e; --ansi-45: #1a6183; --ansi-39: #265f8d; --ansi-33: #365a8d; }
|
|
3
3
|
* { box-sizing: border-box; }
|
|
4
|
+
[hidden] { display: none !important; }
|
|
4
5
|
body { margin: 0; color: var(--text); background: var(--page); font-size: 13px; }
|
|
5
6
|
.shell { max-width: 1220px; margin: 0 auto; padding: 0 24px; }
|
|
6
7
|
.topbar { min-height: 68px; display: flex; justify-content: space-between; align-items: center; gap: 20px; border-bottom: 1px solid var(--top-border); }
|
|
@@ -78,12 +79,52 @@ td { color: var(--table-text); }
|
|
|
78
79
|
.usage-table tr.total-row td { color: var(--accent); font-weight: 700; }
|
|
79
80
|
.lower { margin-top: 11px; padding: 18px; }
|
|
80
81
|
.lower ol { list-style: none; margin: 16px 0 0; padding: 0; }
|
|
81
|
-
.lower li {
|
|
82
|
+
.lower li { padding: 9px 0; border-top: 1px solid var(--border); font-size: 11px; }
|
|
82
83
|
.lower li strong, .lower li span { overflow-wrap: anywhere; }
|
|
83
84
|
.lower li strong { color: var(--text); font-weight: 650; }
|
|
84
85
|
.lower li span, .lower li.empty { color: var(--muted); line-height: 1.5; }
|
|
86
|
+
summary { cursor: pointer; line-height: 1.6; overflow-wrap: anywhere; }
|
|
87
|
+
summary:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
|
88
|
+
.settings-panel > summary { list-style: none; align-items: center; }
|
|
89
|
+
.settings-panel > summary::-webkit-details-marker { display: none; }
|
|
90
|
+
.settings-panel > summary h2::before { content: "+ "; }
|
|
91
|
+
.settings-panel[open] > summary h2::before { content: "− "; }
|
|
92
|
+
.failure-detail { display: grid; grid-template-columns: minmax(110px, auto) minmax(0, 1fr); gap: 7px 18px; margin: 14px 0; line-height: 1.6; }
|
|
93
|
+
.failure-detail dt { color: var(--muted); }
|
|
94
|
+
.failure-detail dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
95
|
+
.analytics-section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
|
|
96
|
+
.analytics-section > summary { color: var(--heading); font-size: 11px; font-weight: 700; letter-spacing: .08em; }
|
|
97
|
+
.analytics-controls { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 18px; }
|
|
98
|
+
.analytics-controls label { color: var(--muted); font-size: 11px; }
|
|
99
|
+
select { max-width: 260px; padding: 5px; font: inherit; color: var(--text); background: var(--page); border: 1px solid var(--border); }
|
|
100
|
+
.chart-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin: 22px 0 10px; }
|
|
101
|
+
.chart-heading h3 { margin: 0; color: var(--heading); font-size: 11px; }
|
|
102
|
+
.chart-heading span, .chart-legend { color: var(--muted); font-size: 10px; }
|
|
103
|
+
.daily-bars { display: flex; align-items: end; gap: 3px; height: 130px; border-bottom: 1px solid var(--border); }
|
|
104
|
+
.daily-bar { height: 100%; flex: 1; min-width: 0; padding: 0; border: 0; display: flex; flex-direction: column; justify-content: end; background: transparent; border-radius: 0; }
|
|
105
|
+
.daily-bar span { display: block; flex-shrink: 0; width: 100%; }
|
|
106
|
+
.bar-input { background: var(--accent-soft); }
|
|
107
|
+
.bar-output { background: var(--ansi-39); }
|
|
108
|
+
.daily-bar:hover { background: var(--option-hover); }
|
|
109
|
+
.daily-axis { display: flex; justify-content: space-between; color: var(--muted); font-size: 10px; margin-top: 7px; }
|
|
110
|
+
.daily-bar[aria-pressed="true"], .calendar-day[aria-pressed="true"] { outline: 2px solid var(--strong); outline-offset: 1px; }
|
|
111
|
+
.chart-legend { display: flex; gap: 18px; margin-top: 9px; }
|
|
112
|
+
.chart-legend span::before { content: "■"; margin-right: 6px; }
|
|
113
|
+
.legend-input::before { color: var(--accent-soft); }
|
|
114
|
+
.legend-output::before { color: var(--ansi-39); }
|
|
115
|
+
.calendar-scroll { overflow-x: auto; padding: 4px; }
|
|
116
|
+
.calendar-grid { width: max-content; }
|
|
117
|
+
.calendar-months { display: grid; grid-auto-columns: 12px; gap: 4px; margin-bottom: 7px; color: var(--muted); font-size: 9px; height: 14px; }
|
|
118
|
+
.activity-calendar { display: grid; grid-template-rows: repeat(7, 12px); grid-auto-flow: column; grid-auto-columns: 12px; gap: 4px; width: max-content; }
|
|
119
|
+
.calendar-day { padding: 0; border: 1px solid var(--border); width: 12px; height: 12px; background: var(--button-bg); }
|
|
120
|
+
.calendar-day[data-level="1"] { background: color-mix(in srgb, var(--accent-soft) 25%, var(--surface)); }
|
|
121
|
+
.calendar-day[data-level="2"] { background: color-mix(in srgb, var(--accent-soft) 45%, var(--surface)); }
|
|
122
|
+
.calendar-day[data-level="3"] { background: color-mix(in srgb, var(--accent-soft) 70%, var(--surface)); }
|
|
123
|
+
.calendar-day[data-level="4"] { background: var(--accent-soft); }
|
|
124
|
+
.calendar-day:disabled, .daily-bar:disabled { background: repeating-linear-gradient(135deg, transparent, transparent 3px, var(--border) 3px, var(--border) 4px); opacity: .5; cursor: default; }
|
|
85
125
|
footer { color: var(--footer); font-size: 10px; letter-spacing: .05em; padding: 22px 0 30px; }
|
|
86
126
|
@media (max-width: 1000px) { .animation-options { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
87
127
|
@media (max-width: 900px) { .overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .details { grid-template-columns: repeat(2, minmax(0, 1fr)); } .details .panel:last-child { grid-column: 1 / -1; } }
|
|
88
128
|
@media (max-width: 680px) { .animation-options { grid-template-columns: 1fr; } }
|
|
89
129
|
@media (max-width: 620px) { .shell { padding: 0 16px; } .topbar { min-height: 60px; } .brand-detail { display: none; } .topbar-actions { gap: 11px; } .intro { display: block; padding: 29px 0 21px; } .intro p { margin-top: 9px; } .overview, .details { grid-template-columns: 1fr; } .details .panel:last-child { grid-column: auto; } .card { min-height: 122px; } .card strong { margin-top: 14px; } .lower li { display: block; } .lower li span { display: block; margin-top: 5px; } }
|
|
130
|
+
@media (max-width: 460px) { .topbar { flex-wrap: wrap; padding: 12px 0; gap: 10px; } .topbar-actions { gap: 9px; } .panel-heading { flex-wrap: wrap; } .heading-actions { gap: 10px; } .failure-detail { grid-template-columns: 1fr; gap: 3px; } .failure-detail dd { margin-bottom: 7px; } select { max-width: 210px; } }
|
package/src/dashboard/ui.js
CHANGED
|
@@ -15,8 +15,10 @@ function duration(value) {
|
|
|
15
15
|
|
|
16
16
|
function renderFailures(recent) {
|
|
17
17
|
const list = element("failures");
|
|
18
|
+
const expanded = new Set([...list.querySelectorAll("details[open]")].map((item) => item.dataset.key));
|
|
18
19
|
list.replaceChildren();
|
|
19
|
-
const failures = Array.isArray(recent) ? recent.slice(-
|
|
20
|
+
const failures = Array.isArray(recent) ? recent.slice(-10).reverse() : [];
|
|
21
|
+
text("failure-count", `${failures.length} retained / response.failed`);
|
|
20
22
|
if (!failures.length) {
|
|
21
23
|
const item = document.createElement("li");
|
|
22
24
|
item.className = "empty";
|
|
@@ -26,11 +28,38 @@ function renderFailures(recent) {
|
|
|
26
28
|
}
|
|
27
29
|
for (const failure of failures) {
|
|
28
30
|
const item = document.createElement("li");
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const details = document.createElement("details");
|
|
32
|
+
details.dataset.key = `${failure.at}/${failure.response_id}/${failure.code}`;
|
|
33
|
+
details.open = expanded.has(details.dataset.key);
|
|
34
|
+
const title = document.createElement("summary");
|
|
35
|
+
title.textContent = `${String(failure.at || "unknown time")} / ${String(failure.model || "unknown_model").slice(0, 80)} / ${String(failure.code || "unknown_error").slice(0, 80)} / ${failure.retried ? "retry attempted" : "no retry"}`;
|
|
36
|
+
const fields = { time: failure.at, model: failure.model, event: failure.event_type, code: failure.code,
|
|
37
|
+
message: failure.message, response_id: failure.response_id, upstream_request_id: failure.upstream_request_id,
|
|
38
|
+
retry: failure.retried ? "attempted (outcome not recorded here)" : "not attempted",
|
|
39
|
+
retry_policy: failure.retry_policy, retry_skipped: failure.retry_skipped };
|
|
40
|
+
const diagnostic = document.createElement("dl");
|
|
41
|
+
diagnostic.className = "failure-detail";
|
|
42
|
+
const lines = [];
|
|
43
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
44
|
+
if (value === undefined || value === null || value === "") continue;
|
|
45
|
+
const label = document.createElement("dt");
|
|
46
|
+
const content = document.createElement("dd");
|
|
47
|
+
label.textContent = key;
|
|
48
|
+
content.textContent = String(value).slice(0, 500);
|
|
49
|
+
diagnostic.append(label, content);
|
|
50
|
+
lines.push(`${key}: ${content.textContent}`);
|
|
51
|
+
}
|
|
52
|
+
const copy = document.createElement("button");
|
|
53
|
+
copy.type = "button";
|
|
54
|
+
copy.textContent = "Copy diagnostic";
|
|
55
|
+
copy.addEventListener("click", async () => {
|
|
56
|
+
try {
|
|
57
|
+
await navigator.clipboard.writeText(lines.join("\n"));
|
|
58
|
+
copy.textContent = "Copied";
|
|
59
|
+
} catch { copy.textContent = "Copy unavailable"; }
|
|
60
|
+
});
|
|
61
|
+
details.append(title, diagnostic, copy);
|
|
62
|
+
item.append(details);
|
|
34
63
|
list.append(item);
|
|
35
64
|
}
|
|
36
65
|
}
|
|
@@ -109,6 +138,8 @@ let previewDisabledByEnvironment = false;
|
|
|
109
138
|
let previewVisible = false;
|
|
110
139
|
let previewTimer = null;
|
|
111
140
|
let previewStarted = 0;
|
|
141
|
+
let animationLoading = false;
|
|
142
|
+
let animationFramesLoaded = false;
|
|
112
143
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
113
144
|
|
|
114
145
|
function renderAnsiFrame(target, frame) {
|
|
@@ -146,14 +177,14 @@ function stopAnimationPreview() {
|
|
|
146
177
|
|
|
147
178
|
function tickAnimationPreview() {
|
|
148
179
|
previewTimer = null;
|
|
149
|
-
if (!previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) return;
|
|
180
|
+
if (!animationTimelines.length || !element("animation-settings").open || !previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) return;
|
|
150
181
|
renderAnimationFrames(performance.now() - previewStarted);
|
|
151
182
|
previewTimer = setTimeout(tickAnimationPreview, 32);
|
|
152
183
|
}
|
|
153
184
|
|
|
154
185
|
function syncAnimationPreview() {
|
|
155
186
|
stopAnimationPreview();
|
|
156
|
-
if (!previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) {
|
|
187
|
+
if (!animationTimelines.length || !element("animation-settings").open || !previewVisible || document.visibilityState !== "visible" || reducedMotion.matches || previewDisabledByEnvironment) {
|
|
157
188
|
for (const timeline of animationTimelines) timeline.lastFrame = -2;
|
|
158
189
|
renderAnimationFrames(0);
|
|
159
190
|
return;
|
|
@@ -186,6 +217,7 @@ function buildAnimationOptions(data) {
|
|
|
186
217
|
selectedAnimation = theme.id;
|
|
187
218
|
for (const [id, row] of animationRows) row.setAttribute("aria-pressed", String(id === theme.id));
|
|
188
219
|
element("save-animation").disabled = selectedAnimation === savedAnimation;
|
|
220
|
+
updateAnimationState();
|
|
189
221
|
syncAnimationPreview();
|
|
190
222
|
});
|
|
191
223
|
const name = document.createElement("span");
|
|
@@ -220,20 +252,33 @@ function animationNote(data) {
|
|
|
220
252
|
}
|
|
221
253
|
|
|
222
254
|
async function loadAnimation() {
|
|
255
|
+
const includeFrames = element("animation-settings").open;
|
|
256
|
+
if (animationLoading || (includeFrames && animationFramesLoaded)) return;
|
|
257
|
+
animationLoading = true;
|
|
223
258
|
text("animation-state", "READING");
|
|
224
259
|
try {
|
|
225
|
-
const response = await fetch(
|
|
260
|
+
const response = await fetch(`/_ccdx/ui/animation${includeFrames ? "" : "?frames=0"}`, { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(8000) });
|
|
226
261
|
const data = await response.json();
|
|
227
262
|
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
228
|
-
|
|
229
|
-
|
|
263
|
+
if (includeFrames) {
|
|
264
|
+
buildAnimationOptions(data);
|
|
265
|
+
animationFramesLoaded = true;
|
|
266
|
+
} else { savedAnimation = data.theme; selectedAnimation = data.theme; }
|
|
267
|
+
updateAnimationState();
|
|
230
268
|
text("animation-note", animationNote(data));
|
|
231
269
|
} catch (error) {
|
|
232
270
|
text("animation-state", "UNAVAILABLE");
|
|
233
271
|
text("animation-note", String(error.message || error).slice(0, 180));
|
|
272
|
+
} finally {
|
|
273
|
+
animationLoading = false;
|
|
274
|
+
if (!includeFrames && element("animation-settings").open) loadAnimation();
|
|
234
275
|
}
|
|
235
276
|
}
|
|
236
277
|
|
|
278
|
+
function updateAnimationState() {
|
|
279
|
+
text("animation-state", `CURRENT ${savedAnimation.toUpperCase()}${selectedAnimation !== savedAnimation ? " / UNSAVED" : ""}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
237
282
|
async function saveAnimation() {
|
|
238
283
|
const button = element("save-animation");
|
|
239
284
|
if (!selectedAnimation || selectedAnimation === savedAnimation) return;
|
|
@@ -249,7 +294,7 @@ async function saveAnimation() {
|
|
|
249
294
|
const data = await response.json();
|
|
250
295
|
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
251
296
|
savedAnimation = data.theme;
|
|
252
|
-
|
|
297
|
+
updateAnimationState();
|
|
253
298
|
text("animation-note", animationNote(data));
|
|
254
299
|
} catch (error) {
|
|
255
300
|
text("animation-note", `Not saved / ${String(error.message || error).slice(0, 150)}`);
|
|
@@ -309,40 +354,182 @@ async function loadModels() {
|
|
|
309
354
|
|
|
310
355
|
function usageValues(row) {
|
|
311
356
|
const rate = Number.isFinite(row.cache_hit_rate) && row.cache_hit_rate >= 0 && row.cache_hit_rate <= 1
|
|
312
|
-
? `${(row.cache_hit_rate * 100).toFixed(1)}%` : "—";
|
|
357
|
+
? `${row.cache_hit_rate_partial ? "~" : ""}${(row.cache_hit_rate * 100).toFixed(1)}%` : "—";
|
|
313
358
|
return [row.model, number(row.requests), number(row.input_tokens), number(row.cache_read_tokens), number(row.output_tokens), number(row.total_tokens), rate];
|
|
314
359
|
}
|
|
315
360
|
|
|
361
|
+
let usageData = null;
|
|
362
|
+
let usageLoading = false;
|
|
363
|
+
let selectedUsageDate = "";
|
|
364
|
+
|
|
365
|
+
function renderUsageTable() {
|
|
366
|
+
const day = selectedUsageDate && usageData.analytics?.days.find((entry) => entry.date === selectedUsageDate);
|
|
367
|
+
const model = element("analytics-model").value;
|
|
368
|
+
const total = day ? { model: "TOTAL", ...analyticsPoint(day, model) } : usageData.total;
|
|
369
|
+
const rows = day ? day.models.filter((row) => !model || row.model === model).sort((a, b) => b.total_tokens - a.total_tokens || a.model.localeCompare(b.model)) : usageData.rows;
|
|
370
|
+
const body = element("usage-body");
|
|
371
|
+
body.replaceChildren();
|
|
372
|
+
if (total.requests > 0) {
|
|
373
|
+
body.append(tableRow(usageValues(total), { total: true }));
|
|
374
|
+
for (const row of rows) body.append(tableRow(usageValues(row)));
|
|
375
|
+
} else { emptyTable("usage-body", 7, "No usage records."); }
|
|
376
|
+
element("clear-usage-day").hidden = !day;
|
|
377
|
+
text("usage-state", day ? day.date : "LOCAL LOG");
|
|
378
|
+
text("usage-note", `${number(total.requests)} records / ${number(rows.length)} of ${number(day ? rows.length : usageData.model_count)} models shown${day ? ` / ${usageData.analytics.time_zone} / ${model || "All models"}` : ""}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function analyticsPoint(day, model) {
|
|
382
|
+
return model ? day.models.find((row) => row.model === model) || { requests: 0, input_tokens: 0, output_tokens: 0 } : day;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function selectUsageDay(date) {
|
|
386
|
+
selectedUsageDate = date;
|
|
387
|
+
renderUsageTable();
|
|
388
|
+
renderAnalytics();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function dayButton(day, point) {
|
|
392
|
+
const button = document.createElement("button");
|
|
393
|
+
button.type = "button";
|
|
394
|
+
button.disabled = !day.available;
|
|
395
|
+
button.setAttribute("aria-pressed", String(day.date === selectedUsageDate));
|
|
396
|
+
const description = !day.available ? "no retained history"
|
|
397
|
+
: `${number(point.requests)} recorded calls / input ${number(point.input_tokens)} / output ${number(point.output_tokens)} / cached ${number(point.cache_read_tokens)} (included in input)${point.tokens_partial ? " / partial token counts" : ""}`;
|
|
398
|
+
button.title = `${day.date} / ${description}`;
|
|
399
|
+
button.setAttribute("aria-label", button.title);
|
|
400
|
+
button.addEventListener("click", () => selectUsageDay(day.date));
|
|
401
|
+
return button;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function renderAnalytics() {
|
|
405
|
+
const analytics = usageData?.analytics;
|
|
406
|
+
if (!analytics) return;
|
|
407
|
+
const model = element("analytics-model").value;
|
|
408
|
+
const days = analytics.days;
|
|
409
|
+
const daily = days.slice(-Number(element("analytics-range").value));
|
|
410
|
+
const maximum = Math.max(1, ...daily.map((day) => {
|
|
411
|
+
const point = analyticsPoint(day, model);
|
|
412
|
+
return point.input_tokens + point.output_tokens;
|
|
413
|
+
}));
|
|
414
|
+
const bars = element("daily-bars");
|
|
415
|
+
bars.replaceChildren();
|
|
416
|
+
for (const day of daily) {
|
|
417
|
+
const point = analyticsPoint(day, model);
|
|
418
|
+
const button = dayButton(day, point);
|
|
419
|
+
button.className = "daily-bar";
|
|
420
|
+
for (const key of ["output_tokens", "input_tokens"]) {
|
|
421
|
+
const segment = document.createElement("span");
|
|
422
|
+
segment.className = key === "input_tokens" ? "bar-input" : "bar-output";
|
|
423
|
+
segment.style.height = `${point[key] / maximum * 100}%`;
|
|
424
|
+
button.append(segment);
|
|
425
|
+
}
|
|
426
|
+
bars.append(button);
|
|
427
|
+
}
|
|
428
|
+
text("daily-from", daily[0].date);
|
|
429
|
+
text("daily-to", analytics.to);
|
|
430
|
+
const metric = element("analytics-metric").value;
|
|
431
|
+
const value = (day) => {
|
|
432
|
+
const point = analyticsPoint(day, model);
|
|
433
|
+
return metric === "tokens" ? point.input_tokens + point.output_tokens : point.requests;
|
|
434
|
+
};
|
|
435
|
+
const peak = Math.max(1, ...days.map(value));
|
|
436
|
+
text("activity-scale", `${metric === "tokens" ? "INPUT + OUTPUT" : "RECORDED CALLS"} / PEAK ${number(peak === 1 && days.every((day) => value(day) === 0) ? 0 : peak)}`);
|
|
437
|
+
const calendar = element("activity-calendar");
|
|
438
|
+
calendar.replaceChildren();
|
|
439
|
+
const offset = (new Date(`${days[0].date}T00:00:00Z`).getUTCDay() + 6) % 7;
|
|
440
|
+
const months = element("calendar-months");
|
|
441
|
+
months.replaceChildren();
|
|
442
|
+
const columns = Math.ceil((offset + days.length) / 7);
|
|
443
|
+
let previousColumn = -4;
|
|
444
|
+
days.forEach((day, index) => {
|
|
445
|
+
if (index !== 0 && !day.date.endsWith("-01")) return;
|
|
446
|
+
const column = Math.floor((offset + index) / 7);
|
|
447
|
+
if (column - previousColumn < 4 || column + 4 > columns) return;
|
|
448
|
+
previousColumn = column;
|
|
449
|
+
const label = document.createElement("span");
|
|
450
|
+
label.textContent = day.date.slice(0, 7);
|
|
451
|
+
label.style.gridColumn = `${column + 1} / span 4`;
|
|
452
|
+
months.append(label);
|
|
453
|
+
});
|
|
454
|
+
for (let index = 0; index < offset; index += 1) {
|
|
455
|
+
const spacer = document.createElement("span");
|
|
456
|
+
spacer.setAttribute("aria-hidden", "true");
|
|
457
|
+
calendar.append(spacer);
|
|
458
|
+
}
|
|
459
|
+
for (const day of days) {
|
|
460
|
+
const button = dayButton(day, analyticsPoint(day, model));
|
|
461
|
+
button.className = "calendar-day";
|
|
462
|
+
const count = value(day);
|
|
463
|
+
button.dataset.level = String(count > 0 ? Math.min(4, Math.ceil(count / peak * 4)) : 0);
|
|
464
|
+
calendar.append(button);
|
|
465
|
+
}
|
|
466
|
+
text("analytics-note", `${analytics.time_zone} / ${daily[0].date} — ${analytics.to} / retained history starts ${analytics.first_date || "unknown"}; oldest day may be partial.${analytics.undated_records ? ` ${number(analytics.undated_records)} records with invalid/future timestamps excluded.` : ""}${analytics.models_truncated ? " Model filters limited to 100; all-model totals include the remainder." : ""}`);
|
|
467
|
+
text("analytics-selection", selectedUsageDate ? `Selected ${selectedUsageDate} / ${model || "All models"}: totals in the Usage table above / All history resets the table.` : "Select a day for model totals. Calls are usage records, not messages or time. Hatched cells: no retained history; empty cells: zero recorded calls.");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function updateAnalyticsModels() {
|
|
471
|
+
const select = element("analytics-model");
|
|
472
|
+
const previous = select.value;
|
|
473
|
+
select.replaceChildren();
|
|
474
|
+
for (const model of ["", ...usageData.analytics.models]) {
|
|
475
|
+
const option = document.createElement("option");
|
|
476
|
+
option.value = model;
|
|
477
|
+
option.textContent = model || "All models";
|
|
478
|
+
select.append(option);
|
|
479
|
+
}
|
|
480
|
+
select.value = usageData.analytics.models.includes(previous) ? previous : "";
|
|
481
|
+
}
|
|
482
|
+
|
|
316
483
|
async function loadUsage() {
|
|
484
|
+
if (usageLoading) return;
|
|
485
|
+
usageLoading = true;
|
|
486
|
+
const includeAnalytics = element("usage-analytics").open || Boolean(selectedUsageDate);
|
|
317
487
|
const button = element("refresh-usage");
|
|
318
488
|
button.disabled = true;
|
|
319
489
|
text("usage-state", "READING LOG");
|
|
320
490
|
text("usage-note", "Aggregating local usage metadata…");
|
|
321
491
|
emptyTable("usage-body", 7, "Loading…");
|
|
322
492
|
try {
|
|
323
|
-
const
|
|
493
|
+
const query = includeAnalytics ? `?analytics=1&time_zone=${encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone)}` : "";
|
|
494
|
+
const response = await fetch(`/_ccdx/ui/usage${query}`, { cache: "no-store", headers: { "X-CCDX-Dashboard": "1" }, signal: AbortSignal.timeout(15000) });
|
|
324
495
|
const data = await response.json();
|
|
325
496
|
if (!response.ok || data.source !== "local_usage_log") throw new Error(data.error || `HTTP ${response.status}`);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
for (const row of data.rows) body.append(tableRow(usageValues(row)));
|
|
331
|
-
} else {
|
|
332
|
-
emptyTable("usage-body", 7, "No usage records.");
|
|
333
|
-
}
|
|
334
|
-
text("usage-state", "LOCAL LOG");
|
|
335
|
-
text("usage-note", `${number(data.total.requests)} records / ${number(data.rows.length)} of ${number(data.model_count)} models shown`);
|
|
497
|
+
usageData = data;
|
|
498
|
+
if (data.analytics) updateAnalyticsModels();
|
|
499
|
+
renderUsageTable();
|
|
500
|
+
renderAnalytics();
|
|
336
501
|
} catch (error) {
|
|
337
502
|
text("usage-state", "LOG UNAVAILABLE");
|
|
338
503
|
text("usage-note", String(error.message || error).slice(0, 180));
|
|
339
504
|
emptyTable("usage-body", 7, "No usage summary available.");
|
|
505
|
+
if (includeAnalytics) text("analytics-note", "Analytics unavailable / refresh Usage to retry.");
|
|
340
506
|
} finally {
|
|
507
|
+
usageLoading = false;
|
|
341
508
|
button.disabled = false;
|
|
509
|
+
if (!includeAnalytics && element("usage-analytics").open) loadUsage();
|
|
342
510
|
}
|
|
343
511
|
}
|
|
344
512
|
|
|
345
513
|
element("save-animation").addEventListener("click", saveAnimation);
|
|
514
|
+
element("animation-settings").addEventListener("toggle", () => {
|
|
515
|
+
element("open-animation").setAttribute("aria-expanded", String(element("animation-settings").open));
|
|
516
|
+
if (element("animation-settings").open) loadAnimation();
|
|
517
|
+
syncAnimationPreview();
|
|
518
|
+
});
|
|
519
|
+
element("open-animation").addEventListener("click", () => {
|
|
520
|
+
const settings = element("animation-settings");
|
|
521
|
+
settings.open = true;
|
|
522
|
+
settings.scrollIntoView({ block: "center" });
|
|
523
|
+
settings.querySelector("summary").focus();
|
|
524
|
+
});
|
|
525
|
+
element("usage-analytics").addEventListener("toggle", () => {
|
|
526
|
+
if (element("usage-analytics").open && !usageData?.analytics) loadUsage();
|
|
527
|
+
});
|
|
528
|
+
for (const id of ["analytics-model", "analytics-range", "analytics-metric"]) element(id).addEventListener("change", () => {
|
|
529
|
+
renderAnalytics();
|
|
530
|
+
if (usageData && selectedUsageDate) renderUsageTable();
|
|
531
|
+
});
|
|
532
|
+
element("clear-usage-day").addEventListener("click", () => selectUsageDay(""));
|
|
346
533
|
element("refresh-models").addEventListener("click", loadModels);
|
|
347
534
|
element("refresh-usage").addEventListener("click", loadUsage);
|
|
348
535
|
loadAnimation();
|
package/src/dashboard-api.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
TERMINAL_ANIMATION_THEMES,
|
|
11
11
|
} from "./terminal-animation.mjs";
|
|
12
12
|
import { cacheReadTokens, summarizeUsageLogs, usageCacheHitRate } from "./usage.mjs";
|
|
13
|
+
import { createUsageAnalytics } from "./usage-analytics.mjs";
|
|
13
14
|
import { readUserSettings, terminalAnimationPreference, writeTerminalAnimationTheme } from "./user-settings.mjs";
|
|
14
15
|
|
|
15
16
|
const ANIMATION_PATH = "/_ccdx/ui/animation";
|
|
@@ -86,6 +87,7 @@ function usageRow(model, values) {
|
|
|
86
87
|
input_tokens: finite(values.input_tokens),
|
|
87
88
|
cache_read_tokens: cacheReadTokens(values) ?? null,
|
|
88
89
|
cache_hit_rate: usageCacheHitRate(values),
|
|
90
|
+
cache_hit_rate_partial: values.cache_hit_unknown_requests > 0,
|
|
89
91
|
output_tokens: finite(values.output_tokens),
|
|
90
92
|
total_tokens: finite(values.total_tokens),
|
|
91
93
|
};
|
|
@@ -106,7 +108,7 @@ export async function handleDashboardApi(req, res, pathname, {
|
|
|
106
108
|
env = process.env,
|
|
107
109
|
home = os.homedir(),
|
|
108
110
|
liveModelsFn = () => fetchLiveCopilotModels({ home }),
|
|
109
|
-
usageSummaryFn = () => summarizeUsageLogs(),
|
|
111
|
+
usageSummaryFn = (options) => summarizeUsageLogs(undefined, options),
|
|
110
112
|
} = {}) {
|
|
111
113
|
if (!isLoopbackAddress(req.socket?.remoteAddress)
|
|
112
114
|
|| req.headers?.host === undefined
|
|
@@ -121,7 +123,8 @@ export async function handleDashboardApi(req, res, pathname, {
|
|
|
121
123
|
|
|
122
124
|
if (pathname === ANIMATION_PATH && req.method === "GET") {
|
|
123
125
|
try {
|
|
124
|
-
|
|
126
|
+
const query = new URL(req.url, "http://localhost").searchParams;
|
|
127
|
+
sendJson(res, 200, animationState(env, home, { includeFrames: query.get("frames") !== "0" }));
|
|
125
128
|
} catch {
|
|
126
129
|
sendJson(res, 409, { error: "Animation settings are invalid; inspect them with ccdx animation" });
|
|
127
130
|
}
|
|
@@ -188,8 +191,21 @@ export async function handleDashboardApi(req, res, pathname, {
|
|
|
188
191
|
}
|
|
189
192
|
|
|
190
193
|
if (pathname === USAGE_PATH && req.method === "GET") {
|
|
194
|
+
const query = new URL(req.url, "http://localhost").searchParams;
|
|
195
|
+
let analytics = null;
|
|
196
|
+
if (query.get("analytics") === "1") {
|
|
197
|
+
try {
|
|
198
|
+
const timeZone = query.get("time_zone") || "UTC";
|
|
199
|
+
if (timeZone.length > 100) throw new Error("Invalid time zone");
|
|
200
|
+
analytics = createUsageAnalytics({ timeZone });
|
|
201
|
+
} catch {
|
|
202
|
+
sendJson(res, 400, { error: "Select a valid usage time zone" });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
191
206
|
try {
|
|
192
|
-
|
|
207
|
+
const summary = await usageSummaryFn(analytics ? { onRecord: analytics.record } : undefined);
|
|
208
|
+
sendJson(res, 200, { source: "local_usage_log", ...usageTable(summary), ...(analytics ? { analytics: analytics.snapshot() } : {}) });
|
|
193
209
|
} catch {
|
|
194
210
|
sendJson(res, 500, { error: "Could not read local usage summary" });
|
|
195
211
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { cacheReadTokens, usageCacheHitRate } from "./usage.mjs";
|
|
2
|
+
|
|
3
|
+
const DAY_MS = 86400000;
|
|
4
|
+
const DAYS = 365;
|
|
5
|
+
const MAX_MODELS = 100;
|
|
6
|
+
const TOKEN_FIELDS = ["input_tokens", "output_tokens", "total_tokens", "cached_input_tokens", "cache_read_input_tokens", "cache_hit_unknown_requests", "cache_hit_invalid_requests"];
|
|
7
|
+
|
|
8
|
+
export function createUsageAnalytics({ timeZone = "UTC", now = Date.now() } = {}) {
|
|
9
|
+
const formatter = new Intl.DateTimeFormat("sv-SE", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" });
|
|
10
|
+
const today = formatter.format(new Date(now));
|
|
11
|
+
const end = Date.parse(`${today}T00:00:00Z`);
|
|
12
|
+
const start = end - (DAYS - 1) * DAY_MS;
|
|
13
|
+
const from = new Date(start).toISOString().slice(0, 10);
|
|
14
|
+
const dates = new Map();
|
|
15
|
+
const models = new Set();
|
|
16
|
+
let first = Infinity;
|
|
17
|
+
let last = -Infinity;
|
|
18
|
+
let undated = 0;
|
|
19
|
+
let modelsTruncated = false;
|
|
20
|
+
|
|
21
|
+
function add(target, values) {
|
|
22
|
+
target.requests += 1;
|
|
23
|
+
for (const key of TOKEN_FIELDS) {
|
|
24
|
+
if (Number.isFinite(values[key]) && values[key] >= 0) target[key] = (target[key] || 0) + values[key];
|
|
25
|
+
}
|
|
26
|
+
if (!Number.isFinite(values.input_tokens) || values.input_tokens < 0 || !Number.isFinite(values.output_tokens) || values.output_tokens < 0) target.tokens_partial = true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function row(values = {}) {
|
|
30
|
+
return {
|
|
31
|
+
requests: values.requests || 0,
|
|
32
|
+
input_tokens: values.input_tokens || 0,
|
|
33
|
+
output_tokens: values.output_tokens || 0,
|
|
34
|
+
total_tokens: values.total_tokens ?? (values.input_tokens || 0) + (values.output_tokens || 0),
|
|
35
|
+
cache_read_tokens: cacheReadTokens(values) ?? null,
|
|
36
|
+
cache_hit_rate: usageCacheHitRate(values),
|
|
37
|
+
cache_hit_rate_partial: values.cache_hit_unknown_requests > 0,
|
|
38
|
+
tokens_partial: values.tokens_partial === true || values.cache_hit_invalid_requests > 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
record(record, summary) {
|
|
44
|
+
const timestamp = typeof record.ts === "string" ? Date.parse(record.ts) : NaN;
|
|
45
|
+
if (!Number.isFinite(timestamp) || timestamp > now) { undated += 1; return; }
|
|
46
|
+
first = Math.min(first, timestamp);
|
|
47
|
+
last = Math.max(last, timestamp);
|
|
48
|
+
if (timestamp < start - 2 * DAY_MS) return;
|
|
49
|
+
const date = formatter.format(new Date(timestamp));
|
|
50
|
+
if (date < from || date > today) return;
|
|
51
|
+
let point = dates.get(date);
|
|
52
|
+
if (!point) { point = { requests: 0, byModel: new Map() }; dates.set(date, point); }
|
|
53
|
+
add(point, summary.totals);
|
|
54
|
+
const model = String(record.model || "unknown").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 100);
|
|
55
|
+
if (!models.has(model) && models.size >= MAX_MODELS) { modelsTruncated = true; return; }
|
|
56
|
+
models.add(model);
|
|
57
|
+
if (!point.byModel.has(model)) point.byModel.set(model, { requests: 0 });
|
|
58
|
+
add(point.byModel.get(model), summary.totals);
|
|
59
|
+
},
|
|
60
|
+
snapshot() {
|
|
61
|
+
const firstDate = Number.isFinite(first) ? formatter.format(new Date(first)) : null;
|
|
62
|
+
const days = Array.from({ length: DAYS }, (_, index) => {
|
|
63
|
+
const date = new Date(start + index * DAY_MS).toISOString().slice(0, 10);
|
|
64
|
+
const point = dates.get(date);
|
|
65
|
+
return {
|
|
66
|
+
date,
|
|
67
|
+
available: firstDate !== null && date >= firstDate,
|
|
68
|
+
...row(point),
|
|
69
|
+
models: point ? [...point.byModel].map(([model, values]) => ({ model, ...row(values) })) : [],
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
return { time_zone: formatter.resolvedOptions().timeZone, from, to: today, first_date: firstDate,
|
|
73
|
+
last_date: Number.isFinite(last) ? formatter.format(new Date(last)) : null,
|
|
74
|
+
undated_records: undated, models_truncated: modelsTruncated, models: [...models].sort(), days };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/usage.mjs
CHANGED
|
@@ -277,15 +277,23 @@ export function summarizeUsage(records) {
|
|
|
277
277
|
summary.totals.cache_hit_unknown_requests = (summary.totals.cache_hit_unknown_requests || 0) + 1;
|
|
278
278
|
modelTotals.cache_hit_unknown_requests = (modelTotals.cache_hit_unknown_requests || 0) + 1;
|
|
279
279
|
}
|
|
280
|
+
const cacheCounts = [record.usage?.cached_input_tokens, record.usage?.cache_read_input_tokens];
|
|
281
|
+
if ((input != null && (!Number.isFinite(input) || input < 0))
|
|
282
|
+
|| cacheCounts.some((value) => value != null && (!Number.isFinite(value) || value < 0))
|
|
283
|
+
|| (Number.isFinite(input) && Number.isFinite(cached) && cached > input)) {
|
|
284
|
+
summary.totals.cache_hit_invalid_requests = (summary.totals.cache_hit_invalid_requests || 0) + 1;
|
|
285
|
+
modelTotals.cache_hit_invalid_requests = (modelTotals.cache_hit_invalid_requests || 0) + 1;
|
|
286
|
+
}
|
|
280
287
|
}
|
|
281
288
|
return summary;
|
|
282
289
|
}
|
|
283
290
|
|
|
284
|
-
export async function summarizeUsageLogs(filePath = usageLogPath(), { warn = console.error } = {}) {
|
|
291
|
+
export async function summarizeUsageLogs(filePath = usageLogPath(), { warn = console.error, onRecord } = {}) {
|
|
285
292
|
const summary = { requests: 0, totals: {}, byModel: {} };
|
|
286
293
|
for (const candidate of [rotatedFilePath(filePath), filePath]) {
|
|
287
294
|
for await (const record of iterateUsageRecords(candidate, { warn })) {
|
|
288
295
|
const one = summarizeUsage([record]);
|
|
296
|
+
onRecord?.(record, one);
|
|
289
297
|
summary.requests += one.requests;
|
|
290
298
|
addUsageTotals(summary.totals, one.totals);
|
|
291
299
|
for (const [model, values] of Object.entries(one.byModel)) {
|
|
@@ -315,14 +323,14 @@ export function cacheReadTokens(usage = {}) {
|
|
|
315
323
|
export function usageCacheHitRate(usage = {}) {
|
|
316
324
|
const input = usage.input_tokens;
|
|
317
325
|
const cached = cacheReadTokens(usage);
|
|
318
|
-
if (usage.
|
|
326
|
+
if (usage.cache_hit_invalid_requests > 0 || !Number.isFinite(input) || input <= 0
|
|
319
327
|
|| !Number.isFinite(cached) || cached < 0 || cached > input) return null;
|
|
320
328
|
return cached / input;
|
|
321
329
|
}
|
|
322
330
|
|
|
323
331
|
function cacheHitPercent(usage) {
|
|
324
332
|
const rate = usageCacheHitRate(usage);
|
|
325
|
-
return rate === null ? "—" : `${(rate * 100).toFixed(1)}%`;
|
|
333
|
+
return rate === null ? "—" : `${usage.cache_hit_unknown_requests > 0 ? "~" : ""}${(rate * 100).toFixed(1)}%`;
|
|
326
334
|
}
|
|
327
335
|
|
|
328
336
|
function formatPlainUsageSummary(summary, filePath, { sanitize = false } = {}) {
|