@xamukavila/pxpipe 0.8.0
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/LICENSE +21 -0
- package/README.md +312 -0
- package/bin/cli.js +7 -0
- package/dist/core/applicability.d.ts +31 -0
- package/dist/core/applicability.js +96 -0
- package/dist/core/atlas-gray.d.ts +26 -0
- package/dist/core/atlas-gray.js +64 -0
- package/dist/core/atlas.d.ts +34 -0
- package/dist/core/atlas.js +71 -0
- package/dist/core/baseline.d.ts +80 -0
- package/dist/core/baseline.js +101 -0
- package/dist/core/caveman.d.ts +38 -0
- package/dist/core/caveman.js +183 -0
- package/dist/core/export.d.ts +128 -0
- package/dist/core/export.js +390 -0
- package/dist/core/factsheet.d.ts +78 -0
- package/dist/core/factsheet.js +216 -0
- package/dist/core/gpt-model-profiles.d.ts +60 -0
- package/dist/core/gpt-model-profiles.js +161 -0
- package/dist/core/history.d.ts +141 -0
- package/dist/core/history.js +553 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.js +8 -0
- package/dist/core/library.d.ts +74 -0
- package/dist/core/library.js +133 -0
- package/dist/core/measurement.d.ts +22 -0
- package/dist/core/measurement.js +213 -0
- package/dist/core/openai-history.d.ts +124 -0
- package/dist/core/openai-history.js +494 -0
- package/dist/core/openai-savings.d.ts +44 -0
- package/dist/core/openai-savings.js +75 -0
- package/dist/core/openai.d.ts +24 -0
- package/dist/core/openai.js +839 -0
- package/dist/core/png.d.ts +11 -0
- package/dist/core/png.js +132 -0
- package/dist/core/proxy.d.ts +81 -0
- package/dist/core/proxy.js +730 -0
- package/dist/core/render.d.ts +188 -0
- package/dist/core/render.js +785 -0
- package/dist/core/schema-strip.d.ts +29 -0
- package/dist/core/schema-strip.js +160 -0
- package/dist/core/tracker.d.ts +154 -0
- package/dist/core/tracker.js +216 -0
- package/dist/core/transform.d.ts +362 -0
- package/dist/core/transform.js +1828 -0
- package/dist/core/types.d.ts +77 -0
- package/dist/core/types.js +8 -0
- package/dist/dashboard/fragments.d.ts +36 -0
- package/dist/dashboard/fragments.js +938 -0
- package/dist/dashboard/types.d.ts +154 -0
- package/dist/dashboard/types.js +3 -0
- package/dist/dashboard/vendor.d.ts +3 -0
- package/dist/dashboard/vendor.js +6 -0
- package/dist/dashboard.d.ts +245 -0
- package/dist/dashboard.js +1140 -0
- package/dist/export-collect.d.ts +36 -0
- package/dist/export-collect.js +59 -0
- package/dist/node.d.ts +9 -0
- package/dist/node.js +9038 -0
- package/dist/sessions.d.ts +172 -0
- package/dist/sessions.js +510 -0
- package/dist/stats.d.ts +74 -0
- package/dist/stats.js +248 -0
- package/dist/worker.d.ts +53 -0
- package/dist/worker.js +102 -0
- package/package.json +96 -0
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live dashboard for the Node host. Serves the main HTML page and JSON
|
|
3
|
+
* polling endpoints. All "/api/*.json" endpoints recompute from disk on
|
|
4
|
+
* every request — pxpipe doesn't have a query layer, but a 1.5 MB JSONL
|
|
5
|
+
* streams in well under 100 ms.
|
|
6
|
+
*
|
|
7
|
+
* Legacy live-poll endpoints (left in place, the existing tick() loop uses
|
|
8
|
+
* them):
|
|
9
|
+
*
|
|
10
|
+
* GET /, /dashboard → main HTML page
|
|
11
|
+
* GET /proxy-stats → JSON aggregate over the in-mem ring
|
|
12
|
+
* GET /proxy-recent → JSON ring buffer of recent requests
|
|
13
|
+
* GET /proxy-latest-png[?crop=N] → raw PNG of the latest rendered image
|
|
14
|
+
*
|
|
15
|
+
* Session endpoints (read-only telemetry — no destructive operations):
|
|
16
|
+
*
|
|
17
|
+
* GET /api/sessions.json → grouped sessions (sha8 + project + counts)
|
|
18
|
+
* GET /api/stats.json → full-history aggregate (formerly `pxpipe stats`)
|
|
19
|
+
*
|
|
20
|
+
* Metric formulas and HTML shell originally ported from the Python reference
|
|
21
|
+
* implementation (deleted after live cache-rate validation hit 98.7% by tokens).
|
|
22
|
+
*
|
|
23
|
+
* Node-only by design. Workers host has no dashboard; use Workers Logs.
|
|
24
|
+
*
|
|
25
|
+
* Memory bound: ring buffer cap 50 events + a parallel ring of the last 50
|
|
26
|
+
* rendered PNGs (images are never persisted to disk, so this ring is the
|
|
27
|
+
* only place to view them). At a typical 75 KB PNG that's ~3-4 MB resident;
|
|
28
|
+
* a process restart starts the image ring empty.
|
|
29
|
+
*/
|
|
30
|
+
import * as fs from 'node:fs';
|
|
31
|
+
import * as readline from 'node:readline';
|
|
32
|
+
import { computeActualInputEff, computeBaselineInputEff, deriveBaselineWarmth, } from './core/baseline.js';
|
|
33
|
+
import { computeOpenAIActualInputEff, computeOpenAIBaselineInputEff, computeOpenAIBaselineRawTokens, openAIOutputRate, } from './core/openai-savings.js';
|
|
34
|
+
import { aggregateSessions, claudeCodeMap, filterSessions, } from './sessions.js';
|
|
35
|
+
import { aggregateEventsFile, summaryToJson } from './stats.js';
|
|
36
|
+
// Server-rendered UI (htmx + Alpine, vendored). No client bundle - the
|
|
37
|
+
// fragments module renders finished HTML from the same payloads the JSON
|
|
38
|
+
// endpoints serve.
|
|
39
|
+
import { renderPage, renderToggleFragment, renderModelsFragment, renderContextMapFragment, renderSessionSummaryFragment, renderHeaderFragment, renderRecentFragment, renderLatestFragment, renderSessionsFragment, renderStatsTableFragment, } from './dashboard/fragments.js';
|
|
40
|
+
import { getAllowedModelBases, getConfiguredModelBases, setAllowedModelBases, } from './core/applicability.js';
|
|
41
|
+
const RECENT_CAP = 50;
|
|
42
|
+
/** How many rendered PNGs to keep in the in-memory image ring. Matches
|
|
43
|
+
* RECENT_CAP so every visible recent-requests row can still resolve its
|
|
44
|
+
* image. Images are never written to disk — this ring is the only store. */
|
|
45
|
+
const IMAGE_RING_CAP = 800;
|
|
46
|
+
/*
|
|
47
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
48
|
+
* PROVENANCE — every magic number below should trace to one of these:
|
|
49
|
+
*
|
|
50
|
+
* [docs-pricing] docs.anthropic.com/en/docs/about-claude/pricing
|
|
51
|
+
* Verified 2026-05-19 via WebFetch. The page lists per-model
|
|
52
|
+
* per-million-token rates and the cache-tier multipliers.
|
|
53
|
+
*
|
|
54
|
+
* [count_tokens] docs.anthropic.com/en/api/messages-count-tokens
|
|
55
|
+
* The dashboard's baseline number comes from a free side
|
|
56
|
+
* call to /v1/messages/count_tokens on the PRE-COMPRESSION
|
|
57
|
+
* body. No estimation, no α, no regression.
|
|
58
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
59
|
+
*/
|
|
60
|
+
/** Output-token rate multiplier (referenced to the input base rate).
|
|
61
|
+
* Source: [docs-pricing] — Opus 4.7 lists $5/Mtok input and $25/Mtok
|
|
62
|
+
* output (5×); Sonnet 4.7 lists $3/Mtok input and $15/Mtok output (5×).
|
|
63
|
+
* Same ratio holds on Haiku 4.5 ($1/$5). */
|
|
64
|
+
const OUTPUT_TOKEN_RATE = 5.0;
|
|
65
|
+
/** Per-million-token input rate ASSUMED for the headline dollar figure.
|
|
66
|
+
* Source: https://docs.claude.com/en/docs/about-claude/pricing — Opus 4.7
|
|
67
|
+
* input is $5/Mtok (same as Opus 4.5 / 4.6; the previous "$2.50" value
|
|
68
|
+
* here was a regression). Cache-write 5m = $6.25/Mtok (1.25×),
|
|
69
|
+
* cache-read = $0.50/Mtok (0.10×).
|
|
70
|
+
*
|
|
71
|
+
* This is exposed on /proxy-stats as `pricing_assumptions.input_per_mtok`
|
|
72
|
+
* so the operator can see what we assumed and override if they're
|
|
73
|
+
* running against a non-default deployment (Bedrock/Vertex add a 10%
|
|
74
|
+
* premium; Sonnet would be $3/Mtok, etc.).
|
|
75
|
+
*
|
|
76
|
+
* NOTE: Opus 4.7 uses a different tokenizer than 4.5/4.6 (per
|
|
77
|
+
* docs.claude.com/en/docs/about-claude/pricing), so even at the same
|
|
78
|
+
* $/Mtok rate, the same string maps to a different token count. The
|
|
79
|
+
* honest oracle for "tokens in this body" is `count_tokens` against the
|
|
80
|
+
* actual target model; do not trust hardcoded chars-per-token or
|
|
81
|
+
* tokens-per-image constants on 4.7 without verifying against the
|
|
82
|
+
* upstream probe. */
|
|
83
|
+
// 2026-06-09: gate is Fable-5-only and Fable 5 bills $10/MTok input,
|
|
84
|
+
// so the dashboard dollar figure uses that rate. Output tokens are
|
|
85
|
+
// excluded entirely (the proxy can't move them), so this still
|
|
86
|
+
// understates the real bill - treat it as "input-side $ saved".
|
|
87
|
+
export const ASSUMED_INPUT_USD_PER_MTOK = 10.0;
|
|
88
|
+
/** Route per-event accounting by upstream. OpenAI paths use the GPT cost
|
|
89
|
+
* model (vision-token imaging, automatic 0.1× prefix cache, no count_tokens
|
|
90
|
+
* probe, 8× output); everything else uses the Anthropic cache-aware baseline.
|
|
91
|
+
* Anthropic paths are `/v1/messages[/count_tokens]`; neither word appears. */
|
|
92
|
+
function isOpenAIEvent(path) {
|
|
93
|
+
if (!path)
|
|
94
|
+
return false;
|
|
95
|
+
return path.includes('responses') || path.includes('chat/completions');
|
|
96
|
+
}
|
|
97
|
+
/** Cache-aware eff bundle for one GPT event. Shared by the live `update()`
|
|
98
|
+
* and `replay()` paths so both read identical per-row numbers. Pure: takes
|
|
99
|
+
* plain scalars (replay has no Usage/TransformInfo objects, only JSONL fields).
|
|
100
|
+
*
|
|
101
|
+
* GPT differs from Anthropic on every axis: input_tokens already INCLUDES the
|
|
102
|
+
* cached subset (`cachedTokens`), there is no cache-create premium, the cached
|
|
103
|
+
* prefix reads at ~0.1×, and the baseline is the measured `baselineImagedTokens`
|
|
104
|
+
* (o200k text-token cost of the imaged content) vs the vision-token `imageTokens`
|
|
105
|
+
* pxpipe actually paid — not a count_tokens probe. No per-session warmth state:
|
|
106
|
+
* OpenAI caching is automatic/prefix-based and the discount is already folded
|
|
107
|
+
* into the cached-input rate. See src/core/openai-savings.ts. */
|
|
108
|
+
function gptEff(args) {
|
|
109
|
+
const { model, inputTokens: inp, outputTokens: out, cachedTokens: cached } = args;
|
|
110
|
+
const { imageTokens, baselineImagedTokens, compressed } = args;
|
|
111
|
+
const haveUsage = inp > 0 || out > 0;
|
|
112
|
+
// The transform measured what the imaged content would have cost as o200k
|
|
113
|
+
// text; without it there is no counterfactual to credit.
|
|
114
|
+
const haveBaseline = baselineImagedTokens > 0;
|
|
115
|
+
const actualInputEff = haveUsage ? computeOpenAIActualInputEff(inp, cached, model) : 0;
|
|
116
|
+
const creditSaving = haveBaseline && haveUsage && compressed;
|
|
117
|
+
const baselineInputEff = creditSaving
|
|
118
|
+
? computeOpenAIBaselineInputEff(inp, cached, imageTokens, baselineImagedTokens, model)
|
|
119
|
+
: actualInputEff;
|
|
120
|
+
const outputEquiv = haveUsage ? out * openAIOutputRate(model) : 0;
|
|
121
|
+
// Raw, rate-free token counts for the session's compression ratio and the
|
|
122
|
+
// Details panel: actual = what we sent; baseline = the text-only equivalent.
|
|
123
|
+
const rawActual = inp;
|
|
124
|
+
const rawBaseline = computeOpenAIBaselineRawTokens(inp, imageTokens, baselineImagedTokens);
|
|
125
|
+
return {
|
|
126
|
+
haveUsage,
|
|
127
|
+
haveBaseline,
|
|
128
|
+
creditSaving,
|
|
129
|
+
actualInputEff,
|
|
130
|
+
baselineInputEff,
|
|
131
|
+
outputEquiv,
|
|
132
|
+
rawActual,
|
|
133
|
+
rawBaseline,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export class DashboardState {
|
|
137
|
+
recent = [];
|
|
138
|
+
/** Per-session dollar-weighted totals, keyed by `info.firstUserSha8`. The
|
|
139
|
+
* dashboard surfaces ONLY the most-recently-active session via the
|
|
140
|
+
* `serveCurrentSessionJson` endpoint — older sessions linger in the Map
|
|
141
|
+
* so a tab refresh during a brief lull still finds the previous session,
|
|
142
|
+
* but get evicted at `SESSION_CAP` to bound memory in long-running hosts. */
|
|
143
|
+
sessions = new Map();
|
|
144
|
+
/** sha8 of the most-recently-active session id. null when no events have
|
|
145
|
+
* ever carried a `firstUserSha8` (e.g. a cold start with only passthrough
|
|
146
|
+
* hits that the upstream probe never tagged). */
|
|
147
|
+
currentSessionId = null;
|
|
148
|
+
/** Per-session prior prefix size for the cache-aware TEXT baseline. Warm/cold
|
|
149
|
+
* comes only from the server-observed cache_read on the actual request; this
|
|
150
|
+
* map is used only after cr>0 to split the text counterfactual into reused vs
|
|
151
|
+
* grown prefix tokens. Reconstructed identically in replay() from persisted
|
|
152
|
+
* timestamps, so live and restored numbers agree. Capped with sessions. */
|
|
153
|
+
baselineWarmth = new Map();
|
|
154
|
+
/** Max age for reusing a prior prefix size after cr>0 has proved warmth. */
|
|
155
|
+
static CACHE_TTL_SEC = 300;
|
|
156
|
+
/** Hard cap on `sessions` Map entries. Keeps memory bounded in
|
|
157
|
+
* long-running deployments. 50 sessions × ~13 numeric fields each is
|
|
158
|
+
* comfortably under a MB even with fat bucket/passthrough histograms. */
|
|
159
|
+
static SESSION_CAP = 50;
|
|
160
|
+
totals = {
|
|
161
|
+
requests: 0,
|
|
162
|
+
compressedRequests: 0,
|
|
163
|
+
actualInputWeighted: 0,
|
|
164
|
+
baselineInputWeighted: 0,
|
|
165
|
+
outputWeighted: 0,
|
|
166
|
+
allBaselineEquivalentWeighted: 0,
|
|
167
|
+
allActualInputWeighted: 0,
|
|
168
|
+
allOutputWeighted: 0,
|
|
169
|
+
allUsageRequests: 0,
|
|
170
|
+
compressedPaidRequests: 0,
|
|
171
|
+
compressedActualInputWeighted: 0,
|
|
172
|
+
compressedOutputWeighted: 0,
|
|
173
|
+
passthroughPaidRequests: 0,
|
|
174
|
+
passthroughActualInputWeighted: 0,
|
|
175
|
+
passthroughOutputWeighted: 0,
|
|
176
|
+
textCharsMeasured: 0,
|
|
177
|
+
thinkingCharsMeasured: 0,
|
|
178
|
+
toolUseCharsMeasured: 0,
|
|
179
|
+
redactedBlockCountMeasured: 0,
|
|
180
|
+
eventsWithMeasurement: 0,
|
|
181
|
+
startedAt: Date.now() / 1000,
|
|
182
|
+
};
|
|
183
|
+
/** Bounded ring of the most recently rendered images (last IMAGE_RING_CAP).
|
|
184
|
+
* Each request that rendered an image pushes one entry; the matching
|
|
185
|
+
* RecentRow carries `img_id` so the dashboard can pull any image still in
|
|
186
|
+
* the ring via /proxy-latest-png?id=N. In-memory only — images are never
|
|
187
|
+
* persisted, so a restart starts this empty. */
|
|
188
|
+
images = [];
|
|
189
|
+
/** Monotonic image id source. Never reset, never reused — an evicted id
|
|
190
|
+
* stays dangling on its RecentRow rather than pointing at a new image. */
|
|
191
|
+
nextImageId = 1;
|
|
192
|
+
/** Runtime kill switch for compression. When false, the proxy forwards
|
|
193
|
+
* /v1/messages unchanged to upstream — pure passthrough, no images,
|
|
194
|
+
* no transforms. Controlled by the dashboard "passthrough" toggle so
|
|
195
|
+
* the operator can toggle the proxy's transform instantly.
|
|
196
|
+
*
|
|
197
|
+
* Defaults to TRUE since 2026-06-09: scope is Fable 5 only, which reads
|
|
198
|
+
* renders at 100/100 (no Opus read tax) with the same image billing, and
|
|
199
|
+
* the live proxy record measured ~68% real input-token savings on dense
|
|
200
|
+
* traffic — the old off-default rationale ("cache-illusory savings")
|
|
201
|
+
* cited the superseded dead verdict. Verbatim recall is still lossy;
|
|
202
|
+
* the dashboard toggle remains the kill switch. See FINDINGS.md. */
|
|
203
|
+
compressionEnabled = true;
|
|
204
|
+
/** Recent requests' transform breakdowns, for the Context Map panel + its
|
|
205
|
+
* history selector. In-memory ring, newest last. */
|
|
206
|
+
contextHistory = [];
|
|
207
|
+
setCompressionEnabled(on) {
|
|
208
|
+
this.compressionEnabled = on;
|
|
209
|
+
}
|
|
210
|
+
getCompressionEnabled() {
|
|
211
|
+
return this.compressionEnabled;
|
|
212
|
+
}
|
|
213
|
+
/** Resolved disk paths for the events.jsonl + 4xx-bodies sidecar dir. The
|
|
214
|
+
* new sessions / cleanup endpoints need this; legacy callers that don't
|
|
215
|
+
* pass `paths` opt out of those endpoints by returning 503. */
|
|
216
|
+
paths;
|
|
217
|
+
/** Test hook: when set, /api/sessions.json and /api/sessions/${id}.json
|
|
218
|
+
* call this instead of `claudeCodeMap()` with the real `~/.claude/projects/`
|
|
219
|
+
* path. Lets unit tests run in tens of ms instead of scanning hundreds of
|
|
220
|
+
* the developer's actual Claude Code session files. */
|
|
221
|
+
ccMapFn;
|
|
222
|
+
constructor(paths, ccMapFn) {
|
|
223
|
+
this.paths = paths;
|
|
224
|
+
this.ccMapFn = ccMapFn ?? (() => claudeCodeMap());
|
|
225
|
+
}
|
|
226
|
+
/** Stash every rendered image into the ring (called from onRequest with the
|
|
227
|
+
* raw ProxyEvent before info.firstImagePng is dropped by toTrackEvent).
|
|
228
|
+
* Returns the assigned image ids in render order; empty array when there
|
|
229
|
+
* are no images. The caller stamps ids[0] onto the RecentRow as `img_id`
|
|
230
|
+
* for back-compat and the full list as `img_ids`. */
|
|
231
|
+
captureImage(info) {
|
|
232
|
+
const pngs = info.imagePngs ?? (info.firstImagePng ? [info.firstImagePng] : []);
|
|
233
|
+
if (pngs.length === 0)
|
|
234
|
+
return [];
|
|
235
|
+
const dims = info.imageDims ??
|
|
236
|
+
(info.firstImagePng
|
|
237
|
+
? [{ width: info.firstImageWidth ?? 0, height: info.firstImageHeight ?? 0 }]
|
|
238
|
+
: []);
|
|
239
|
+
const ids = [];
|
|
240
|
+
for (let i = 0; i < pngs.length; i++) {
|
|
241
|
+
const id = this.nextImageId++;
|
|
242
|
+
const width = dims[i]?.width ?? 0;
|
|
243
|
+
const height = dims[i]?.height ?? 0;
|
|
244
|
+
const kb = (pngs[i].length / 1024).toFixed(1);
|
|
245
|
+
const meta = `${width}×${height} · ${kb} KB · image ${i + 1}/${pngs.length}`;
|
|
246
|
+
this.images.push({
|
|
247
|
+
id,
|
|
248
|
+
png: pngs[i],
|
|
249
|
+
meta,
|
|
250
|
+
width,
|
|
251
|
+
height,
|
|
252
|
+
ts: Date.now() / 1000,
|
|
253
|
+
sourceText: info.imageSourceText,
|
|
254
|
+
});
|
|
255
|
+
ids.push(id);
|
|
256
|
+
}
|
|
257
|
+
// Evict the oldest entries past the cap. splice() keeps insertion order
|
|
258
|
+
// so images[images.length - 1] is always the latest render.
|
|
259
|
+
if (this.images.length > IMAGE_RING_CAP) {
|
|
260
|
+
this.images.splice(0, this.images.length - IMAGE_RING_CAP);
|
|
261
|
+
}
|
|
262
|
+
return ids;
|
|
263
|
+
}
|
|
264
|
+
/** Fold one event into the running totals + ring buffer.
|
|
265
|
+
*
|
|
266
|
+
* Savings math is gated on a per-request `baseline_tokens` measurement
|
|
267
|
+
* from the parallel count_tokens probe AND an upstream usage block.
|
|
268
|
+
* When either is missing, we still count the request but skip its
|
|
269
|
+
* savings contribution — no estimation. */
|
|
270
|
+
update(ev) {
|
|
271
|
+
// Stash the image bytes before they get GC'd by the request finishing.
|
|
272
|
+
// The returned id (if any) is stamped onto this request's RecentRow so
|
|
273
|
+
// the dashboard can pull the exact image that request rendered.
|
|
274
|
+
const imgIds = ev.info ? this.captureImage(ev.info) : [];
|
|
275
|
+
const imgId = imgIds[0];
|
|
276
|
+
const u = ev.usage;
|
|
277
|
+
const info = ev.info;
|
|
278
|
+
const compressed = info?.compressed === true;
|
|
279
|
+
const inp = u?.input_tokens ?? 0;
|
|
280
|
+
const out = u?.output_tokens ?? 0;
|
|
281
|
+
const cc = u?.cache_creation_input_tokens ?? 0;
|
|
282
|
+
const cr = u?.cache_read_input_tokens ?? 0;
|
|
283
|
+
const gpt = isOpenAIEvent(ev.path);
|
|
284
|
+
// Unified per-row accounting, filled by the provider branch below. The
|
|
285
|
+
// downstream totals / per-session / recent-row code reads only these —
|
|
286
|
+
// it never re-derives cache math, so Anthropic and GPT can't drift.
|
|
287
|
+
let haveUsage;
|
|
288
|
+
let haveBaseline;
|
|
289
|
+
let creditSaving;
|
|
290
|
+
let actualInputEff;
|
|
291
|
+
let baselineInputEff;
|
|
292
|
+
let outputEquiv;
|
|
293
|
+
let rawActual; // raw tokens sent (session ratio + Details realInput)
|
|
294
|
+
let rawBaseline; // raw text-only counterfactual tokens
|
|
295
|
+
let baselineForRow; // baseline token count for contextHistory/recent
|
|
296
|
+
let cacheReadForRow; // tokens to surface in the "Cache hits" column
|
|
297
|
+
let warmForRow; // did the TEXT baseline read warm? Server-observed:
|
|
298
|
+
// Anthropic cr>0 or GPT cached_tokens>0. Drives Context Map narration.
|
|
299
|
+
if (gpt) {
|
|
300
|
+
// GPT cost model: no count_tokens probe, no cache-create premium, no
|
|
301
|
+
// per-session warmth — the discount is automatic and folded into the
|
|
302
|
+
// cached-input rate. Baseline is the measured imaged-vs-text delta.
|
|
303
|
+
const e = gptEff({
|
|
304
|
+
model: ev.model,
|
|
305
|
+
inputTokens: inp,
|
|
306
|
+
outputTokens: out,
|
|
307
|
+
cachedTokens: u?.cached_tokens ?? 0,
|
|
308
|
+
imageTokens: info?.imageTokens ?? 0,
|
|
309
|
+
baselineImagedTokens: info?.baselineImagedTokens ?? 0,
|
|
310
|
+
compressed,
|
|
311
|
+
});
|
|
312
|
+
haveUsage = e.haveUsage;
|
|
313
|
+
haveBaseline = e.haveBaseline;
|
|
314
|
+
creditSaving = e.creditSaving;
|
|
315
|
+
actualInputEff = e.actualInputEff;
|
|
316
|
+
baselineInputEff = e.baselineInputEff;
|
|
317
|
+
outputEquiv = e.outputEquiv;
|
|
318
|
+
rawActual = e.rawActual;
|
|
319
|
+
rawBaseline = e.rawBaseline;
|
|
320
|
+
baselineForRow = e.rawBaseline;
|
|
321
|
+
cacheReadForRow = u?.cached_tokens ?? 0;
|
|
322
|
+
// GPT's prefix discount is automatic: cached_tokens>0 ⇒ it read warm.
|
|
323
|
+
warmForRow = (u?.cached_tokens ?? 0) > 0;
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
haveUsage = u !== undefined && (inp > 0 || out > 0 || cc > 0 || cr > 0);
|
|
327
|
+
const baseline = info?.baselineTokens;
|
|
328
|
+
// Honest gating: only attribute savings when BOTH baseline probes
|
|
329
|
+
// resolved (status === 'ok'). When the cacheable-prefix probe failed
|
|
330
|
+
// (status === 'partial') we previously fell through to cacheable=0,
|
|
331
|
+
// which silently charges the unproxied counterfactual the cold-input
|
|
332
|
+
// rate on tokens that actually would have been cache-discounted —
|
|
333
|
+
// fabricating "$ saved". Excluding the row is the only honest move
|
|
334
|
+
// until the probe succeeds.
|
|
335
|
+
const probeOk = info?.baselineProbeStatus === 'ok'
|
|
336
|
+
// Back-compat: hosts that haven't adopted baselineProbeStatus yet
|
|
337
|
+
// still see fields land; we accept legacy rows where the full-body
|
|
338
|
+
// probe resolved AND (either no markers existed OR cacheable did too).
|
|
339
|
+
|| (info?.baselineProbeStatus === undefined && baseline !== undefined && baseline > 0);
|
|
340
|
+
haveBaseline = typeof baseline === 'number' && baseline > 0 && probeOk;
|
|
341
|
+
// Weighted INPUT cost we actually paid this turn.
|
|
342
|
+
actualInputEff = haveUsage ? computeActualInputEff(inp, cc, cr) : 0;
|
|
343
|
+
// pxpipe only reduces input by imaging the static slab. An UNCOMPRESSED
|
|
344
|
+
// row had its body forwarded untouched, so its unproxied counterfactual
|
|
345
|
+
// IS exactly what it paid — crediting the cache-modeled baseline there
|
|
346
|
+
// (which prices the prefix at the cache-READ rate) fabricates savings on
|
|
347
|
+
// passthrough traffic. Only credit the counterfactual when the row was
|
|
348
|
+
// actually compressed AND we have a usable probe.
|
|
349
|
+
creditSaving = haveBaseline && haveUsage && compressed;
|
|
350
|
+
// Cache-aware, server-observed baseline. INVARIANT: pxpipe is credited ONLY
|
|
351
|
+
// for the text it imaged away — NEVER for caching. The imagined text path
|
|
352
|
+
// gets the same observed cache state as the actual request: cr>0 means warm
|
|
353
|
+
// for both, cr===0 means cold for both. No wall-clock-only inference.
|
|
354
|
+
// Uncompressed rows fall back to actualInputEff → zero savings.
|
|
355
|
+
const cacheable = info?.baselineCacheableTokens ?? 0;
|
|
356
|
+
// If cr>0 proved warmth, a completed prior with the same prefix refines the
|
|
357
|
+
// reused/grown split for the text baseline. Use request start for that
|
|
358
|
+
// lookup; an overlapping request that had not completed could not provide a
|
|
359
|
+
// prior prefix size for this in-flight request.
|
|
360
|
+
const sidNow = info?.firstUserSha8;
|
|
361
|
+
const prefixShaNow = info?.systemSha8;
|
|
362
|
+
const completionSec = Date.now() / 1000;
|
|
363
|
+
const requestStartSec = completionSec - Math.max(0, ev.durationMs || 0) / 1000;
|
|
364
|
+
const warmthPrev = typeof sidNow === 'string' && sidNow.length > 0
|
|
365
|
+
? this.baselineWarmth.get(sidNow)
|
|
366
|
+
: undefined;
|
|
367
|
+
// Warmth itself is cr-only; prior state only estimates the warm split.
|
|
368
|
+
// Centralised in deriveBaselineWarmth so update()/replay()/sessions can't drift.
|
|
369
|
+
const { warm, prevCacheable } = deriveBaselineWarmth(warmthPrev, requestStartSec, cacheable, cr, DashboardState.CACHE_TTL_SEC, prefixShaNow);
|
|
370
|
+
baselineInputEff = creditSaving
|
|
371
|
+
? computeBaselineInputEff(baseline, cacheable, inp, cc, cr, warm, prevCacheable)
|
|
372
|
+
: actualInputEff;
|
|
373
|
+
// Record this completed turn's prefix size for future cr>0 split estimates.
|
|
374
|
+
// Carry the prior cacheable when this row has no probe.
|
|
375
|
+
if (typeof sidNow === 'string' && sidNow.length > 0 && haveUsage) {
|
|
376
|
+
this.baselineWarmth.set(sidNow, {
|
|
377
|
+
ts: completionSec,
|
|
378
|
+
cacheable: cacheable > 0 ? cacheable : (warmthPrev?.cacheable ?? 0),
|
|
379
|
+
prefixSha: prefixShaNow ?? warmthPrev?.prefixSha,
|
|
380
|
+
});
|
|
381
|
+
if (this.baselineWarmth.size > DashboardState.SESSION_CAP) {
|
|
382
|
+
const firstKey = this.baselineWarmth.keys().next().value;
|
|
383
|
+
if (firstKey !== undefined)
|
|
384
|
+
this.baselineWarmth.delete(firstKey);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// Output tokens are identical with/without compression — the proxy never
|
|
388
|
+
// touches the response body. They show up on BOTH sides of the savings
|
|
389
|
+
// ratio at their actual rate (OUTPUT_TOKEN_RATE × input rate) so the
|
|
390
|
+
// denominator reflects the full bill the user actually pays. Without
|
|
391
|
+
// this, an output-heavy turn would silently inflate the "saved %"
|
|
392
|
+
// headline relative to what Anthropic's weekly limit meters as token
|
|
393
|
+
// consumption (input + output × 5).
|
|
394
|
+
outputEquiv = haveUsage ? out * OUTPUT_TOKEN_RATE : 0;
|
|
395
|
+
rawActual = inp + cc + cr;
|
|
396
|
+
rawBaseline = baseline ?? 0;
|
|
397
|
+
baselineForRow = baseline ?? 0;
|
|
398
|
+
cacheReadForRow = cr;
|
|
399
|
+
warmForRow = warm; // server-observed cache read (cr>0)
|
|
400
|
+
}
|
|
401
|
+
// Record the request's transform breakdown for the Context Map panel. This
|
|
402
|
+
// runs AFTER the eff-tokens are computed so the Details headline reads the
|
|
403
|
+
// SAME cache-weighted pair as the recent row's As-text / Sent / Saved
|
|
404
|
+
// columns (baselineInputEff / actualInputEff) — the two panels can no longer
|
|
405
|
+
// disagree. Raw counts are kept for the cache-blind sub-line. Gate on
|
|
406
|
+
// haveUsage so an in-flight request doesn't render a bogus "-100%".
|
|
407
|
+
if (info && haveUsage && imgId !== undefined) {
|
|
408
|
+
// Key by the request's first image id so the recent table's "view" link
|
|
409
|
+
// (which carries that id) maps straight to this breakdown.
|
|
410
|
+
this.contextHistory.push({
|
|
411
|
+
id: imgId,
|
|
412
|
+
baselineTokens: baselineForRow,
|
|
413
|
+
realInput: rawActual,
|
|
414
|
+
baselineInputEff,
|
|
415
|
+
actualInputEff,
|
|
416
|
+
haveBaseline,
|
|
417
|
+
cacheRead: cacheReadForRow,
|
|
418
|
+
warm: warmForRow,
|
|
419
|
+
output: out,
|
|
420
|
+
imageCount: info.imageCount ?? 0,
|
|
421
|
+
buckets: { ...(info.bucketChars ?? {}) },
|
|
422
|
+
imageIds: [...imgIds],
|
|
423
|
+
compressed,
|
|
424
|
+
});
|
|
425
|
+
// Keep in lockstep with RECENT_CAP so every "view" link in the recent
|
|
426
|
+
// table resolves to a real breakdown (was 30 < 50, so older visible rows
|
|
427
|
+
// silently fell back to the latest request's data).
|
|
428
|
+
if (this.contextHistory.length > RECENT_CAP) {
|
|
429
|
+
this.contextHistory.splice(0, this.contextHistory.length - RECENT_CAP);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
this.totals.requests += 1;
|
|
433
|
+
if (compressed)
|
|
434
|
+
this.totals.compressedRequests += 1;
|
|
435
|
+
// Measured headline: only compressed rows with a usable probe. An
|
|
436
|
+
// uncompressed row contributes zero saved (baseline === actual), so
|
|
437
|
+
// including it here would only dilute the "saved on rows we moved" %.
|
|
438
|
+
if (creditSaving) {
|
|
439
|
+
this.totals.baselineInputWeighted += baselineInputEff;
|
|
440
|
+
this.totals.actualInputWeighted += actualInputEff;
|
|
441
|
+
this.totals.outputWeighted += outputEquiv;
|
|
442
|
+
}
|
|
443
|
+
// All-rows COUNTERFACTUAL spend, ungated on the probe — the honest
|
|
444
|
+
// denominator for "did pxpipe move my real bill". Measured rows
|
|
445
|
+
// contribute their cache-aware baseline (what the unproxied path
|
|
446
|
+
// would have billed); unmeasured/probe-failed/passthrough rows
|
|
447
|
+
// contribute their actual input (pxpipe either didn't run or we
|
|
448
|
+
// can't measure the counterfactual, so actual ≈ baseline). This
|
|
449
|
+
// keeps the ratio bounded at 100% — you can't save more than you
|
|
450
|
+
// would have paid.
|
|
451
|
+
if (haveUsage) {
|
|
452
|
+
// baselineInputEff already folds the uncompressed/probe-failed fallback
|
|
453
|
+
// to actualInputEff, so passthrough rows contribute zero saved here.
|
|
454
|
+
this.totals.allBaselineEquivalentWeighted += baselineInputEff;
|
|
455
|
+
this.totals.allActualInputWeighted += actualInputEff;
|
|
456
|
+
this.totals.allOutputWeighted += outputEquiv;
|
|
457
|
+
this.totals.allUsageRequests += 1;
|
|
458
|
+
// Direct observed compressed-vs-passthrough split. No counterfactual,
|
|
459
|
+
// no probe gating — just partition the paid-rows set by which path
|
|
460
|
+
// actually ran this turn. Headline answers "is the compressed path
|
|
461
|
+
// cheaper per request on real traffic". Selection bias (the gate
|
|
462
|
+
// routes each turn) is real; sample counts go to the UI so the
|
|
463
|
+
// operator can judge sufficiency.
|
|
464
|
+
if (compressed) {
|
|
465
|
+
this.totals.compressedPaidRequests += 1;
|
|
466
|
+
this.totals.compressedActualInputWeighted += actualInputEff;
|
|
467
|
+
this.totals.compressedOutputWeighted += outputEquiv;
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
this.totals.passthroughPaidRequests += 1;
|
|
471
|
+
this.totals.passthroughActualInputWeighted += actualInputEff;
|
|
472
|
+
this.totals.passthroughOutputWeighted += outputEquiv;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// Per-session aggregation. Uses the SAME baseline/actual/output math as
|
|
476
|
+
// the global accumulators above, partitioned by `info.firstUserSha8`
|
|
477
|
+
// so the dashboard's "current session" panel can show what's happening
|
|
478
|
+
// RIGHT NOW instead of stale lifetime numbers. Untagged events (no
|
|
479
|
+
// firstUserSha8 — cold start, passthrough probe failures) are skipped
|
|
480
|
+
// rather than bucketed into a synthetic "unknown" session.
|
|
481
|
+
const sid = info?.firstUserSha8;
|
|
482
|
+
if (typeof sid === 'string' && sid.length > 0) {
|
|
483
|
+
this.currentSessionId = sid;
|
|
484
|
+
let s = this.sessions.get(sid);
|
|
485
|
+
if (!s) {
|
|
486
|
+
s = {
|
|
487
|
+
sessionId: sid,
|
|
488
|
+
baselineInputWeighted: 0,
|
|
489
|
+
actualInputWeighted: 0,
|
|
490
|
+
baselineMeasuredCount: 0,
|
|
491
|
+
allActualInputWeighted: 0,
|
|
492
|
+
allOutputWeighted: 0,
|
|
493
|
+
rawActualTokens: 0,
|
|
494
|
+
rawBaselineTokens: 0,
|
|
495
|
+
rawOutputTokens: 0,
|
|
496
|
+
};
|
|
497
|
+
this.sessions.set(sid, s);
|
|
498
|
+
// Cap memory — drop the first (oldest by insertion order) session
|
|
499
|
+
// when over budget. We no longer track lastSeen privately on the
|
|
500
|
+
// class — insertion order is a fine proxy because `currentSessionId`
|
|
501
|
+
// (set above on every update) is what the serve path uses to pick
|
|
502
|
+
// the most-recent session, not a scan of `this.sessions`.
|
|
503
|
+
if (this.sessions.size > DashboardState.SESSION_CAP) {
|
|
504
|
+
const firstKey = this.sessions.keys().next().value;
|
|
505
|
+
if (firstKey !== undefined)
|
|
506
|
+
this.sessions.delete(firstKey);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// Reuse the same haveUsage / haveBaseline guards + the
|
|
510
|
+
// baselineInputEff / actualInputEff locals computed earlier in
|
|
511
|
+
// update() so the lifetime totals block (above) and the per-session
|
|
512
|
+
// block (here) read the same values. Re-deriving them here would
|
|
513
|
+
// duplicate the cache-aware-baseline math and invite drift.
|
|
514
|
+
if (creditSaving) {
|
|
515
|
+
s.baselineInputWeighted += baselineInputEff;
|
|
516
|
+
s.actualInputWeighted += actualInputEff;
|
|
517
|
+
s.baselineMeasuredCount += 1;
|
|
518
|
+
// RAW, rate-free compression: real tokens sent vs the same body as text.
|
|
519
|
+
s.rawActualTokens += rawActual;
|
|
520
|
+
s.rawBaselineTokens += rawBaseline;
|
|
521
|
+
s.rawOutputTokens += out; // not compressed; added to BOTH sides for the honest total
|
|
522
|
+
}
|
|
523
|
+
// ALL-rows session bill — mirrors the global `if (haveUsage)` block
|
|
524
|
+
// above (allActualInputWeighted / allOutputWeighted). Used as the
|
|
525
|
+
// honest denominator for the session's saved-% so caching wins on
|
|
526
|
+
// unmeasured requests still count toward "what you actually paid".
|
|
527
|
+
if (haveUsage) {
|
|
528
|
+
s.allActualInputWeighted += actualInputEff;
|
|
529
|
+
s.allOutputWeighted += outputEquiv;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// Measurement totals are independent of usage/baseline gating — they
|
|
533
|
+
// accumulate whenever the scanner produced numbers. The scanner sets
|
|
534
|
+
// measurement to undefined on 5xx (no body to scan) and on unknown
|
|
535
|
+
// content-types; we count an event as "measured" when it has any.
|
|
536
|
+
const m = ev.measurement;
|
|
537
|
+
if (m) {
|
|
538
|
+
this.totals.textCharsMeasured += m.textChars;
|
|
539
|
+
this.totals.thinkingCharsMeasured += m.thinkingChars;
|
|
540
|
+
this.totals.toolUseCharsMeasured += m.toolUseChars;
|
|
541
|
+
this.totals.redactedBlockCountMeasured += m.redactedBlockCount;
|
|
542
|
+
this.totals.eventsWithMeasurement += 1;
|
|
543
|
+
}
|
|
544
|
+
const row = {
|
|
545
|
+
ts: Date.now() / 1000,
|
|
546
|
+
method: ev.method,
|
|
547
|
+
path: ev.path,
|
|
548
|
+
model: ev.model,
|
|
549
|
+
status: ev.status,
|
|
550
|
+
compressed,
|
|
551
|
+
cc_added: compressed ? 1 : undefined,
|
|
552
|
+
input_tokens: haveUsage ? inp : undefined,
|
|
553
|
+
output_tokens: haveUsage ? out : undefined,
|
|
554
|
+
cache_create: haveUsage ? cc : undefined,
|
|
555
|
+
cache_read: haveUsage ? cacheReadForRow : undefined,
|
|
556
|
+
actual_input: haveUsage ? round1(actualInputEff) : undefined,
|
|
557
|
+
baseline_input: creditSaving ? round1(baselineInputEff) : undefined,
|
|
558
|
+
session_saved_so_far_delta: creditSaving ? round1(baselineInputEff - actualInputEff) : undefined,
|
|
559
|
+
img_id: imgId,
|
|
560
|
+
img_ids: imgIds,
|
|
561
|
+
};
|
|
562
|
+
this.recent.push(row);
|
|
563
|
+
if (this.recent.length > RECENT_CAP)
|
|
564
|
+
this.recent.splice(0, this.recent.length - RECENT_CAP);
|
|
565
|
+
}
|
|
566
|
+
/** On startup, fold the last N entries from the JSONL events file back
|
|
567
|
+
* into the ring buffer so a process restart doesn't show an empty table.
|
|
568
|
+
* Cumulative totals are *not* restored (the file may have rotated, and
|
|
569
|
+
* double-counting is worse than starting fresh). */
|
|
570
|
+
async replay(filePath) {
|
|
571
|
+
try {
|
|
572
|
+
await fs.promises.access(filePath, fs.constants.R_OK);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return; // no file yet, nothing to replay
|
|
576
|
+
}
|
|
577
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
578
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
579
|
+
const tail = [];
|
|
580
|
+
for await (const line of rl) {
|
|
581
|
+
if (!line)
|
|
582
|
+
continue;
|
|
583
|
+
try {
|
|
584
|
+
const ev = JSON.parse(line);
|
|
585
|
+
tail.push(ev);
|
|
586
|
+
if (tail.length > RECENT_CAP)
|
|
587
|
+
tail.shift();
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
/* skip malformed line */
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// Replay mirrors the live update() warmth logic (per-session baselineWarmth,
|
|
594
|
+
// cr-grounded) so it produces byte-identical per-row numbers to update().
|
|
595
|
+
for (const t of tail) {
|
|
596
|
+
const inp = t.input_tokens ?? 0;
|
|
597
|
+
const out = t.output_tokens ?? 0;
|
|
598
|
+
const cc = t.cache_create_tokens ?? 0;
|
|
599
|
+
const cr = t.cache_read_tokens ?? 0;
|
|
600
|
+
const compressed = t.compressed === true;
|
|
601
|
+
const gpt = isOpenAIEvent(t.path);
|
|
602
|
+
// Same unified accounting as update(); see the branch comments there.
|
|
603
|
+
let haveUsage;
|
|
604
|
+
let haveBaseline;
|
|
605
|
+
let creditSaving;
|
|
606
|
+
let actualInputEff;
|
|
607
|
+
let baselineInputEff;
|
|
608
|
+
let rawActual;
|
|
609
|
+
let rawBaseline;
|
|
610
|
+
let baselineForRow;
|
|
611
|
+
let cacheReadForRow;
|
|
612
|
+
let warmForRow; // text-baseline warmth for the Context Map narration
|
|
613
|
+
if (gpt) {
|
|
614
|
+
const e = gptEff({
|
|
615
|
+
model: t.model,
|
|
616
|
+
inputTokens: inp,
|
|
617
|
+
outputTokens: out,
|
|
618
|
+
cachedTokens: t.cached_tokens ?? 0,
|
|
619
|
+
imageTokens: t.image_tokens ?? 0,
|
|
620
|
+
baselineImagedTokens: t.baseline_imaged_tokens ?? 0,
|
|
621
|
+
compressed,
|
|
622
|
+
});
|
|
623
|
+
haveUsage = e.haveUsage;
|
|
624
|
+
haveBaseline = e.haveBaseline;
|
|
625
|
+
creditSaving = e.creditSaving;
|
|
626
|
+
actualInputEff = e.actualInputEff;
|
|
627
|
+
baselineInputEff = e.baselineInputEff;
|
|
628
|
+
rawActual = e.rawActual;
|
|
629
|
+
rawBaseline = e.rawBaseline;
|
|
630
|
+
baselineForRow = e.rawBaseline;
|
|
631
|
+
cacheReadForRow = t.cached_tokens ?? 0;
|
|
632
|
+
warmForRow = (t.cached_tokens ?? 0) > 0;
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
haveUsage = inp > 0 || out > 0 || cc > 0 || cr > 0;
|
|
636
|
+
const baseline = t.baseline_tokens;
|
|
637
|
+
const cacheable = t
|
|
638
|
+
.baseline_cacheable_tokens ?? 0;
|
|
639
|
+
const probeStatus = t.baseline_probe_status;
|
|
640
|
+
// Same gating rule as update(): require an explicit 'ok' status when
|
|
641
|
+
// present; fall back to "have a baseline number" for legacy JSONL.
|
|
642
|
+
const probeOk = probeStatus === 'ok'
|
|
643
|
+
|| (probeStatus === undefined && typeof baseline === 'number' && baseline > 0);
|
|
644
|
+
haveBaseline = typeof baseline === 'number' && baseline > 0 && probeOk;
|
|
645
|
+
actualInputEff = haveUsage ? computeActualInputEff(inp, cc, cr) : 0;
|
|
646
|
+
// Mirror update(): only credit the cache-modeled counterfactual on
|
|
647
|
+
// compressed rows. Uncompressed/passthrough rows fall back to the
|
|
648
|
+
// actual cost so they show zero saved (no fabricated savings).
|
|
649
|
+
creditSaving = haveBaseline && haveUsage && compressed;
|
|
650
|
+
// Warm/cold is reconstructed from server-observed cr only. Persisted
|
|
651
|
+
// completion ts + duration_ms are used only to find a prior prefix size
|
|
652
|
+
// for the reused/grown split after cr>0 has proved warmth.
|
|
653
|
+
const sidR = t.first_user_sha8;
|
|
654
|
+
const prefixShaR = t.system_sha8;
|
|
655
|
+
const completionSecR = Date.parse(t.ts) / 1000;
|
|
656
|
+
const requestStartSecR = completionSecR - Math.max(0, t.duration_ms || 0) / 1000;
|
|
657
|
+
const warmthPrevR = typeof sidR === 'string' && sidR.length > 0 ? this.baselineWarmth.get(sidR) : undefined;
|
|
658
|
+
// Same cr-only warmth as update(); prior state only refines the split.
|
|
659
|
+
const { warm: warmR, prevCacheable: prevCacheableR } = deriveBaselineWarmth(warmthPrevR, requestStartSecR, cacheable, cr, DashboardState.CACHE_TTL_SEC, prefixShaR);
|
|
660
|
+
baselineInputEff = creditSaving
|
|
661
|
+
? computeBaselineInputEff(baseline, cacheable, inp, cc, cr, warmR, prevCacheableR)
|
|
662
|
+
: actualInputEff;
|
|
663
|
+
if (typeof sidR === 'string' && sidR.length > 0 && haveUsage) {
|
|
664
|
+
this.baselineWarmth.set(sidR, {
|
|
665
|
+
ts: completionSecR,
|
|
666
|
+
cacheable: cacheable > 0 ? cacheable : (warmthPrevR?.cacheable ?? 0),
|
|
667
|
+
prefixSha: prefixShaR ?? warmthPrevR?.prefixSha,
|
|
668
|
+
});
|
|
669
|
+
if (this.baselineWarmth.size > DashboardState.SESSION_CAP) {
|
|
670
|
+
const firstKey = this.baselineWarmth.keys().next().value;
|
|
671
|
+
if (firstKey !== undefined)
|
|
672
|
+
this.baselineWarmth.delete(firstKey);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
rawActual = inp + cc + cr;
|
|
676
|
+
rawBaseline = baseline ?? 0;
|
|
677
|
+
baselineForRow = baseline ?? 0;
|
|
678
|
+
cacheReadForRow = cr;
|
|
679
|
+
warmForRow = warmR; // server-observed cache read
|
|
680
|
+
}
|
|
681
|
+
// Rebuild the Context Map breakdown so old rows keep their "Saved" value
|
|
682
|
+
// and "Details" link after a restart. The PNG ring is in-memory and gone,
|
|
683
|
+
// so thumbnails can't return (imageIds: [], flagged `restored`) — but the
|
|
684
|
+
// headline %, buckets and Saved delta all reconstruct from the persisted
|
|
685
|
+
// event with the same cache-weighted math as the live update() path.
|
|
686
|
+
const imageCount = t.image_count ?? 0;
|
|
687
|
+
let imgId;
|
|
688
|
+
if (compressed && haveUsage && (imageCount > 0 || rawBaseline > 0)) {
|
|
689
|
+
imgId = this.nextImageId++;
|
|
690
|
+
this.contextHistory.push({
|
|
691
|
+
id: imgId,
|
|
692
|
+
baselineTokens: baselineForRow,
|
|
693
|
+
realInput: rawActual,
|
|
694
|
+
baselineInputEff,
|
|
695
|
+
actualInputEff,
|
|
696
|
+
haveBaseline,
|
|
697
|
+
cacheRead: cacheReadForRow,
|
|
698
|
+
warm: warmForRow,
|
|
699
|
+
output: out,
|
|
700
|
+
imageCount,
|
|
701
|
+
buckets: { ...(t.bucket_chars ?? {}) },
|
|
702
|
+
imageIds: [], // PNG ring is in-memory; not restorable across restart
|
|
703
|
+
compressed,
|
|
704
|
+
restored: true,
|
|
705
|
+
});
|
|
706
|
+
if (this.contextHistory.length > RECENT_CAP) {
|
|
707
|
+
this.contextHistory.splice(0, this.contextHistory.length - RECENT_CAP);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const row = {
|
|
711
|
+
ts: Date.parse(t.ts) / 1000,
|
|
712
|
+
method: t.method,
|
|
713
|
+
path: t.path,
|
|
714
|
+
model: t.model,
|
|
715
|
+
status: t.status,
|
|
716
|
+
compressed,
|
|
717
|
+
cc_added: compressed ? 1 : undefined,
|
|
718
|
+
input_tokens: t.input_tokens,
|
|
719
|
+
output_tokens: t.output_tokens,
|
|
720
|
+
cache_create: t.cache_create_tokens,
|
|
721
|
+
cache_read: gpt ? cacheReadForRow : t.cache_read_tokens,
|
|
722
|
+
actual_input: haveUsage ? round1(actualInputEff) : undefined,
|
|
723
|
+
baseline_input: creditSaving ? round1(baselineInputEff) : undefined,
|
|
724
|
+
session_saved_so_far_delta: creditSaving ? round1(baselineInputEff - actualInputEff) : undefined,
|
|
725
|
+
img_id: imgId,
|
|
726
|
+
img_ids: imgId !== undefined ? [imgId] : undefined,
|
|
727
|
+
};
|
|
728
|
+
this.recent.push(row);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
// ---- HTTP handlers ------------------------------------------------------
|
|
732
|
+
/**
|
|
733
|
+
* Per-session "what's happening right now" payload backing the
|
|
734
|
+
* `SessionSummary` panel. Scopes the dollar-weighted savings ratio + the
|
|
735
|
+
* per-bucket char attribution + the passthrough-reason histogram to the
|
|
736
|
+
* most-recently-active session (tracked via `info.firstUserSha8`) so the
|
|
737
|
+
* top-of-dashboard headline reflects the live session rather than stale
|
|
738
|
+
* lifetime aggregates from a previous run.
|
|
739
|
+
*
|
|
740
|
+
* Returns `{ sessionId: null, message: 'no active session yet' }` when no
|
|
741
|
+
* events have been received yet (or the first events were all untagged —
|
|
742
|
+
* cold start, probe failures) — `update()` only sets `currentSessionId`
|
|
743
|
+
* when a `firstUserSha8`-tagged event lands. The client renders a stale
|
|
744
|
+
* panel rather than zeroes when a session goes idle (NO `lastSeen >
|
|
745
|
+
* threshold` check here; see comment in `update()` for the rationale).
|
|
746
|
+
*/
|
|
747
|
+
serveCurrentSessionJson() {
|
|
748
|
+
if (!this.currentSessionId) {
|
|
749
|
+
return jsonResponse({
|
|
750
|
+
sessionId: null,
|
|
751
|
+
message: 'no active session yet',
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
const s = this.sessions.get(this.currentSessionId);
|
|
755
|
+
if (!s) {
|
|
756
|
+
return jsonResponse({ sessionId: null, message: 'no active session yet' });
|
|
757
|
+
}
|
|
758
|
+
// Headline payload: the same dollar-weighted baseline/actual input
|
|
759
|
+
// accumulators the global Totals block exposes, plus the honest
|
|
760
|
+
// denominator (`baselineMeasuredCount` — only requests that carried a
|
|
761
|
+
// baseline measurement). We ALSO ship the ALL-rows session bill
|
|
762
|
+
// (`allActualInputWeighted` + `allOutputWeighted`) so the Svelte panel
|
|
763
|
+
// can compute saved-% against the full session bill instead of just
|
|
764
|
+
// the measured slice — matching the global `saved_pct_of_all_spend`
|
|
765
|
+
// math but scoped to one session. The Svelte panel does the dollar
|
|
766
|
+
// conversion itself so we don't round-trip pricing through the wire.
|
|
767
|
+
return jsonResponse({
|
|
768
|
+
sessionId: s.sessionId,
|
|
769
|
+
baselineInputWeighted: s.baselineInputWeighted,
|
|
770
|
+
actualInputWeighted: s.actualInputWeighted,
|
|
771
|
+
baselineMeasuredCount: s.baselineMeasuredCount,
|
|
772
|
+
allActualInputWeighted: s.allActualInputWeighted,
|
|
773
|
+
allOutputWeighted: s.allOutputWeighted,
|
|
774
|
+
rawActualTokens: s.rawActualTokens,
|
|
775
|
+
rawBaselineTokens: s.rawBaselineTokens,
|
|
776
|
+
rawOutputTokens: s.rawOutputTokens,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
serveStats() {
|
|
780
|
+
// Two headline numbers, derived from the same per-event accumulators:
|
|
781
|
+
//
|
|
782
|
+
// saved_pct_input_only = Σ saved / Σ baseline_input_eff × 100
|
|
783
|
+
// What the proxy actually saved on the slice it can move (input).
|
|
784
|
+
// Numerator = input tokens we didn't pay for (cache-aware).
|
|
785
|
+
// Denominator = input tokens we WOULD have paid (cache-aware).
|
|
786
|
+
// Output is excluded because the proxy doesn't touch it.
|
|
787
|
+
//
|
|
788
|
+
// saved_pct_of_total_bill = Σ saved / Σ (baseline_input + output × 5) × 100
|
|
789
|
+
// What share of the TOTAL bill the proxy saved. Honest counter to the
|
|
790
|
+
// input-only number: on output-heavy sessions (long thinking blocks,
|
|
791
|
+
// big edits) the percentage shrinks because output dominates.
|
|
792
|
+
//
|
|
793
|
+
// token_equivalent_total = Σ (actual_input + output × 5)
|
|
794
|
+
// What Anthropic's weekly limit actually meters — input × 1.0 +
|
|
795
|
+
// output × 5.0 (the same ratio as the per-MTok price card). This is
|
|
796
|
+
// the number that moves your "%% used this week" indicator.
|
|
797
|
+
const baseline = this.totals.baselineInputWeighted;
|
|
798
|
+
const actual = this.totals.actualInputWeighted;
|
|
799
|
+
const output = this.totals.outputWeighted; // already × OUTPUT_TOKEN_RATE
|
|
800
|
+
const saved = baseline - actual;
|
|
801
|
+
const pctInput = baseline > 0 ? (saved / baseline) * 100 : 0;
|
|
802
|
+
const baselineTotal = baseline + output;
|
|
803
|
+
const actualTotal = actual + output;
|
|
804
|
+
const pctTotal = baselineTotal > 0 ? (saved / baselineTotal) * 100 : 0;
|
|
805
|
+
// Share-of-all-spend: honest denominator. The numerator can only credit
|
|
806
|
+
// savings against rows where we have a probe baseline (otherwise it's
|
|
807
|
+
// estimation), but the denominator MUST include every request the user
|
|
808
|
+
// actually paid for — including passthrough rows, probe-failed rows,
|
|
809
|
+
// and untransformed turns the gate said no to. Otherwise the headline
|
|
810
|
+
// answers "did pxpipe help on the rows where it ran" instead of
|
|
811
|
+
// "did pxpipe move my real bill". The first is a cherry-pick.
|
|
812
|
+
const allBaselineEquiv = this.totals.allBaselineEquivalentWeighted;
|
|
813
|
+
const allActual = this.totals.allActualInputWeighted;
|
|
814
|
+
const allOutput = this.totals.allOutputWeighted;
|
|
815
|
+
// Denominator = counterfactual all-rows bill: what the user would have
|
|
816
|
+
// paid with no pxpipe. Bounded ratio at 100%; a single cold-miss
|
|
817
|
+
// compressed request on an otherwise empty session shows ~99% saved,
|
|
818
|
+
// not 280%.
|
|
819
|
+
const allCounterfactualBill = allBaselineEquiv + allOutput;
|
|
820
|
+
const pctAllSpend = allCounterfactualBill > 0 ? (saved / allCounterfactualBill) * 100 : 0;
|
|
821
|
+
// Direct observed split — actual $ per request, partitioned by which
|
|
822
|
+
// path ran. Token-equivalent (input × 1.0 + cache_create × 1.25 +
|
|
823
|
+
// cache_read × 0.10 + output × 5) → $ at the assumed Opus 4.7 input
|
|
824
|
+
// rate. Same $/Mtok rate is applied to both buckets, so the bias from
|
|
825
|
+
// the rate assumption cancels in the delta. Selection bias from the
|
|
826
|
+
// gate is NOT cancelled — the operator interprets that via the
|
|
827
|
+
// sample-count caveat below.
|
|
828
|
+
const compressedTokenEquiv = this.totals.compressedActualInputWeighted +
|
|
829
|
+
this.totals.compressedOutputWeighted;
|
|
830
|
+
const passthroughTokenEquiv = this.totals.passthroughActualInputWeighted +
|
|
831
|
+
this.totals.passthroughOutputWeighted;
|
|
832
|
+
const compressedActualUsd = (compressedTokenEquiv * ASSUMED_INPUT_USD_PER_MTOK) / 1e6;
|
|
833
|
+
const passthroughActualUsd = (passthroughTokenEquiv * ASSUMED_INPUT_USD_PER_MTOK) / 1e6;
|
|
834
|
+
const compressedAvgUsd = this.totals.compressedPaidRequests > 0
|
|
835
|
+
? compressedActualUsd / this.totals.compressedPaidRequests
|
|
836
|
+
: 0;
|
|
837
|
+
const passthroughAvgUsd = this.totals.passthroughPaidRequests > 0
|
|
838
|
+
? passthroughActualUsd / this.totals.passthroughPaidRequests
|
|
839
|
+
: 0;
|
|
840
|
+
// Sufficient-sample threshold is a soft heuristic. 20 paid requests per
|
|
841
|
+
// bucket is enough to see a real effect on Opus 4.7 traffic (a single
|
|
842
|
+
// big cold-miss can dominate at lower n). Below this, the UI shows the
|
|
843
|
+
// bucket numbers but hides the delta and surfaces "small sample".
|
|
844
|
+
const SUFFICIENT = 20;
|
|
845
|
+
const splitSufficient = this.totals.compressedPaidRequests >= SUFFICIENT &&
|
|
846
|
+
this.totals.passthroughPaidRequests >= SUFFICIENT;
|
|
847
|
+
const splitDeltaUsd = compressedAvgUsd - passthroughAvgUsd;
|
|
848
|
+
const uptimeSec = Date.now() / 1000 - this.totals.startedAt;
|
|
849
|
+
const payload = {
|
|
850
|
+
requests: this.totals.requests,
|
|
851
|
+
compressed_requests: this.totals.compressedRequests,
|
|
852
|
+
baseline_input_weighted: Math.round(baseline),
|
|
853
|
+
actual_input_weighted: Math.round(actual),
|
|
854
|
+
saved_input_tokens: Math.round(saved),
|
|
855
|
+
// saved_pct kept for back-compat with existing dashboard HTML; it is
|
|
856
|
+
// the input-only number. New code should read saved_pct_input_only.
|
|
857
|
+
saved_pct: round1(pctInput),
|
|
858
|
+
saved_pct_input_only: round1(pctInput),
|
|
859
|
+
saved_pct_of_total_bill: round1(pctTotal),
|
|
860
|
+
// Honest "share of total bill saved" — measured-rows numerator over
|
|
861
|
+
// ALL paid requests in the denominator (compressed + passthrough +
|
|
862
|
+
// probe-failed). This is the number users actually want when they
|
|
863
|
+
// ask "is pxpipe helping". Negative when flap-pollution from
|
|
864
|
+
// passthrough turns exceeds the collapse win on measured turns.
|
|
865
|
+
saved_pct_of_all_spend: round1(pctAllSpend),
|
|
866
|
+
all_baseline_equivalent_weighted: Math.round(allBaselineEquiv),
|
|
867
|
+
all_actual_input_weighted: Math.round(allActual),
|
|
868
|
+
all_output_weighted: Math.round(allOutput),
|
|
869
|
+
all_usage_requests: this.totals.allUsageRequests,
|
|
870
|
+
// Direct observed split — replaces "share of spend saved" as the
|
|
871
|
+
// headline. Total actual $ and average $/req per path, plus a delta
|
|
872
|
+
// gated on `split_sufficient_sample`. No counterfactual: each
|
|
873
|
+
// bucket is what each path actually billed.
|
|
874
|
+
compressed_paid_requests: this.totals.compressedPaidRequests,
|
|
875
|
+
passthrough_paid_requests: this.totals.passthroughPaidRequests,
|
|
876
|
+
compressed_actual_usd: round4(compressedActualUsd),
|
|
877
|
+
passthrough_actual_usd: round4(passthroughActualUsd),
|
|
878
|
+
compressed_avg_usd_per_request: round4(compressedAvgUsd),
|
|
879
|
+
passthrough_avg_usd_per_request: round4(passthroughAvgUsd),
|
|
880
|
+
compressed_minus_passthrough_avg_usd: round4(splitDeltaUsd),
|
|
881
|
+
split_sufficient_sample: splitSufficient,
|
|
882
|
+
split_min_sample_per_bucket: SUFFICIENT,
|
|
883
|
+
saved_usd: round4((saved * ASSUMED_INPUT_USD_PER_MTOK) / 1e6),
|
|
884
|
+
output_weighted: Math.round(output),
|
|
885
|
+
baseline_token_equivalent: Math.round(baselineTotal),
|
|
886
|
+
actual_token_equivalent: Math.round(actualTotal),
|
|
887
|
+
pricing_assumptions: {
|
|
888
|
+
input_per_mtok: ASSUMED_INPUT_USD_PER_MTOK,
|
|
889
|
+
output_multiplier: OUTPUT_TOKEN_RATE,
|
|
890
|
+
cache_write_5m_multiplier: 1.25,
|
|
891
|
+
cache_write_1h_multiplier: 2.0,
|
|
892
|
+
cache_read_multiplier: 0.1,
|
|
893
|
+
source: 'docs.anthropic.com/en/docs/about-claude/pricing (verified 2026-05-19)',
|
|
894
|
+
},
|
|
895
|
+
// Honest output measurement — char counts from the SSE/JSON scanner,
|
|
896
|
+
// independent of Anthropic's `usage.output_tokens`. Surfaces the gap
|
|
897
|
+
// the May-2026 weekly-meter audit hypothesized (redacted_thinking adds
|
|
898
|
+
// billed tokens that we can't see). `events_with_measurement` lets the
|
|
899
|
+
// operator weigh how representative the numbers are; when it's near
|
|
900
|
+
// `requests`, the gap is real. When it's 0, the scanner never landed
|
|
901
|
+
// (5xx-heavy session, no /v1/messages traffic).
|
|
902
|
+
measured_text_chars: this.totals.textCharsMeasured,
|
|
903
|
+
measured_thinking_chars: this.totals.thinkingCharsMeasured,
|
|
904
|
+
measured_tool_use_chars: this.totals.toolUseCharsMeasured,
|
|
905
|
+
measured_redacted_block_count: this.totals.redactedBlockCountMeasured,
|
|
906
|
+
events_with_measurement: this.totals.eventsWithMeasurement,
|
|
907
|
+
uptime_sec: uptimeSec,
|
|
908
|
+
compression_enabled: this.compressionEnabled,
|
|
909
|
+
};
|
|
910
|
+
return new Response(JSON.stringify(payload, null, 2), {
|
|
911
|
+
headers: { 'content-type': 'application/json' },
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
serveRecent() {
|
|
915
|
+
const latest = this.images[this.images.length - 1];
|
|
916
|
+
const payload = {
|
|
917
|
+
recent: this.recent,
|
|
918
|
+
has_preview: latest !== undefined,
|
|
919
|
+
preview_meta: latest?.meta ?? '',
|
|
920
|
+
// Image ids currently resident in the ring. The dashboard uses this to
|
|
921
|
+
// tell which RecentRow.img_id values still resolve — older ones have
|
|
922
|
+
// been evicted and their "view" button should be disabled.
|
|
923
|
+
image_ids: this.images.map((im) => im.id),
|
|
924
|
+
};
|
|
925
|
+
return new Response(JSON.stringify(payload), {
|
|
926
|
+
headers: { 'content-type': 'application/json' },
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
/** GET /proxy-latest-png[?id=N] — raw PNG from the image ring. With no id
|
|
930
|
+
* (or an unknown id) the latest render is returned; ?id=N pulls that
|
|
931
|
+
* specific image while it's still in the ring. 404 once evicted. */
|
|
932
|
+
servePng(id) {
|
|
933
|
+
// Cropping is done client-side via CSS (object-position + overflow:hidden).
|
|
934
|
+
// Python decoded the PNG to crop server-side; we skip that to avoid
|
|
935
|
+
// pulling a PNG decoder back in — the CSS approach renders identically.
|
|
936
|
+
const entry = id !== undefined
|
|
937
|
+
? this.images.find((im) => im.id === id)
|
|
938
|
+
: this.images[this.images.length - 1];
|
|
939
|
+
if (!entry) {
|
|
940
|
+
return new Response('no image yet', { status: 404 });
|
|
941
|
+
}
|
|
942
|
+
return new Response(entry.png, {
|
|
943
|
+
headers: { 'content-type': 'image/png', 'cache-control': 'no-cache' },
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
/** GET /api/image-source[?id=N] — the source text the PNG was rendered
|
|
947
|
+
* from, so the operator can see the text → image conversion side by side.
|
|
948
|
+
* Falls back to the latest image; 404 if evicted or text wasn't captured. */
|
|
949
|
+
serveImageSource(id) {
|
|
950
|
+
const entry = id !== undefined
|
|
951
|
+
? this.images.find((im) => im.id === id)
|
|
952
|
+
: this.images[this.images.length - 1];
|
|
953
|
+
if (!entry || entry.sourceText === undefined) {
|
|
954
|
+
return new Response(JSON.stringify({ error: 'no source text' }), {
|
|
955
|
+
status: 404,
|
|
956
|
+
headers: { 'content-type': 'application/json' },
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
return new Response(JSON.stringify({ id: entry.id, meta: entry.meta, source_text: entry.sourceText }), { headers: { 'content-type': 'application/json' } });
|
|
960
|
+
}
|
|
961
|
+
serveHtml(port) {
|
|
962
|
+
return htmlResponse(renderPage(port));
|
|
963
|
+
}
|
|
964
|
+
/** GET /fragments/<name> — server-rendered htmx fragments. Each one reuses
|
|
965
|
+
* the corresponding JSON endpoint's payload (via Response.json()) so the
|
|
966
|
+
* HTML and JSON surfaces can't drift apart. */
|
|
967
|
+
async serveFragment(name, url, port) {
|
|
968
|
+
switch (name) {
|
|
969
|
+
case 'toggle':
|
|
970
|
+
return htmlResponse(renderToggleFragment(this.compressionEnabled));
|
|
971
|
+
case 'models':
|
|
972
|
+
return htmlResponse(renderModelsFragment(getAllowedModelBases(), getConfiguredModelBases(), this.compressionEnabled));
|
|
973
|
+
case 'context-map': {
|
|
974
|
+
const reqParam = url.searchParams.get('req');
|
|
975
|
+
if (reqParam) {
|
|
976
|
+
// Explicit request: show ONLY that one. If its breakdown was evicted
|
|
977
|
+
// or never recorded (no usage on that completion), say so — don't
|
|
978
|
+
// silently fall back to the latest request's data under its label.
|
|
979
|
+
const found = this.contextHistory.find((h) => h.id === Number(reqParam));
|
|
980
|
+
return htmlResponse(renderContextMapFragment(found, this.contextHistory, !found));
|
|
981
|
+
}
|
|
982
|
+
// No specific request → default to the latest.
|
|
983
|
+
return htmlResponse(renderContextMapFragment(this.contextHistory[this.contextHistory.length - 1], this.contextHistory));
|
|
984
|
+
}
|
|
985
|
+
case 'session-summary': {
|
|
986
|
+
// Lifetime hero — same cumulative payload as the header strip so the
|
|
987
|
+
// headline and the "$ saved" tiles never disagree and it stops jumping.
|
|
988
|
+
const s = (await this.serveStats().json());
|
|
989
|
+
return htmlResponse(renderSessionSummaryFragment(s));
|
|
990
|
+
}
|
|
991
|
+
case 'header': {
|
|
992
|
+
const s = (await this.serveStats().json());
|
|
993
|
+
return htmlResponse(renderHeaderFragment(s, port));
|
|
994
|
+
}
|
|
995
|
+
case 'recent': {
|
|
996
|
+
const r = (await this.serveRecent().json());
|
|
997
|
+
return htmlResponse(renderRecentFragment(r));
|
|
998
|
+
}
|
|
999
|
+
case 'latest': {
|
|
1000
|
+
const r = (await this.serveRecent().json());
|
|
1001
|
+
const pinRaw = url.searchParams.get('pin');
|
|
1002
|
+
const pinNum = pinRaw != null && pinRaw !== '' ? Number(pinRaw) : NaN;
|
|
1003
|
+
const pin = Number.isFinite(pinNum) ? pinNum : null;
|
|
1004
|
+
const showSource = url.searchParams.get('source') === '1';
|
|
1005
|
+
let sourceText = null;
|
|
1006
|
+
if (showSource) {
|
|
1007
|
+
const entry = pin != null
|
|
1008
|
+
? this.images.find((im) => im.id === pin)
|
|
1009
|
+
: this.images[this.images.length - 1];
|
|
1010
|
+
sourceText = entry?.sourceText ?? null;
|
|
1011
|
+
}
|
|
1012
|
+
return htmlResponse(renderLatestFragment({ payload: r, pin, showSource, sourceText }));
|
|
1013
|
+
}
|
|
1014
|
+
case 'sessions': {
|
|
1015
|
+
const res = await this.serveSessionsJson();
|
|
1016
|
+
if (!res.ok)
|
|
1017
|
+
return htmlResponse(`<div class="status">sessions unavailable</div>`);
|
|
1018
|
+
const p = (await res.json());
|
|
1019
|
+
return htmlResponse(renderSessionsFragment(p));
|
|
1020
|
+
}
|
|
1021
|
+
case 'stats': {
|
|
1022
|
+
const res = await this.serveApiStats();
|
|
1023
|
+
const p = (await res.json());
|
|
1024
|
+
return htmlResponse(renderStatsTableFragment(p));
|
|
1025
|
+
}
|
|
1026
|
+
default:
|
|
1027
|
+
return new Response('unknown fragment', { status: 404 });
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
// ---- session / cleanup endpoints --------------------------------------
|
|
1031
|
+
//
|
|
1032
|
+
// Every endpoint below recomputes from disk on demand. The dashboard polls
|
|
1033
|
+
// these on a 5s cadence, which is fine for a single-user dev tool — even at
|
|
1034
|
+
// ~3k events / 1.5 MB the round-trip is <100ms on a warm SSD.
|
|
1035
|
+
/** GET /api/sessions.json — grouped sessions enriched with the Claude Code
|
|
1036
|
+
* cross-reference. The body is the top-level `sessions` array; the client
|
|
1037
|
+
* renders a bar chart of the top savers. */
|
|
1038
|
+
async serveSessionsJson(opts = {}) {
|
|
1039
|
+
if (!this.paths)
|
|
1040
|
+
return notConfigured('sessions');
|
|
1041
|
+
const [{ sessions }, ccMap] = await Promise.all([
|
|
1042
|
+
aggregateSessions(this.paths),
|
|
1043
|
+
this.ccMapFn(),
|
|
1044
|
+
]);
|
|
1045
|
+
const rows = filterSessions(sessions, opts);
|
|
1046
|
+
const enriched = rows.map((s) => ({
|
|
1047
|
+
...s,
|
|
1048
|
+
claudeCode: ccMap.get(s.id) ?? null,
|
|
1049
|
+
}));
|
|
1050
|
+
return jsonResponse({ sessions: enriched, count: enriched.length });
|
|
1051
|
+
}
|
|
1052
|
+
/** GET /api/stats.json — full-history aggregate. Migrated from the
|
|
1053
|
+
* former `pxpipe stats` CLI. */
|
|
1054
|
+
async serveApiStats() {
|
|
1055
|
+
if (!this.paths)
|
|
1056
|
+
return notConfigured('stats');
|
|
1057
|
+
const result = await aggregateEventsFile(this.paths.eventsFile);
|
|
1058
|
+
if (!result) {
|
|
1059
|
+
return jsonResponse({
|
|
1060
|
+
error: 'no events file yet',
|
|
1061
|
+
path: this.paths.eventsFile,
|
|
1062
|
+
}, 404);
|
|
1063
|
+
}
|
|
1064
|
+
return jsonResponse({
|
|
1065
|
+
parsed: result.parsed,
|
|
1066
|
+
dropped: result.dropped,
|
|
1067
|
+
summary: summaryToJson(result.summary),
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
/** POST /api/compression — flip the runtime kill switch.
|
|
1071
|
+
* Body: { enabled: boolean }. Returns the new state. In-memory only;
|
|
1072
|
+
* restart resets to the default (on). */
|
|
1073
|
+
handleCompressionToggle(body) {
|
|
1074
|
+
const on = body.enabled === true;
|
|
1075
|
+
this.compressionEnabled = on;
|
|
1076
|
+
return jsonResponse({ compression_enabled: on });
|
|
1077
|
+
}
|
|
1078
|
+
/** POST /fragments/models — add/remove ONE model (Claude or GPT) from the
|
|
1079
|
+
* runtime compress scope. In-memory only; restart resets to the PXPIPE_MODELS
|
|
1080
|
+
* env / built-in default. The model checks read this live. */
|
|
1081
|
+
handleModelsToggle(model, on) {
|
|
1082
|
+
const next = new Set(getAllowedModelBases());
|
|
1083
|
+
if (on)
|
|
1084
|
+
next.add(model);
|
|
1085
|
+
else
|
|
1086
|
+
next.delete(model);
|
|
1087
|
+
setAllowedModelBases([...next]);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
function jsonResponse(body, status = 200) {
|
|
1091
|
+
return new Response(JSON.stringify(body), {
|
|
1092
|
+
status,
|
|
1093
|
+
headers: {
|
|
1094
|
+
'content-type': 'application/json',
|
|
1095
|
+
},
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
function notConfigured(what) {
|
|
1099
|
+
// The dashboard was constructed without SessionsPaths (e.g. a legacy host
|
|
1100
|
+
// that doesn't track to disk). Return 503 so the client can surface a
|
|
1101
|
+
// helpful error rather than failing silently.
|
|
1102
|
+
return jsonResponse({ error: `${what} unavailable: dashboard not configured with event paths` }, 503);
|
|
1103
|
+
}
|
|
1104
|
+
function round1(n) {
|
|
1105
|
+
return Math.round(n * 10) / 10;
|
|
1106
|
+
}
|
|
1107
|
+
function round4(n) {
|
|
1108
|
+
return Math.round(n * 10000) / 10000;
|
|
1109
|
+
}
|
|
1110
|
+
/** Match dashboard paths (handle query strings on /proxy-latest-png). */
|
|
1111
|
+
export function dashboardPath(pathname) {
|
|
1112
|
+
if (pathname === '/' || pathname === '/dashboard')
|
|
1113
|
+
return { kind: 'html' };
|
|
1114
|
+
if (pathname === '/proxy-stats')
|
|
1115
|
+
return { kind: 'stats' };
|
|
1116
|
+
if (pathname === '/proxy-recent')
|
|
1117
|
+
return { kind: 'recent' };
|
|
1118
|
+
if (pathname === '/proxy-latest-png')
|
|
1119
|
+
return { kind: 'png' };
|
|
1120
|
+
if (pathname === '/api/sessions.json')
|
|
1121
|
+
return { kind: 'api-sessions' };
|
|
1122
|
+
if (pathname === '/api/stats.json')
|
|
1123
|
+
return { kind: 'api-stats' };
|
|
1124
|
+
if (pathname === '/api/current-session.json')
|
|
1125
|
+
return { kind: 'current-session' };
|
|
1126
|
+
if (pathname === '/api/compression')
|
|
1127
|
+
return { kind: 'api-compression' };
|
|
1128
|
+
if (pathname === '/api/image-source')
|
|
1129
|
+
return { kind: 'api-image-source' };
|
|
1130
|
+
if (pathname.startsWith('/fragments/')) {
|
|
1131
|
+
return { kind: 'fragment', name: pathname.slice('/fragments/'.length) };
|
|
1132
|
+
}
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
function htmlResponse(body) {
|
|
1136
|
+
return new Response(body, {
|
|
1137
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
//# sourceMappingURL=dashboard.js.map
|