@oh-my-pi/omp-stats 18.10.3 → 18.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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.11.0] - 2026-10-14
6
+
7
+ ### Fixed
8
+
9
+ - Providers → Subscription windows no longer mixes Codex 5-hour and 7-day quotas across plans: Team and weekly-only (ProLite) accounts now share the 7-day row, the 5-hour row counts only accounts that have one, and labels no longer flip with whichever account reported last ([#15664](https://github.com/can1357/oh-my-pi/issues/15664))
10
+
11
+ ## [18.10.4] - 2026-10-13
12
+
13
+ ### Fixed
14
+
15
+ - Usage recorded at $0 for a model whose catalog price changes on a date (Gemini 4 Argon, Devin SWE-2) is now priced at the rate in force on the request's date, instead of staying $0 like peak/off-peak tariff usage ([#15620](https://github.com/can1357/oh-my-pi/pull/15620) by [@eggpeat](https://github.com/eggpeat))
16
+
5
17
  ## [18.9.0] - 2026-10-11
6
18
 
7
19
  ### Fixed
@@ -75,7 +75,17 @@ export declare function parseSessionFile(sessionPath: string, fromOffset?: numbe
75
75
  */
76
76
  export declare function listSessionFolders(): Promise<string[]>;
77
77
  /**
78
- * List all session files in a folder.
78
+ * Name prefix of the scratch directories `omp gc --compress` creates beside
79
+ * the files it copies on APFS. Their `.jsonl` files are transient copies of
80
+ * transcripts, never transcripts of their own; a killed run can leave one
81
+ * behind until the next `omp gc --compress --apply` removes it.
82
+ */
83
+ export declare const COMPRESS_SCRATCH_PREFIX = ".omp-gc-compress-";
84
+ /** Whether `relativePath` (from any ancestor directory) lies inside a compress scratch directory. */
85
+ export declare function isCompressScratchPath(relativePath: string): boolean;
86
+ /**
87
+ * List all session files in a folder, excluding copies inside
88
+ * `omp gc --compress` scratch directories.
79
89
  */
80
90
  export declare function listSessionFiles(folderPath: string): Promise<string[]>;
81
91
  /**
@@ -409,7 +409,7 @@ export interface UsageWindowSeries {
409
409
  accountKey: string;
410
410
  /** Email/account id when known, else the stable account key. */
411
411
  accountLabel: string;
412
- /** Groups the same limit window across accounts (the provider limit id). */
412
+ /** Groups the same limit window across accounts: the limit id, or quota family + window for positional Codex slots. */
413
413
  windowKey: string;
414
414
  /** Human label of the limit (distinguishes same-duration windows). */
415
415
  windowLabel: string;
@@ -422,7 +422,7 @@ export interface UsageWindowSeries {
422
422
  */
423
423
  export interface ProviderWindowInsight {
424
424
  provider: string;
425
- /** Groups the same limit window across accounts (the provider limit id). */
425
+ /** Groups the same limit window across accounts: the limit id, or quota family + window for positional Codex slots. */
426
426
  windowKey: string;
427
427
  /** Human label of the limit (distinguishes same-duration windows). */
428
428
  windowLabel: string;
@@ -57,10 +57,12 @@ export declare function sumFleetTokens(clients: ClientUsageClientSummary[]): Map
57
57
  /**
58
58
  * Derive utilization series and per-window insights from raw snapshots.
59
59
  *
60
- * Windows are grouped by `(provider, limitId)` — never by display label.
61
- * Distinct limits can share a duration label (Anthropic's overall and
62
- * model-scoped 7-day windows, Codex's base and Spark weeklies); merging them
63
- * interleaves unrelated fractions per account and wildly inflates consumption.
60
+ * Windows are grouped by `(provider, windowKeyOf(row))` — never by display
61
+ * label alone. Distinct limits can share a duration label (Anthropic's overall
62
+ * and model-scoped 7-day windows, Codex's base and Spark weeklies); merging
63
+ * them interleaves unrelated fractions per account and wildly inflates
64
+ * consumption. Conversely, one window reported under different positional
65
+ * slots by different accounts must merge (see {@link windowKeyOf}).
64
66
  *
65
67
  * `tokensByProvider` supplies each provider's token burn over the same time
66
68
  * range (fleet-wide broker client reports when available, else local message
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "18.10.3",
4
+ "version": "18.11.0",
5
5
  "description": "Local observability dashboard for omp AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -42,9 +42,9 @@
42
42
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
43
43
  },
44
44
  "dependencies": {
45
- "@oh-my-pi/pi-ai": "18.10.3",
46
- "@oh-my-pi/pi-catalog": "18.10.3",
47
- "@oh-my-pi/pi-utils": "18.10.3",
45
+ "@oh-my-pi/pi-ai": "18.11.0",
46
+ "@oh-my-pi/pi-catalog": "18.11.0",
47
+ "@oh-my-pi/pi-utils": "18.11.0",
48
48
  "lucide-react": "^1.24.0",
49
49
  "react": "19.2.7",
50
50
  "react-dom": "19.2.7"
package/src/db.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  calculateUsageCost,
7
7
  type GeneratedProvider,
8
8
  getBundledModel,
9
+ hasRecurringTariff,
9
10
  } from "@oh-my-pi/pi-catalog/models";
10
11
  import type { ModelCost } from "@oh-my-pi/pi-catalog/types";
11
12
  import { getConfigRootDir, getStatsDbPath, VERSION } from "@oh-my-pi/pi-utils";
@@ -463,9 +464,11 @@ function resolveStoredCost(stats: MessageStatsInput): ResolvedCost {
463
464
  const storedCost = raw ? normalizeUsageCost(raw) : undefined;
464
465
  const catalogCost = getCatalogCost(stats.provider, stats.model);
465
466
 
466
- // Scheduled prices are frozen per request, including explicitly free usage.
467
- // Preserve legacy zero-cost subscription correction for unscheduled models.
468
- if (storedCost && Number.isFinite(storedCost.total) && (storedCost.total !== 0 || catalogCost?.timeBased)) {
467
+ // Recurring-tariff prices are frozen per request, including explicitly free
468
+ // usage. Preserve legacy zero-cost subscription correction for other models;
469
+ // dated rate cards reprice it at the card for the request's timestamp.
470
+ const recurring = catalogCost?.timeBased !== undefined && hasRecurringTariff(catalogCost.timeBased);
471
+ if (storedCost && Number.isFinite(storedCost.total) && (storedCost.total !== 0 || recurring)) {
469
472
  return { cost: storedCost, unpriced: false };
470
473
  }
471
474
 
@@ -528,12 +531,14 @@ function backfillMissingCatalogCosts(database: Database): void {
528
531
  const applyBackfill = database.transaction(() => {
529
532
  for (const row of rows) {
530
533
  // A stored zero cannot distinguish missing historical prices from an
531
- // explicitly free request. Never reprice recorded scheduled usage: a
532
- // zero ingested for a scheduled card is permanent, because this
534
+ // explicitly free request. Never reprice recorded recurring-tariff
535
+ // usage: a zero ingested for such a card is permanent, because this
533
536
  // backfill skips those rows. Re-parsing the session file is the only
534
537
  // repair, and it works only once the entry's absent `cost` reaches
535
538
  // `resolveStoredCost` as absence rather than as a synthesized zero.
536
- if (getCatalogCost(row.provider, row.model)?.timeBased) continue;
539
+ // Dated rate cards need the row's timestamp to pick their card.
540
+ const schedule = getCatalogCost(row.provider, row.model)?.timeBased;
541
+ if (schedule && (hasRecurringTariff(schedule) || !hasRequestTimestamp(row.timestamp))) continue;
537
542
  const cost = calculateCatalogCost(
538
543
  row.provider,
539
544
  row.model,
@@ -19,6 +19,7 @@
19
19
  import { type Judge, judgeQuestions, type Model } from "@oh-my-pi/pi-ai";
20
20
  import { compareRevision, parseRevision } from "@oh-my-pi/pi-catalog/compat/revision";
21
21
  import { classifyModel } from "@oh-my-pi/pi-catalog/compat/taxonomy";
22
+ import { getEffectiveTokenCost } from "@oh-my-pi/pi-catalog/models";
22
23
  import { logger } from "@oh-my-pi/pi-utils";
23
24
  import { getTimeRangeConfig } from "./aggregator";
24
25
  import {
@@ -214,8 +215,8 @@ export async function estimateFrustrationRun(range?: string | null): Promise<Fru
214
215
  const { cutoff } = getTimeRangeConfig(range);
215
216
  const { messages, chars } = getPendingFrustrationTotals(cutoff);
216
217
  const inputTokens = messages * REQUEST_OVERHEAD_TOKENS + Math.ceil(chars / CHARS_PER_TOKEN);
217
- const cost =
218
- (inputTokens * model.cost.input) / 1e6 + (messages * OUTPUT_TOKENS_PER_REQUEST * model.cost.output) / 1e6;
218
+ const rates = getEffectiveTokenCost(model.cost);
219
+ const cost = (inputTokens * rates.input) / 1e6 + (messages * OUTPUT_TOKENS_PER_REQUEST * rates.output) / 1e6;
219
220
  return { available: true, messages, chars, inputTokens, cost, judge: `${model.provider}/${model.id}` };
220
221
  }
221
222
 
package/src/live.ts CHANGED
@@ -15,6 +15,7 @@ import * as path from "node:path";
15
15
  import { getSessionsDir, logger } from "@oh-my-pi/pi-utils";
16
16
  import { syncAllSessions } from "./aggregator";
17
17
  import { getDataVersion, initDb } from "./db";
18
+ import { isCompressScratchPath } from "./parser";
18
19
  import { getRollupStatus, refreshRollups } from "./rollup";
19
20
  import type { LiveStatus, LiveSyncStatus } from "./shared-types";
20
21
 
@@ -231,7 +232,7 @@ export class StatsLive {
231
232
  const pending = new Set<string>();
232
233
  try {
233
234
  this.#watcher = fs.watch(root, { recursive: true }, (_event, filename) => {
234
- if (!filename?.endsWith(".jsonl")) return;
235
+ if (!filename?.endsWith(".jsonl") || isCompressScratchPath(filename)) return;
235
236
  pending.add(path.join(root, filename));
236
237
  clearTimeout(this.#debounceTimer ?? undefined);
237
238
  this.#debounceTimer = setTimeout(() => {
package/src/parser.ts CHANGED
@@ -672,12 +672,33 @@ export async function listSessionFolders(): Promise<string[]> {
672
672
  }
673
673
 
674
674
  /**
675
- * List all session files in a folder.
675
+ * Name prefix of the scratch directories `omp gc --compress` creates beside
676
+ * the files it copies on APFS. Their `.jsonl` files are transient copies of
677
+ * transcripts, never transcripts of their own; a killed run can leave one
678
+ * behind until the next `omp gc --compress --apply` removes it.
679
+ */
680
+ export const COMPRESS_SCRATCH_PREFIX = ".omp-gc-compress-";
681
+
682
+ /** Whether `relativePath` (from any ancestor directory) lies inside a compress scratch directory. */
683
+ export function isCompressScratchPath(relativePath: string): boolean {
684
+ return relativePath.split(/[\\/]/).some(part => part.startsWith(COMPRESS_SCRATCH_PREFIX));
685
+ }
686
+
687
+ /**
688
+ * List all session files in a folder, excluding copies inside
689
+ * `omp gc --compress` scratch directories.
676
690
  */
677
691
  export async function listSessionFiles(folderPath: string): Promise<string[]> {
678
692
  try {
679
693
  const entries = await fs.readdir(folderPath, { recursive: true, withFileTypes: true });
680
- return entries.filter(e => e.isFile() && e.name.endsWith(".jsonl")).map(e => path.join(e.parentPath, e.name));
694
+ return entries
695
+ .filter(
696
+ e =>
697
+ e.isFile() &&
698
+ e.name.endsWith(".jsonl") &&
699
+ !isCompressScratchPath(path.relative(folderPath, e.parentPath)),
700
+ )
701
+ .map(e => path.join(e.parentPath, e.name));
681
702
  } catch {
682
703
  return [];
683
704
  }
@@ -438,7 +438,7 @@ export interface UsageWindowSeries {
438
438
  accountKey: string;
439
439
  /** Email/account id when known, else the stable account key. */
440
440
  accountLabel: string;
441
- /** Groups the same limit window across accounts (the provider limit id). */
441
+ /** Groups the same limit window across accounts: the limit id, or quota family + window for positional Codex slots. */
442
442
  windowKey: string;
443
443
  /** Human label of the limit (distinguishes same-duration windows). */
444
444
  windowLabel: string;
@@ -452,7 +452,7 @@ export interface UsageWindowSeries {
452
452
  */
453
453
  export interface ProviderWindowInsight {
454
454
  provider: string;
455
- /** Groups the same limit window across accounts (the provider limit id). */
455
+ /** Groups the same limit window across accounts: the limit id, or quota family + window for positional Codex slots. */
456
456
  windowKey: string;
457
457
  /** Human label of the limit (distinguishes same-duration windows). */
458
458
  windowLabel: string;
@@ -241,13 +241,33 @@ function windowDisplayLabel(row: UsageSnapshotRow): string {
241
241
  return `${label} · ${windowLabel}`;
242
242
  }
243
243
 
244
+ /** Trailing limit-id segment naming the report slot a window arrived in, not the quota itself. */
245
+ const POSITIONAL_SLOT = /:(?:primary|secondary)$/;
246
+
247
+ /**
248
+ * Cross-account identity of one limit window: usually the limit id. Positional
249
+ * ids (Codex's `openai-codex:primary`) name a slot, not a window — a Team plan
250
+ * reports its 5-hour window as `primary` and its 7-day one as `secondary`,
251
+ * while a weekly-only plan reports its 7-day window as `primary`. For those,
252
+ * the quota family (the id minus the slot) plus the recorded window label is
253
+ * the identity, so both plans' weeklies share a group while Spark's
254
+ * (`openai-codex:spark:*`) stays apart. A row without a window label keeps its
255
+ * raw id rather than having its window guessed from the slot.
256
+ */
257
+ function windowKeyOf(row: UsageSnapshotRow): string {
258
+ if (!row.windowLabel || !POSITIONAL_SLOT.test(row.limitId)) return row.limitId;
259
+ return `${row.limitId.replace(POSITIONAL_SLOT, "")}:${row.windowLabel}`;
260
+ }
261
+
244
262
  /**
245
263
  * Derive utilization series and per-window insights from raw snapshots.
246
264
  *
247
- * Windows are grouped by `(provider, limitId)` — never by display label.
248
- * Distinct limits can share a duration label (Anthropic's overall and
249
- * model-scoped 7-day windows, Codex's base and Spark weeklies); merging them
250
- * interleaves unrelated fractions per account and wildly inflates consumption.
265
+ * Windows are grouped by `(provider, windowKeyOf(row))` — never by display
266
+ * label alone. Distinct limits can share a duration label (Anthropic's overall
267
+ * and model-scoped 7-day windows, Codex's base and Spark weeklies); merging
268
+ * them interleaves unrelated fractions per account and wildly inflates
269
+ * consumption. Conversely, one window reported under different positional
270
+ * slots by different accounts must merge (see {@link windowKeyOf}).
251
271
  *
252
272
  * `tokensByProvider` supplies each provider's token burn over the same time
253
273
  * range (fleet-wide broker client reports when available, else local message
@@ -262,12 +282,13 @@ export function computeUsageWindowStats(
262
282
  ): UsageWindowStats {
263
283
  const groups = new Map<string, WindowGroup>();
264
284
  for (const row of rows) {
265
- const groupKey = `${row.provider}\u0000${row.limitId}`;
285
+ const windowKey = windowKeyOf(row);
286
+ const groupKey = `${row.provider}\u0000${windowKey}`;
266
287
  let group = groups.get(groupKey);
267
288
  if (!group) {
268
289
  group = {
269
290
  provider: row.provider,
270
- windowKey: row.limitId,
291
+ windowKey,
271
292
  windowLabel: windowDisplayLabel(row),
272
293
  accounts: new Map(),
273
294
  };