osdy-pi 1.9.0 → 1.11.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/README.md +49 -10
- package/extensions/osdy-pi/runtime.test.ts +11 -8
- package/extensions/osdy-pi/runtime.ts +6 -0
- package/extensions/osdy-pi/usage-analytics-data.ts +208 -0
- package/extensions/osdy-pi/usage-analytics-store.test.ts +302 -0
- package/extensions/osdy-pi/usage-analytics-store.ts +216 -0
- package/extensions/osdy-pi/usage-analytics-ui.test.ts +500 -0
- package/extensions/osdy-pi/usage-analytics-ui.ts +444 -0
- package/extensions/osdy-pi/usage-analytics.test.ts +152 -0
- package/extensions/osdy-pi/usage-analytics.ts +110 -0
- package/package.json +5 -2
- package/themes/osdy-pi-miles-morales.json +86 -0
- package/themes/osdy-pi-spider-man-classic.json +87 -0
- package/themes/osdy-pi-spider-verse.json +87 -0
package/README.md
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
# Osdy Pi — a themed, responsive Pi workspace
|
|
2
2
|
|
|
3
|
-
Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive terminal presentation with a header, editor, working indicator, and Git view. Visit the [Osdy landing page](https://
|
|
3
|
+
Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive terminal presentation with a header, editor, working indicator, and Git view. Visit the [Osdy landing page](https://osdy-pi.vercel.app/).
|
|
4
4
|
|
|
5
5
|
<img width="1857" height="847" alt="Osdy Pi interface" src="https://github.com/user-attachments/assets/028eeb14-3f43-4f1c-9603-0c55a8d2856d" />
|
|
6
6
|
|
|
7
7
|
## Release highlights
|
|
8
8
|
|
|
9
|
-
**1.
|
|
9
|
+
**1.11.0 adds three Spider-Man-inspired dark palettes**, bringing the bundle to **23 themes**. It retains the four-page local usage analytics introduced in 1.10.0, with shared period and profile/provider/model filters, keyboard navigation, and metadata-only history. These highlights describe the 1.11.0 release source; verify npm availability and the resolved version before installing.
|
|
10
10
|
|
|
11
11
|
| Area | What ships |
|
|
12
12
|
| --- | --- |
|
|
13
|
-
|
|
|
13
|
+
| Local analytics (1.10.0) | `/osdy-usage` opens Summary, Profiles, Models, and History pages for recorded tokens and estimated cost—not billing or subscription quota. |
|
|
14
|
+
| Themes introduced in 1.11.0 | Spider-Man Classic, Miles Morales, and Spider-Verse add three distinct dark palettes. |
|
|
15
|
+
| Themes introduced in 1.9.0 | Halloween and Halloween Killer add two dark palettes. |
|
|
14
16
|
| Themes introduced in 1.8.0 | Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute. |
|
|
15
17
|
| Message cards | Pi-native colored user cards without emojis and plain assistant Markdown preceded by a separate `🦝` transcript row. |
|
|
16
18
|
| Header and mascot | A theme-aware `neon` header and selectable `bts` mascot, both responsive and persisted independently. |
|
|
@@ -38,7 +40,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
38
40
|
pi install git:github.com/OsdyOrtiz/Osdy-Pi
|
|
39
41
|
```
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
Check the resolved npm version: the Spider-Man-inspired themes require 1.11.0 or later; local analytics requires 1.10.0 or later. To select this release explicitly, use `pi install npm:osdy-pi@1.11.0` once it is available on npm. For an unpinned installation, use `pi update`; it does not move a pinned version, so replace an older pin explicitly. First-party TODO is included starting in 1.5.0; `/osdy-pi uninstall` and explicit agent provider modes start in 1.6.0. Installing Osdy does not automatically install Joker; see [explicit agent provider modes](#explicit-agent-provider-modes-in-normal-pi).
|
|
42
44
|
|
|
43
45
|
2. Start Pi:
|
|
44
46
|
|
|
@@ -46,7 +48,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
46
48
|
pi
|
|
47
49
|
```
|
|
48
50
|
|
|
49
|
-
Osdy Pi's own extension and themes are bundled—do not install them separately. Version **1.
|
|
51
|
+
Osdy Pi's own extension and themes are bundled—do not install them separately. Version **1.11.0** bundles **23 themes**, including Spider-Man Classic, Miles Morales, and Spider-Verse: Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute were introduced in 1.8.0; Halloween and Halloween Killer were added in 1.9.0. Check your installed npm version to see which themes are available. Launch through `osdy-pi` (or `npm run pi:dev`) to activate a valid Osdy default account into Pi's shared agent directory before Pi starts. With no default, Pi remains unmanaged; run `/osdy-account` to create a profile and establish a default. On session start, Osdy Pi restores its persisted enabled state when a UI is available and preserves your selected Pi theme. When disabled, it leaves Gentle Shell (or Pi's native UI) untouched.
|
|
50
52
|
|
|
51
53
|
### Optional: isolated `osdy` command
|
|
52
54
|
|
|
@@ -250,11 +252,12 @@ Before rename or removal, close this Pi process when it uses the target and **ma
|
|
|
250
252
|
|
|
251
253
|
| Area | Included behavior |
|
|
252
254
|
| --- | --- |
|
|
253
|
-
| Themes |
|
|
255
|
+
| Themes | 23 built-in themes, including Osdy, Kanagawa, Dracula, Gruvbox, Nord, Rosé Pine, Daniela Cute, Halloween, Halloween Killer, Catppuccin, Matrix, Lucent Orange, and three Spider-Man-inspired palettes |
|
|
254
256
|
| Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts` mascots |
|
|
255
257
|
| Messages | Native colored user cards without emojis and plain assistant Markdown with a separate assistant `🦝` row |
|
|
256
258
|
| Input | Responsive auto editor by default, with selectable simple Pi-native or extended framed modes |
|
|
257
259
|
| Status | Theme-aware Braille spinner and animated working label, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
|
|
260
|
+
| Local analytics | Four keyboard-selectable pages with shared filters, token charts, estimated cost, and private metadata-only history from new assistant turns |
|
|
258
261
|
| Git | Working-tree summary and a centered, filterable diff panel |
|
|
259
262
|
| Audio | Optional event sounds on macOS and Windows |
|
|
260
263
|
|
|
@@ -264,10 +267,10 @@ Before rename or removal, close this Pi process when it uses the target and **ma
|
|
|
264
267
|
|
|
265
268
|
### Themes
|
|
266
269
|
|
|
267
|
-
Version **1.
|
|
270
|
+
Version **1.11.0** contains **23 themes**, including the new Spider-Man Classic, Miles Morales, and Spider-Verse palettes. Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute were introduced in **1.8.0**; Halloween and Halloween Killer were added in **1.9.0**. To try the themes from a checkout:
|
|
268
271
|
|
|
269
|
-
1. Run `npm run pi:dev` from the 1.
|
|
270
|
-
2. Open `/settings` → **Theme** and select `osdy-pi-
|
|
272
|
+
1. Run `npm run pi:dev` from the 1.11.0 checkout, or `/reload` if that checkout is already loaded in Pi.
|
|
273
|
+
2. Open `/settings` → **Theme** and select `osdy-pi-spider-man-classic`, `osdy-pi-miles-morales`, `osdy-pi-spider-verse`, or another name below.
|
|
271
274
|
|
|
272
275
|
| Theme | Use |
|
|
273
276
|
| --- | --- |
|
|
@@ -291,6 +294,9 @@ Version **1.9.0** contains **20 themes**. Gruvbox Dark, Nord, Rosé Pine base da
|
|
|
291
294
|
| `osdy-pi-catppuccin-mocha` | Catppuccin Mocha dark palette. |
|
|
292
295
|
| `osdy-pi-matrix` | OpenCode Matrix dark palette. |
|
|
293
296
|
| `osdy-pi-lucent-orange` | Lucent Orange dark palette with terminal-background passthrough. |
|
|
297
|
+
| `osdy-pi-spider-man-classic` | Classic Peter Parker: night-blue panels, red accents, and web-white text, with blue links and distinct green success/yellow warning indicators. |
|
|
298
|
+
| `osdy-pi-miles-morales` | Miles Morales: black and charcoal panels with intense red accents, pale text, and distinct green success/yellow warning indicators. |
|
|
299
|
+
| `osdy-pi-spider-verse` | Spider-Verse: dark violet panels, red, electric-blue, and magenta highlights, with readable syntax and distinct green success/yellow warning indicators. |
|
|
294
300
|
|
|
295
301
|
Osdy Pi preserves your selected Pi theme when it enables, reapplies, or disables its UI. Choose any theme in Pi:
|
|
296
302
|
|
|
@@ -358,6 +364,7 @@ The enabled state, editor mode, working-tree visibility preference, header, and
|
|
|
358
364
|
| Diff | `/osdy-pi diff` |
|
|
359
365
|
| Package | `/osdy-pi uninstall` |
|
|
360
366
|
| Codex subscription | `/usage` |
|
|
367
|
+
| Local usage analytics (starting in 1.10.0) | `/osdy-usage` |
|
|
361
368
|
|
|
362
369
|
`/osdy-pi uninstall` finds a unique Osdy Pi package in Pi's configured package list and asks you to confirm its exact source and user/project scope before calling Pi's `remove` command. Run it from a trusted project with interactive UI. Git registrations are checked against Pi's host/path identity; a local registration must point to this running extension's package root and have a matching manifest. Ambiguous or changed registrations, unrecognized sources, and cancelled confirmation remove nothing. It removes only the Pi package registration, not Osdy profiles, accounts, the globally installed CLI, or other packages. Restart Pi afterward to unload the extension. If no unique source is found, inspect `pi list` and use `pi remove <source> [-l]` manually.
|
|
363
370
|
|
|
@@ -375,9 +382,41 @@ Usage loads once when the session starts and refreshes when the modal opens or `
|
|
|
375
382
|
|
|
376
383
|
> **Privacy:** OAuth is resolved only through Pi's `modelRegistry`. Osdy Pi does not read `auth.json`, persist or log credentials, or display account IDs, tokens, response bodies, or endpoint internals in the UI. Requests use a fixed HTTPS endpoint with bounded timeout, response size, and redirects.
|
|
377
384
|
|
|
385
|
+
### Local usage analytics (starting in 1.10.0)
|
|
386
|
+
|
|
387
|
+
Run `/osdy-usage` in interactive terminal Pi. **Summary** opens by default; use Tab / Shift+Tab or `1`–`4` to choose a focused page. Period, filters and the loaded snapshot are shared; switching pages resets scroll without reading history or making network requests.
|
|
388
|
+
|
|
389
|
+
| Page | Content |
|
|
390
|
+
| --- | --- |
|
|
391
|
+
| `1` Summary | Total tokens, recorded turns, estimated USD and compact timeline |
|
|
392
|
+
| `2` Profiles | Every profile's consumption ranking |
|
|
393
|
+
| `3` Models | Every provider/model ranking and exact input/output/cache composition of the selection; use `f` to inspect one model |
|
|
394
|
+
| `4` History | Timeline and exact local bucket values, timezone and repeated DST hours with UTC offsets |
|
|
395
|
+
|
|
396
|
+
Narrow terminals stack metrics and wrap names; every group remains reachable by scrolling. Shares use the selected total tokens; bars scale to each ranking's maximum. Loading, stale, scan-limit/warning and unpriced/partial-cost states remain on every page and in help. Sticky status lines wrap instead of concealing cost status behind scan warnings, including at End. When height is scarce, statuses take priority over optional headings, charts and footer controls; the active page remains visible when space permits. Press `?` for methodology and full controls, then `?` again to return. Choosing a page also leaves help without discarding filters.
|
|
397
|
+
|
|
398
|
+
This is separate from the unchanged `/usage` subscription quota panel. RPC, JSON and print modes receive a short notification instead of charts. The four-page UI passed independent scripted synthetic PTY checks for navigation, shared selection and responsive statuses at 12/32/100 columns, including a five-row terminal. Human visual confirmation and provider-backed capture remain unverified.
|
|
399
|
+
|
|
400
|
+
| Control | Action |
|
|
401
|
+
| --- | --- |
|
|
402
|
+
| Tab / Shift+Tab, `1` / `2` / `3` / `4` | Cycle pages with wrap; jump to Summary / Profiles / Models / History |
|
|
403
|
+
| `?` | Toggle methodology/control help; `?` returns to the selected page |
|
|
404
|
+
| `d` / `w` / `m`, left / right | Local calendar day, Monday-based week or month; previous / next range (not pages) |
|
|
405
|
+
| `p` / `v` / `f`, `x` | Cycle profile / provider / model filters; reset filters |
|
|
406
|
+
| `r`, arrows / Page Up / Page Down / Home / End | Refresh history; scroll |
|
|
407
|
+
| Esc / `q` / Ctrl+C | Close, including from help |
|
|
408
|
+
|
|
409
|
+
**Collection starts with new assistant turns begun while Osdy is enabled**, after session settings finish loading. `/osdy-pi off` stops collection; `/osdy-pi on` enables it for subsequent turns. Existing history remains viewable while disabled. No old-session import or replay/compaction backfill occurs. Processes without this extension and non-message/background usage (including summaries and cache warming) are not recorded.
|
|
410
|
+
|
|
411
|
+
**Storage and privacy:** metadata-only private JSONL shards live at `<active agent dir>/extensions/osdy-pi/usage-analytics/`: an absolute `PI_CODING_AGENT_DIR`, otherwise `~/.pi/agent/extensions/osdy-pi/usage-analytics/`. No files are created until an actual append. Same-agent profiles share history; isolated agent directories keep separate histories. Records contain timestamps, session/entry IDs, the selected Codex profile label, actual provider/model, token counts and nullable estimated cost—no prompts, responses, credentials, account IDs or session paths. Analytics never reads auth files. No automatic deletion or retention engine is provided.
|
|
412
|
+
|
|
413
|
+
Codex labels are snapshotted at turn start, not verified account identities; later switches cannot reattribute that turn. Other providers or missing labels are **unmanaged**. Rename/delete does not rewrite old labels; case-only differences share profile identity. Calendar ranges use the machine's local timezone (shown in the panel), including daylight-saving boundaries.
|
|
414
|
+
|
|
415
|
+
Costs are Pi's per-message estimates, **not quota, a provider bill, or confirmed spending**. No matching records, entirely unavailable costs and reported zero are distinct states. Partial estimates show the unpriced turn count. Zero-priced models remain zero; absent prices remain unknown. History scans are bounded and prefer recent data, so limited/corrupt coverage warnings mean totals may omit records. Write failures produce generic warnings; subsequent reads report unavailable/incomplete coverage rather than presenting those totals as complete.
|
|
416
|
+
|
|
378
417
|
## Native message cards
|
|
379
418
|
|
|
380
|
-
Pi renders the colored user card and assistant Markdown natively. User messages have no emoji; Osdy Pi places a separate `🦝` transcript entry before each assistant response with visible text, not inside message content. Previously saved user `👤` entries remain in session history but render no row on replay. Selecting only the native message keeps its body clean; a wide selection that includes the emoji row copies the emoji too. Assistant markers persist with the session but do not enter model context.
|
|
419
|
+
Pi renders the colored user card and assistant Markdown natively. User messages have no emoji; Osdy Pi places a separate `🦝` transcript entry before each assistant response with visible text, not inside message content. Previously saved user `👤` entries remain in session history but render no row on replay. Selecting only the native message keeps its body clean; a wide selection that includes the emoji row copies the emoji too. Assistant markers persist with the session but do not enter model context. Earlier checks on Pi 0.87.1 found that it did not use `assistantMessage*` theme palette keys; these keys alone should not be assumed to create a native assistant card. Osdy does not install a Markdown transformer or change streaming. The current user card colors remain unchanged. Try both roles in your selected theme with `npm run pi:dev`; terminal rendering and selection still need a live visual check. When disabled, Osdy does not add new markers.
|
|
381
420
|
|
|
382
421
|
## Editor and working indicator
|
|
383
422
|
|
|
@@ -408,8 +408,8 @@ void test("every registered theme defines native user message colors", () => {
|
|
|
408
408
|
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
|
|
409
409
|
) as { pi: { themes: string[] } };
|
|
410
410
|
|
|
411
|
-
assert.equal(manifest.pi.themes.length,
|
|
412
|
-
assert.equal(new Set(manifest.pi.themes).size,
|
|
411
|
+
assert.equal(manifest.pi.themes.length, 23);
|
|
412
|
+
assert.equal(new Set(manifest.pi.themes).size, 23, "theme registrations must be unique");
|
|
413
413
|
for (const themePath of manifest.pi.themes) {
|
|
414
414
|
const theme = JSON.parse(
|
|
415
415
|
readFileSync(
|
|
@@ -438,18 +438,21 @@ void test("new dark themes are explicitly registered with unique matching names"
|
|
|
438
438
|
return theme.name;
|
|
439
439
|
});
|
|
440
440
|
assert.equal(new Set(names).size, names.length, "theme names must be unique");
|
|
441
|
-
for (const name of ["osdy-pi-gruvbox-dark", "osdy-pi-nord", "osdy-pi-rose-pine", "osdy-pi-daniela-cute", "osdy-pi-halloween", "osdy-pi-halloween-killer"]) {
|
|
441
|
+
for (const name of ["osdy-pi-gruvbox-dark", "osdy-pi-nord", "osdy-pi-rose-pine", "osdy-pi-daniela-cute", "osdy-pi-halloween", "osdy-pi-halloween-killer", "osdy-pi-spider-man-classic", "osdy-pi-miles-morales", "osdy-pi-spider-verse"]) {
|
|
442
442
|
assert.equal(manifest.pi.themes.filter((path) => path === `./themes/${name}.json`).length, 1);
|
|
443
443
|
assert.equal(names.filter((registered) => registered === name).length, 1);
|
|
444
444
|
}
|
|
445
445
|
});
|
|
446
446
|
|
|
447
|
-
void test("README documents all
|
|
447
|
+
void test("README documents all 23 themes including three Spider-Man-inspired palettes", () => {
|
|
448
448
|
const readme = readFileSync(new URL("../../README.md", import.meta.url), "utf8");
|
|
449
|
-
assert.match(readme, /\*\*
|
|
450
|
-
assert.match(readme, /
|
|
451
|
-
assert.doesNotMatch(readme, /\
|
|
452
|
-
assert.equal(readme.match(/^\| `osdy-pi-[^`]+` \|/gm)?.length,
|
|
449
|
+
assert.match(readme, /\*\*23 themes\*\*/);
|
|
450
|
+
assert.match(readme, /23 built-in themes/);
|
|
451
|
+
assert.doesNotMatch(readme, /\b(?:18|20) (?:built-in )?themes\b/);
|
|
452
|
+
assert.equal(readme.match(/^\| `osdy-pi-[^`]+` \|/gm)?.length, 23);
|
|
453
|
+
for (const name of ["osdy-pi-spider-man-classic", "osdy-pi-miles-morales", "osdy-pi-spider-verse"]) {
|
|
454
|
+
assert.ok(readme.includes(`| \`${name}\` |`), `${name}: documented theme row`);
|
|
455
|
+
}
|
|
453
456
|
assert.match(readme, /\| `osdy-pi-daniela-cute` \| Daniela Cute/);
|
|
454
457
|
assert.match(readme, /\| `osdy-pi-halloween` \|.*[Pp]umpkin.*violet.*lime/);
|
|
455
458
|
assert.match(readme, /\| `osdy-pi-halloween-killer` \|.*[Gg]othic.*blood.red.*violet/);
|
|
@@ -62,6 +62,7 @@ import { registerTodosCommand } from "./todo-command.js";
|
|
|
62
62
|
import { createTodoSessionStore } from "./todo-session.js";
|
|
63
63
|
import { registerMessageRoleMarkers } from "./message-role-markers.js";
|
|
64
64
|
import { registerTodoWidget } from "./todo-widget.js";
|
|
65
|
+
import { registerUsageAnalytics } from "./usage-analytics.js";
|
|
65
66
|
|
|
66
67
|
function scheduleOsdyRefresh(
|
|
67
68
|
delayMs: number,
|
|
@@ -946,6 +947,7 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
|
|
|
946
947
|
| ReturnType<typeof createResponsiveCoordinator>
|
|
947
948
|
| undefined;
|
|
948
949
|
let sessionContext: ExtensionContext | undefined;
|
|
950
|
+
let usageSettingsReady = false;
|
|
949
951
|
let codexUsageAbort: AbortController | undefined;
|
|
950
952
|
const refreshCurrentCodexUsage = async (
|
|
951
953
|
ctx: ExtensionContext,
|
|
@@ -1026,6 +1028,7 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
|
|
|
1026
1028
|
}
|
|
1027
1029
|
});
|
|
1028
1030
|
pi.on("session_shutdown", () => {
|
|
1031
|
+
usageSettingsReady = false;
|
|
1029
1032
|
codexUsageAbort?.abort();
|
|
1030
1033
|
codexUsageAbort = undefined;
|
|
1031
1034
|
state.codexUsage = { kind: "idle" };
|
|
@@ -1036,6 +1039,7 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
|
|
|
1036
1039
|
sessionContext = undefined;
|
|
1037
1040
|
});
|
|
1038
1041
|
pi.on("session_start", async (_event, ctx) => {
|
|
1042
|
+
usageSettingsReady = false;
|
|
1039
1043
|
cancelOsdyRefreshes();
|
|
1040
1044
|
stopResponsive();
|
|
1041
1045
|
sessionContext = ctx;
|
|
@@ -1043,6 +1047,7 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
|
|
|
1043
1047
|
if (sessionContext !== ctx) return;
|
|
1044
1048
|
state.fallbackEditorFactory = ctx.ui.getEditorComponent();
|
|
1045
1049
|
state.enabled = editorSettings.enabled;
|
|
1050
|
+
usageSettingsReady = true;
|
|
1046
1051
|
state.editorMode = editorSettings.editorMode;
|
|
1047
1052
|
state.workingTreeEnabled = editorSettings.workingTreeEnabled;
|
|
1048
1053
|
state.headerVariant = editorSettings.headerVariant;
|
|
@@ -1065,6 +1070,7 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
|
|
|
1065
1070
|
void refreshCurrentCodexUsage(ctx);
|
|
1066
1071
|
});
|
|
1067
1072
|
|
|
1073
|
+
registerUsageAnalytics(pi, { isEnabled: () => usageSettingsReady && state.enabled });
|
|
1068
1074
|
registerUsageCommand(
|
|
1069
1075
|
pi,
|
|
1070
1076
|
state,
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
export interface UsageRecord {
|
|
2
|
+
version: 1;
|
|
3
|
+
timestamp: number;
|
|
4
|
+
sessionId: string;
|
|
5
|
+
entryId: string;
|
|
6
|
+
profile: string | null;
|
|
7
|
+
provider: string;
|
|
8
|
+
model: string;
|
|
9
|
+
input: number;
|
|
10
|
+
output: number;
|
|
11
|
+
cacheRead: number;
|
|
12
|
+
cacheWrite: number;
|
|
13
|
+
estimatedCost: number | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function text(value: unknown): value is string {
|
|
17
|
+
return typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\p{Cc}\p{Cf}]/u.test(value);
|
|
18
|
+
}
|
|
19
|
+
function identifier(value: unknown): value is string {
|
|
20
|
+
return text(value) && !value.includes("/") && !value.includes("\\");
|
|
21
|
+
}
|
|
22
|
+
function count(value: unknown): value is number {
|
|
23
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
24
|
+
}
|
|
25
|
+
function validProfile(value: unknown): value is string | null {
|
|
26
|
+
return value === null || (
|
|
27
|
+
typeof value === "string" &&
|
|
28
|
+
/^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/.test(value) &&
|
|
29
|
+
!["default", "profiles", "auth.json"].includes(value.toLowerCase())
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Narrow unknown input and copy only approved metadata; absent cost means unavailable. */
|
|
34
|
+
export function parseUsageRecord(value: unknown): UsageRecord | null {
|
|
35
|
+
if (typeof value !== "object" || value === null) return null;
|
|
36
|
+
if (
|
|
37
|
+
!("version" in value) || value.version !== 1 ||
|
|
38
|
+
!("timestamp" in value) || !count(value.timestamp) || value.timestamp > 8.64e15
|
|
39
|
+
) return null;
|
|
40
|
+
if (
|
|
41
|
+
!("sessionId" in value) || !identifier(value.sessionId) ||
|
|
42
|
+
!("entryId" in value) || !identifier(value.entryId)
|
|
43
|
+
) return null;
|
|
44
|
+
if (
|
|
45
|
+
!("profile" in value) || !validProfile(value.profile) ||
|
|
46
|
+
!("provider" in value) || !text(value.provider) ||
|
|
47
|
+
!("model" in value) || !text(value.model)
|
|
48
|
+
) return null;
|
|
49
|
+
if (
|
|
50
|
+
!("input" in value) || !count(value.input) ||
|
|
51
|
+
!("output" in value) || !count(value.output) ||
|
|
52
|
+
!("cacheRead" in value) || !count(value.cacheRead) ||
|
|
53
|
+
!("cacheWrite" in value) || !count(value.cacheWrite)
|
|
54
|
+
) return null;
|
|
55
|
+
const cost = "estimatedCost" in value ? value.estimatedCost : null;
|
|
56
|
+
if (cost !== null && cost !== undefined && (
|
|
57
|
+
typeof cost !== "number" || !Number.isFinite(cost) || cost < 0 || cost > Number.MAX_SAFE_INTEGER
|
|
58
|
+
)) return null;
|
|
59
|
+
return {
|
|
60
|
+
version: 1,
|
|
61
|
+
timestamp: value.timestamp,
|
|
62
|
+
sessionId: value.sessionId,
|
|
63
|
+
entryId: value.entryId,
|
|
64
|
+
profile: value.profile,
|
|
65
|
+
provider: value.provider,
|
|
66
|
+
model: value.model,
|
|
67
|
+
input: value.input,
|
|
68
|
+
output: value.output,
|
|
69
|
+
cacheRead: value.cacheRead,
|
|
70
|
+
cacheWrite: value.cacheWrite,
|
|
71
|
+
estimatedCost: cost ?? null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Tagged keys prevent a named "unmanaged" profile colliding with missing attribution. */
|
|
76
|
+
export function profileKey(profile: string | null): string {
|
|
77
|
+
if (!validProfile(profile)) throw new Error("Invalid profile label");
|
|
78
|
+
return profile === null ? "unmanaged:" : `profile:${profile.toLowerCase()}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type UsagePeriodKind = "day" | "week" | "month";
|
|
82
|
+
export interface UsagePeriod { kind: UsagePeriodKind; start: number; end: number }
|
|
83
|
+
|
|
84
|
+
function civilMidnight(year: number, month: number, day: number): Date {
|
|
85
|
+
// Build each boundary independently: a skipped midnight may normalize to 01:00,
|
|
86
|
+
// but that hour must not carry into the next civil date. setFullYear handles 0–99.
|
|
87
|
+
const boundary = new Date(0);
|
|
88
|
+
boundary.setFullYear(year, month, day);
|
|
89
|
+
boundary.setHours(0, 0, 0, 0);
|
|
90
|
+
return boundary;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function usagePeriod(kind: UsagePeriodKind, anchor: Date): UsagePeriod {
|
|
94
|
+
if (!["day", "week", "month"].includes(kind) || !Number.isFinite(anchor.getTime())) throw new Error("Invalid calendar period");
|
|
95
|
+
const year = anchor.getFullYear();
|
|
96
|
+
const month = anchor.getMonth();
|
|
97
|
+
const day = kind === "month" ? 1 : anchor.getDate() - (kind === "week" ? (anchor.getDay() + 6) % 7 : 0);
|
|
98
|
+
const start = civilMidnight(year, month, day);
|
|
99
|
+
const end = kind === "month" ? civilMidnight(year, month + 1, 1) : civilMidnight(year, month, day + (kind === "week" ? 7 : 1));
|
|
100
|
+
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) throw new Error("Calendar period out of range");
|
|
101
|
+
return { kind, start: start.getTime(), end: end.getTime() };
|
|
102
|
+
}
|
|
103
|
+
function validatePeriod(period: UsagePeriod): void {
|
|
104
|
+
const canonical = usagePeriod(period.kind, new Date(period.start));
|
|
105
|
+
if (canonical.start !== period.start || canonical.end !== period.end) throw new Error("Invalid calendar period boundaries");
|
|
106
|
+
}
|
|
107
|
+
export function adjacentPeriod(period: UsagePeriod, direction: -1 | 1): UsagePeriod {
|
|
108
|
+
validatePeriod(period);
|
|
109
|
+
if (direction !== -1 && direction !== 1) throw new Error("Invalid navigation direction");
|
|
110
|
+
const anchor = new Date(period.start);
|
|
111
|
+
if (period.kind === "month") anchor.setMonth(anchor.getMonth() + direction);
|
|
112
|
+
else anchor.setDate(anchor.getDate() + direction * (period.kind === "week" ? 7 : 1));
|
|
113
|
+
return usagePeriod(period.kind, anchor);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface UsageTotals {
|
|
117
|
+
records: number;
|
|
118
|
+
input: number;
|
|
119
|
+
output: number;
|
|
120
|
+
cacheRead: number;
|
|
121
|
+
cacheWrite: number;
|
|
122
|
+
/** Recorded estimates, never authoritative billing or subscription quota. */
|
|
123
|
+
estimatedCost: number;
|
|
124
|
+
unavailableCostRecords: number;
|
|
125
|
+
}
|
|
126
|
+
export interface UsageBucket { start: number; end: number; totals: UsageTotals }
|
|
127
|
+
export interface UsageProfileGroup { key: string; profile: string | null; totals: UsageTotals }
|
|
128
|
+
export interface UsageModelGroup { provider: string; model: string; totals: UsageTotals }
|
|
129
|
+
export interface UsageFilter { profile?: string | null; provider?: string; model?: string }
|
|
130
|
+
export interface UsageAggregation {
|
|
131
|
+
period: UsagePeriod;
|
|
132
|
+
totals: UsageTotals;
|
|
133
|
+
buckets: UsageBucket[];
|
|
134
|
+
profiles: UsageProfileGroup[];
|
|
135
|
+
models: UsageModelGroup[];
|
|
136
|
+
}
|
|
137
|
+
function emptyTotals(): UsageTotals {
|
|
138
|
+
return { records: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, estimatedCost: 0, unavailableCostRecords: 0 };
|
|
139
|
+
}
|
|
140
|
+
function add(totals: UsageTotals, record: UsageRecord): void {
|
|
141
|
+
totals.records++;
|
|
142
|
+
totals.input += record.input;
|
|
143
|
+
totals.output += record.output;
|
|
144
|
+
totals.cacheRead += record.cacheRead;
|
|
145
|
+
totals.cacheWrite += record.cacheWrite;
|
|
146
|
+
if (record.estimatedCost === null) totals.unavailableCostRecords++;
|
|
147
|
+
else totals.estimatedCost += record.estimatedCost;
|
|
148
|
+
for (const value of [totals.records, totals.input, totals.output, totals.cacheRead, totals.cacheWrite, totals.estimatedCost]) {
|
|
149
|
+
if (!Number.isFinite(value) || value > Number.MAX_SAFE_INTEGER) throw new Error("Usage totals exceed safe numeric range");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function compare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; }
|
|
153
|
+
|
|
154
|
+
/** Local timezone calendar boundaries, half-open intervals, and explicit empty buckets.
|
|
155
|
+
* Repeated fall-back hours are separate timestamped buckets; missing spring hours are absent.
|
|
156
|
+
*/
|
|
157
|
+
export function aggregateUsage(records: readonly UsageRecord[], period: UsagePeriod, filter: UsageFilter = {}): UsageAggregation {
|
|
158
|
+
validatePeriod(period);
|
|
159
|
+
if (filter.profile !== undefined) profileKey(filter.profile);
|
|
160
|
+
if ((filter.provider !== undefined && !text(filter.provider)) || (filter.model !== undefined && !text(filter.model))) throw new Error("Invalid model filter");
|
|
161
|
+
const buckets: UsageBucket[] = [];
|
|
162
|
+
let cursor = period.start;
|
|
163
|
+
while (cursor < period.end) {
|
|
164
|
+
const next = new Date(cursor);
|
|
165
|
+
// Local hour boundaries realign after fractional-hour DST jumps; elapsed hours
|
|
166
|
+
// retain repeated fall-back hours instead of skipping straight to the next label.
|
|
167
|
+
if (period.kind === "day") {
|
|
168
|
+
next.setHours(next.getHours() + 1, 0, 0, 0);
|
|
169
|
+
next.setTime(Math.min(next.getTime(), cursor + 3600000));
|
|
170
|
+
} else next.setTime(civilMidnight(next.getFullYear(), next.getMonth(), next.getDate() + 1).getTime());
|
|
171
|
+
const end = Math.min(next.getTime(), period.end);
|
|
172
|
+
buckets.push({ start: cursor, end, totals: emptyTotals() });
|
|
173
|
+
cursor = end;
|
|
174
|
+
}
|
|
175
|
+
const totals = emptyTotals();
|
|
176
|
+
const profiles = new Map<string, UsageProfileGroup>();
|
|
177
|
+
const models = new Map<string, UsageModelGroup>();
|
|
178
|
+
for (const input of records) {
|
|
179
|
+
const record = parseUsageRecord(input);
|
|
180
|
+
if (!record) throw new Error("Invalid usage record");
|
|
181
|
+
if (record.timestamp < period.start || record.timestamp >= period.end) continue;
|
|
182
|
+
const key = profileKey(record.profile);
|
|
183
|
+
if (filter.profile !== undefined && key !== profileKey(filter.profile)) continue;
|
|
184
|
+
if (filter.provider !== undefined && record.provider !== filter.provider) continue;
|
|
185
|
+
if (filter.model !== undefined && record.model !== filter.model) continue;
|
|
186
|
+
add(totals, record);
|
|
187
|
+
const bucket = buckets.find((item) => record.timestamp >= item.start && record.timestamp < item.end);
|
|
188
|
+
if (bucket) add(bucket.totals, record);
|
|
189
|
+
let profile = profiles.get(key);
|
|
190
|
+
if (!profile) {
|
|
191
|
+
profile = { key, profile: record.profile, totals: emptyTotals() };
|
|
192
|
+
profiles.set(key, profile);
|
|
193
|
+
}
|
|
194
|
+
add(profile.totals, record);
|
|
195
|
+
const modelKey = JSON.stringify([record.provider, record.model]);
|
|
196
|
+
let model = models.get(modelKey);
|
|
197
|
+
if (!model) {
|
|
198
|
+
model = { provider: record.provider, model: record.model, totals: emptyTotals() };
|
|
199
|
+
models.set(modelKey, model);
|
|
200
|
+
}
|
|
201
|
+
add(model.totals, record);
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
period: { ...period }, totals, buckets,
|
|
205
|
+
profiles: [...profiles.values()].sort((a, b) => compare(a.key, b.key)),
|
|
206
|
+
models: [...models.values()].sort((a, b) => compare(a.provider, b.provider) || compare(a.model, b.model)),
|
|
207
|
+
};
|
|
208
|
+
}
|