osdy-pi 1.8.0 → 1.10.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 +47 -9
- package/extensions/osdy-pi/runtime.test.ts +10 -6
- 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 +3 -1
- package/themes/osdy-pi-halloween-killer.json +87 -0
- package/themes/osdy-pi-halloween.json +87 -0
package/README.md
CHANGED
|
@@ -6,11 +6,13 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
6
6
|
|
|
7
7
|
## Release highlights
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**1.10.0 adds four-page local usage analytics** with shared period and profile/provider/model filters, keyboard navigation, and metadata-only history. The bundle retains **20 themes**. These highlights describe the 1.10.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.9.0 | Halloween and Halloween Killer add two dark palettes, bringing the bundle to 20 themes. |
|
|
15
|
+
| Themes introduced in 1.8.0 | Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute. |
|
|
14
16
|
| Message cards | Pi-native colored user cards without emojis and plain assistant Markdown preceded by a separate `🦝` transcript row. |
|
|
15
17
|
| Header and mascot | A theme-aware `neon` header and selectable `bts` mascot, both responsive and persisted independently. |
|
|
16
18
|
| Accounts | `/osdy-account` switches Codex profiles in place with atomic activation, rollback, bounded auth files, and process-safe locks. |
|
|
@@ -37,7 +39,7 @@ The **1.8.0 release candidate** includes the changes below; npm publication is n
|
|
|
37
39
|
pi install git:github.com/OsdyOrtiz/Osdy-Pi
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
Check the resolved npm version: local analytics requires 1.10.0 or later. Once 1.10.0 is available on npm, `pi install npm:osdy-pi@1.10.0` selects it explicitly. 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).
|
|
41
43
|
|
|
42
44
|
2. Start Pi:
|
|
43
45
|
|
|
@@ -45,7 +47,7 @@ The **1.8.0 release candidate** includes the changes below; npm publication is n
|
|
|
45
47
|
pi
|
|
46
48
|
```
|
|
47
49
|
|
|
48
|
-
Osdy Pi's own extension and themes are bundled—do not install them separately.
|
|
50
|
+
Osdy Pi's own extension and themes are bundled—do not install them separately. Version **1.10.0** bundles **20 themes**: Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute were introduced in 1.8.0; Halloween and Halloween Killer are 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.
|
|
49
51
|
|
|
50
52
|
### Optional: isolated `osdy` command
|
|
51
53
|
|
|
@@ -249,11 +251,12 @@ Before rename or removal, close this Pi process when it uses the target and **ma
|
|
|
249
251
|
|
|
250
252
|
| Area | Included behavior |
|
|
251
253
|
| --- | --- |
|
|
252
|
-
| Themes |
|
|
254
|
+
| Themes | 20 built-in themes, including Osdy, Kanagawa, Dracula, Gruvbox, Nord, Rosé Pine, Daniela Cute, Halloween, Halloween Killer, Catppuccin, Matrix, and Lucent Orange palettes |
|
|
253
255
|
| Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts` mascots |
|
|
254
256
|
| Messages | Native colored user cards without emojis and plain assistant Markdown with a separate assistant `🦝` row |
|
|
255
257
|
| Input | Responsive auto editor by default, with selectable simple Pi-native or extended framed modes |
|
|
256
258
|
| Status | Theme-aware Braille spinner and animated working label, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
|
|
259
|
+
| Local analytics | Four keyboard-selectable pages with shared filters, token charts, estimated cost, and private metadata-only history from new assistant turns |
|
|
257
260
|
| Git | Working-tree summary and a centered, filterable diff panel |
|
|
258
261
|
| Audio | Optional event sounds on macOS and Windows |
|
|
259
262
|
|
|
@@ -263,10 +266,10 @@ Before rename or removal, close this Pi process when it uses the target and **ma
|
|
|
263
266
|
|
|
264
267
|
### Themes
|
|
265
268
|
|
|
266
|
-
|
|
269
|
+
Version **1.10.0** contains **20 themes**. 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:
|
|
267
270
|
|
|
268
|
-
1. Run `npm run pi:dev` from the 1.
|
|
269
|
-
2. Open `/settings` → **Theme** and select `osdy-pi-gruvbox-dark`, `osdy-pi-nord`, `osdy-pi-rose-pine`, `osdy-pi-daniela-cute`, or another name below.
|
|
271
|
+
1. Run `npm run pi:dev` from the 1.10.0 checkout, or `/reload` if that checkout is already loaded in Pi.
|
|
272
|
+
2. Open `/settings` → **Theme** and select `osdy-pi-gruvbox-dark`, `osdy-pi-nord`, `osdy-pi-rose-pine`, `osdy-pi-daniela-cute`, `osdy-pi-halloween`, `osdy-pi-halloween-killer`, or another name below.
|
|
270
273
|
|
|
271
274
|
| Theme | Use |
|
|
272
275
|
| --- | --- |
|
|
@@ -282,6 +285,8 @@ The bundle contains **18 themes**; Gruvbox Dark, Nord, Rosé Pine base dark, and
|
|
|
282
285
|
| `osdy-pi-nord` | [Nord](https://www.nordtheme.com/docs/colors-and-palettes) dark palette: cool, restrained Polar Night, Snow Storm, Frost, and Aurora colors. |
|
|
283
286
|
| `osdy-pi-rose-pine` | [Rosé Pine](https://rosepinetheme.com/palette/ingredients/) base dark palette: soft colors, not Moon or Dawn. |
|
|
284
287
|
| `osdy-pi-daniela-cute` | Daniela Cute: navy panels/export page (`#0B1220`), blue cards (`#111F33`), borders (`#253A55`), blue accents (`#3584E4`), main text (`#D8E2EF`), secondary text (`#93A6BE`), and selection (`#193655`). Distinct status and syntax colors remain; the terminal background remains unchanged. |
|
|
288
|
+
| `osdy-pi-halloween` | Pumpkin orange, spectral violet, and lime accents on dark plum panels, with warm text and distinct success/error colors. |
|
|
289
|
+
| `osdy-pi-halloween-killer` | Gothic/slasher dark palette: near-black panels, blood-red and violet accents, bone-colored text, and sage success indicators distinct from red errors. |
|
|
285
290
|
| `osdy-pi-catppuccin-latte` | Catppuccin Latte light palette. |
|
|
286
291
|
| `osdy-pi-catppuccin-frappe` | Catppuccin Frappé dark palette. |
|
|
287
292
|
| `osdy-pi-catppuccin-macchiato` | Catppuccin Macchiato dark palette. |
|
|
@@ -355,6 +360,7 @@ The enabled state, editor mode, working-tree visibility preference, header, and
|
|
|
355
360
|
| Diff | `/osdy-pi diff` |
|
|
356
361
|
| Package | `/osdy-pi uninstall` |
|
|
357
362
|
| Codex subscription | `/usage` |
|
|
363
|
+
| Local usage analytics (starting in 1.10.0) | `/osdy-usage` |
|
|
358
364
|
|
|
359
365
|
`/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.
|
|
360
366
|
|
|
@@ -372,9 +378,41 @@ Usage loads once when the session starts and refreshes when the modal opens or `
|
|
|
372
378
|
|
|
373
379
|
> **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.
|
|
374
380
|
|
|
381
|
+
### Local usage analytics (starting in 1.10.0)
|
|
382
|
+
|
|
383
|
+
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.
|
|
384
|
+
|
|
385
|
+
| Page | Content |
|
|
386
|
+
| --- | --- |
|
|
387
|
+
| `1` Summary | Total tokens, recorded turns, estimated USD and compact timeline |
|
|
388
|
+
| `2` Profiles | Every profile's consumption ranking |
|
|
389
|
+
| `3` Models | Every provider/model ranking and exact input/output/cache composition of the selection; use `f` to inspect one model |
|
|
390
|
+
| `4` History | Timeline and exact local bucket values, timezone and repeated DST hours with UTC offsets |
|
|
391
|
+
|
|
392
|
+
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.
|
|
393
|
+
|
|
394
|
+
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.
|
|
395
|
+
|
|
396
|
+
| Control | Action |
|
|
397
|
+
| --- | --- |
|
|
398
|
+
| Tab / Shift+Tab, `1` / `2` / `3` / `4` | Cycle pages with wrap; jump to Summary / Profiles / Models / History |
|
|
399
|
+
| `?` | Toggle methodology/control help; `?` returns to the selected page |
|
|
400
|
+
| `d` / `w` / `m`, left / right | Local calendar day, Monday-based week or month; previous / next range (not pages) |
|
|
401
|
+
| `p` / `v` / `f`, `x` | Cycle profile / provider / model filters; reset filters |
|
|
402
|
+
| `r`, arrows / Page Up / Page Down / Home / End | Refresh history; scroll |
|
|
403
|
+
| Esc / `q` / Ctrl+C | Close, including from help |
|
|
404
|
+
|
|
405
|
+
**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.
|
|
406
|
+
|
|
407
|
+
**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.
|
|
408
|
+
|
|
409
|
+
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.
|
|
410
|
+
|
|
411
|
+
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.
|
|
412
|
+
|
|
375
413
|
## Native message cards
|
|
376
414
|
|
|
377
|
-
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.
|
|
415
|
+
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.
|
|
378
416
|
|
|
379
417
|
## Editor and working indicator
|
|
380
418
|
|
|
@@ -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, 20);
|
|
412
|
+
assert.equal(new Set(manifest.pi.themes).size, 20, "theme registrations must be unique");
|
|
413
413
|
for (const themePath of manifest.pi.themes) {
|
|
414
414
|
const theme = JSON.parse(
|
|
415
415
|
readFileSync(
|
|
@@ -438,17 +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"]) {
|
|
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"]) {
|
|
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 20 themes including both Halloween palettes", () => {
|
|
448
448
|
const readme = readFileSync(new URL("../../README.md", import.meta.url), "utf8");
|
|
449
|
-
assert.match(readme, /\*\*
|
|
450
|
-
assert.match(readme, /
|
|
449
|
+
assert.match(readme, /\*\*20 themes\*\*/);
|
|
450
|
+
assert.match(readme, /20 built-in themes/);
|
|
451
|
+
assert.doesNotMatch(readme, /\b18 (?:built-in )?themes\b/);
|
|
452
|
+
assert.equal(readme.match(/^\| `osdy-pi-[^`]+` \|/gm)?.length, 20);
|
|
451
453
|
assert.match(readme, /\| `osdy-pi-daniela-cute` \| Daniela Cute/);
|
|
454
|
+
assert.match(readme, /\| `osdy-pi-halloween` \|.*[Pp]umpkin.*violet.*lime/);
|
|
455
|
+
assert.match(readme, /\| `osdy-pi-halloween-killer` \|.*[Gg]othic.*blood.red.*violet/);
|
|
452
456
|
});
|
|
453
457
|
|
|
454
458
|
void test("Tokyo Night retains its approved native user card colors", () => {
|
|
@@ -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
|
+
}
|