@robhowley/pi-openrouter 0.4.0 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-openrouter
2
2
 
3
- A [Pi](https://pi.dev/) extension for OpenRouter usage and session visibility, with an `/openrouter-usage` terminal overlay for spend, caps, burn rate, and model breakdowns, plus automatic `session_id` tagging for dashboard grouping.
3
+ A [Pi](https://pi.dev/) extension for OpenRouter usage and session visibility, with an `/openrouter-usage` terminal overlay for spend, caps, burn rate, live tracking, and model breakdowns, plus automatic `session_id` tagging for dashboard grouping.
4
4
 
5
5
  ## Installation
6
6
 
@@ -12,8 +12,8 @@ pi install npm:@robhowley/pi-openrouter
12
12
 
13
13
  Set one of these environment variables:
14
14
 
15
- - `OPENROUTER_MANAGEMENT_KEY` (preferred) provides full usage data including model breakdowns
16
- - `OPENROUTER_API_KEY` basic usage data only
15
+ - `OPENROUTER_MANAGEMENT_KEY` (preferred), provides full usage data including model breakdowns
16
+ - `OPENROUTER_API_KEY`, basic usage data only
17
17
 
18
18
  ```shell
19
19
  export OPENROUTER_MANAGEMENT_KEY=sk-or-...
@@ -26,20 +26,20 @@ Type `/openrouter-usage` in Pi to open the usage overlay.
26
26
  The overlay shows:
27
27
  - **Month spend** vs cap with percentage
28
28
  - **7-day spend** with burn rate projection
29
- - **Today's spend**
29
+ - **Today's spend** from live tracked turns while Activity API data catches up
30
30
  - **Top models** (7d and 30d)
31
31
  - **Usage by provider** (30d)
32
- - **Usage by day** (last 7 days)
32
+ - **Daily spend** (30d)
33
33
 
34
34
  The extension refreshes data in the background every 30 seconds (with exponential backoff on errors).
35
35
 
36
- Press `q`, `Esc`, or `Ctrl+C` to close the overlay.
36
+ <img src="https://raw.githubusercontent.com/robhowley/pi-userland/main/packages/pi-openrouter/img/openrouter-usage-tui.png" alt="OpenRouter Usage Overlay" width="600">
37
37
 
38
38
  ## Session tracking
39
39
 
40
40
  `pi-openrouter` automatically tags OpenRouter requests with `session_id` field set to the Pi session's ID.
41
41
 
42
- Can view the Pi session ID with
42
+ View the Pi session ID with
43
43
 
44
44
  ```bash
45
45
  /session # [uuid]
@@ -54,7 +54,7 @@ The session can be tracked in OpenRouter's logs under the following ID:
54
54
  pi:[uuid]
55
55
  ```
56
56
 
57
- This feature allows for session level tracking the OpenRouter Logs → Sessions page. It does not use Pi session names, local paths, repo names, branches, or other local identifiers.
57
+ This enables session-level tracking in the OpenRouter Logs → Sessions page.
58
58
 
59
59
  ## License
60
60
 
@@ -4,16 +4,17 @@ import { renderSpendSparkline } from '../chart.js';
4
4
  import type { ActivityItem } from '@openrouter/sdk/models/index.js';
5
5
 
6
6
  describe('aggregateUsage', () => {
7
- it('should correctly aggregate today spend regardless of timezone', () => {
7
+ it('should correctly aggregate today spend using UTC date', () => {
8
8
  const credits = {
9
9
  totalUsage: 10,
10
10
  totalCredits: 100,
11
11
  };
12
12
 
13
- // Get today's date in YYYY-MM-DD format using LOCAL date
13
+ // Get today's date in YYYY-MM-DD format using UTC date
14
14
  // This matches how the API returns dates (YYYY-MM-DD without timezone)
15
+ // and how the implementation calculates 'today' (using UTC)
15
16
  const now = new Date();
16
- const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
17
+ const todayStr = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-${String(now.getUTCDate()).padStart(2, '0')}`;
17
18
 
18
19
  const analytics: ActivityItem[] = [
19
20
  {
@@ -33,9 +34,6 @@ describe('aggregateUsage', () => {
33
34
 
34
35
  const result = aggregateUsage(credits, analytics);
35
36
 
36
- // Today should include data from todayStr (date strings compared directly)
37
- // This was previously a bug where dates were parsed as UTC timestamps
38
- // causing timezone-related filtering errors
39
37
  expect(result.today).toBe(6.55);
40
38
  });
41
39
 
@@ -1,16 +1,13 @@
1
- import type { UsageSummary } from './types.js';
1
+ import type { CacheEntry, UsageSummary } from './types.js';
2
2
  import type { ActivityItem } from '@openrouter/sdk/models/index.js';
3
3
  import { aggregateUsage } from './format.js';
4
4
  import { getCredits, getActivity } from './client.js';
5
+ import { readLocalUsage, aggregateLocal, getCurrentUtcDate, addUtcDays } from './local-usage.js';
6
+ import { ZERO_AGGREGATE } from './types.js';
5
7
 
6
8
  export const CACHE_TTL_MS = 45000;
7
9
  export const BACKGROUND_REFRESH_INTERVAL_MS = 30000;
8
10
 
9
- interface CacheEntry<T> {
10
- data: T;
11
- timestamp: number;
12
- }
13
-
14
11
  export class TTLCache<T> {
15
12
  private cache = new Map<string, CacheEntry<T>>();
16
13
 
@@ -73,8 +70,87 @@ export async function fetchAndAggregate(): Promise<UsageSummary | null> {
73
70
  hasActivityData = false;
74
71
  }
75
72
  const timestamp = Date.now();
76
- const summary = aggregateUsage(credits, analytics ?? [], timestamp);
73
+
74
+ // Get official aggregate from Activity API data
75
+ const officialThroughDate = (function (): string | undefined {
76
+ if (!analytics || analytics.length === 0) return undefined;
77
+ let maxDate = '';
78
+ // Match YYYY-MM-DD or YYYY-MM-DD HH:MM:SS
79
+ const dateRE = /^\d{4}-\d{2}-\d{2}/;
80
+ for (let i = 0; i < analytics.length; i++) {
81
+ const d = analytics[i];
82
+ if (d && d.date && dateRE.test(d.date)) {
83
+ const datePart = d.date.slice(0, 10); // Extract YYYY-MM-DD
84
+ if (datePart > maxDate) {
85
+ maxDate = datePart;
86
+ }
87
+ }
88
+ }
89
+ return maxDate || undefined;
90
+ })();
91
+
92
+ // Compute official aggregate (only from Activity API data up to officialThroughDate)
93
+ const officialAggregate =
94
+ hasActivityData && analytics && analytics.length > 0
95
+ ? aggregateLocal(
96
+ analytics.map(
97
+ (item) =>
98
+ ({
99
+ id: item.date + '-' + item.providerName + '-' + item.model,
100
+ sessionId: 'activity-api',
101
+ completedAt: item.date + 'T00:00:00.000Z',
102
+ requests: item.requests || 1,
103
+ model: item.model,
104
+ provider: item.providerName,
105
+ promptTokens: item.promptTokens,
106
+ completionTokens: item.completionTokens,
107
+ reasoningTokens: item.reasoningTokens,
108
+ cost: item.usage,
109
+ }) as any,
110
+ ),
111
+ )
112
+ : ZERO_AGGREGATE;
113
+
114
+ // Read local JSONL after officialThroughDate
115
+ const localEvents: any[] = [];
116
+ if (officialThroughDate) {
117
+ // Read from the day after officialThroughDate to today
118
+ const localFrom = addUtcDays(officialThroughDate, 1);
119
+ const localTo = getCurrentUtcDate();
120
+ try {
121
+ const localEventsList = await readLocalUsage({
122
+ fromDateUtc: localFrom,
123
+ toDateUtc: localTo,
124
+ });
125
+ localEvents.push(...localEventsList);
126
+ } catch (err) {
127
+ // Fail open - if local read fails, continue with empty local
128
+ console.log('Local usage read failed:', err);
129
+ }
130
+ }
131
+
132
+ // Aggregate local events
133
+ const localAggregate = aggregateLocal(localEvents);
134
+
135
+ // Combine official + local
136
+ const combinedAggregate: any = {
137
+ requests: officialAggregate.requests + localAggregate.requests,
138
+ promptTokens: officialAggregate.promptTokens + localAggregate.promptTokens,
139
+ completionTokens: officialAggregate.completionTokens + localAggregate.completionTokens,
140
+ reasoningTokens: officialAggregate.reasoningTokens + localAggregate.reasoningTokens,
141
+ cacheReadTokens: officialAggregate.cacheReadTokens + localAggregate.cacheReadTokens,
142
+ cacheWriteTokens: officialAggregate.cacheWriteTokens + localAggregate.cacheWriteTokens,
143
+ cost: officialAggregate.cost + localAggregate.cost,
144
+ };
145
+
146
+ // Build full summary with local events included for 7d/30d totals
147
+ const summary = aggregateUsage(credits, analytics ?? [], timestamp, localEvents);
77
148
  summary.hasActivityData = hasActivityData;
149
+ summary.officialThroughDate = (officialThroughDate as string | undefined) ?? undefined;
150
+ summary.official = officialAggregate;
151
+ summary.local = localAggregate;
152
+ summary.combined = combinedAggregate;
153
+
78
154
  return summary;
79
155
  }
80
156
 
@@ -1,56 +1,94 @@
1
1
  import type { ActivityItem } from '@openrouter/sdk/models/index.js';
2
2
  import type { ModelStats, ProviderStats, TokenStats, UsageSummary } from './types.js';
3
+ import { ZERO_AGGREGATE, type LocalUsageEvent } from './types.js';
4
+ import { getUtcDateFromTimestamp } from './local-usage.js';
3
5
 
4
- /** Convert a Date to YYYY-MM-DD string in local timezone (matching API format) */
5
- function localISODate(date: Date): string {
6
- const year = date.getFullYear();
7
- const month = String(date.getMonth() + 1).padStart(2, '0');
8
- const day = String(date.getDate()).padStart(2, '0');
9
- return `${year}-${month}-${day}`;
6
+ /** Convert a Date to YYYY-MM-DD string in UTC (matching OpenRouter API format) */
7
+ function utcISODate(date: Date): string {
8
+ return date.toISOString().slice(0, 10);
10
9
  }
11
10
 
12
11
  export function aggregateUsage(
13
12
  credits: { totalUsage: number; totalCredits?: number },
14
13
  analytics: ActivityItem[],
15
14
  timestamp: number = Date.now(),
15
+ localEvents: LocalUsageEvent[] = [],
16
16
  ): UsageSummary {
17
17
  const now = new Date();
18
- const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
18
+ // Use UTC dates to match OpenRouter API (which uses UTC)
19
+ const startOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
19
20
  const startOfWeek = new Date(startOfDay);
20
- startOfWeek.setDate(startOfWeek.getDate() - 7);
21
+ startOfWeek.setUTCDate(startOfWeek.getUTCDate() - 7);
21
22
 
22
23
  const weekData = analytics.filter((d) => {
23
- // API dates are YYYY-MM-DD; compare by local date boundary
24
- return d.date >= localISODate(startOfWeek);
24
+ // API dates are YYYY-MM-DD in UTC; compare by UTC date boundary
25
+ return d.date >= utcISODate(startOfWeek);
25
26
  });
26
27
 
27
28
  const todayData = analytics.filter((d) => {
28
- // API dates are YYYY-MM-DD; compare by local date boundary
29
- return d.date >= localISODate(startOfDay);
29
+ // API dates are YYYY-MM-DD in UTC; compare by UTC date boundary
30
+ return d.date >= utcISODate(startOfDay);
30
31
  });
31
32
 
32
- const week = sumSpend(weekData);
33
- const today = sumSpend(todayData);
33
+ const weekFromAnalytics = sumSpend(weekData);
34
+ const todayFromAnalytics = sumSpend(todayData);
34
35
  const month = credits.totalUsage;
35
36
 
37
+ // Add local events to compute combined totals
38
+ const weekFromLocal = localEvents
39
+ .filter((e) => getUtcDateFromTimestamp(e.completedAt) >= utcISODate(startOfWeek))
40
+ .reduce((sum, e) => sum + (e.cost || 0), 0);
41
+ const todayFromLocal = localEvents
42
+ .filter((e) => getUtcDateFromTimestamp(e.completedAt) >= utcISODate(startOfDay))
43
+ .reduce((sum, e) => sum + (e.cost || 0), 0);
44
+
45
+ const week = weekFromAnalytics + weekFromLocal;
46
+ const today = todayFromAnalytics + todayFromLocal;
47
+
48
+ // Merge analytics with local events for model/provider stats
49
+ // Convert local events to ActivityItem-like format for existing functions
50
+ const localItems = localEvents.map((e) => ({
51
+ date: getUtcDateFromTimestamp(e.completedAt),
52
+ usage: e.cost || 0,
53
+ promptTokens: e.promptTokens || 0,
54
+ completionTokens: e.completionTokens || 0,
55
+ reasoningTokens: e.reasoningTokens || 0,
56
+ requests: e.requests || 1,
57
+ model: e.model || 'unknown',
58
+ providerName: e.provider || 'unknown',
59
+ // Required fields that aren't used by our functions
60
+ byokUsageInference: 0,
61
+ endpointId: 'local-turns',
62
+ modelPermaslug: e.model || 'unknown',
63
+ }));
64
+
65
+ const allData = [...analytics, ...localItems] as any[];
66
+
36
67
  // Build model stats for both 7d and 30d windows
37
- const modelStatsMap = buildModelStats(weekData, analytics);
68
+ // Use allData (combined API + local) for 30d, weekData (combined) for 7d
69
+ const modelStatsMap = buildModelStats(weekData, allData);
38
70
  const topModels = Array.from(modelStatsMap.values())
39
71
  .sort((a, b) => b.spend30d - a.spend30d)
40
72
  .slice(0, 10);
41
73
 
42
- return {
74
+ const summary = {
43
75
  today,
44
76
  week,
45
77
  month,
46
78
  cap: credits.totalCredits ?? 0,
47
79
  burnRate: (week / 7) * 30,
48
80
  topModels,
49
- byProvider: buildProviderStats(analytics),
50
- byDay: aggregateByDay(analytics),
81
+ byProvider: buildProviderStats(allData),
82
+ byDay: aggregateByDay(allData),
51
83
  timestamp,
52
84
  hasActivityData: true, // aggregateUsage is only called when analytics data is available
53
- };
85
+ officialThroughDate: undefined as string | undefined,
86
+ official: ZERO_AGGREGATE,
87
+ local: ZERO_AGGREGATE,
88
+ combined: ZERO_AGGREGATE,
89
+ } as UsageSummary;
90
+
91
+ return summary;
54
92
  }
55
93
 
56
94
  function sumSpend(data: ActivityItem[]): number {
@@ -62,8 +100,9 @@ function aggregateTokens(data: ActivityItem[]): TokenStats {
62
100
  (acc, d) => {
63
101
  acc.input += d.promptTokens || 0;
64
102
  acc.output += d.completionTokens || 0;
65
- acc.reasoning += d.reasoningTokens || 0;
66
- acc.total += (d.promptTokens || 0) + (d.completionTokens || 0) + (d.reasoningTokens || 0);
103
+ acc.reasoning += (d.reasoningTokens || 0) as number;
104
+ acc.total +=
105
+ (d.promptTokens || 0) + (d.completionTokens || 0) + ((d.reasoningTokens || 0) as number);
67
106
  return acc;
68
107
  },
69
108
  { input: 0, output: 0, reasoning: 0, total: 0 } as TokenStats,
@@ -9,6 +9,7 @@ import {
9
9
  import { AuthError } from './client.js';
10
10
  import { UsageOverlayComponent } from './overlay.js';
11
11
  import { formatSessionId, isOpenRouterRequest, type OpenRouterSessionState } from './session.js';
12
+ import { writeLocalUsage, type LocalUsageEvent } from './local-usage.js';
12
13
  import crypto from 'node:crypto';
13
14
 
14
15
  // Store the current session state for use in command handlers
@@ -47,6 +48,7 @@ export default function (pi: ExtensionAPI) {
47
48
  // Install before_provider_request hook once
48
49
  if (!sessionTrackingInstalled) {
49
50
  sessionTrackingInstalled = true;
51
+
50
52
  pi.on('before_provider_request', (event, ctx) => {
51
53
  try {
52
54
  // Validate the payload exists
@@ -79,6 +81,70 @@ export default function (pi: ExtensionAPI) {
79
81
  });
80
82
  }
81
83
 
84
+ // Hook turn_end to capture completed OpenRouter turns for local logging
85
+ pi.on('turn_end', async (event, ctx) => {
86
+ try {
87
+ const turnEvent = event as unknown as Record<string, unknown>;
88
+
89
+ const message = turnEvent['message'] as Record<string, unknown> | undefined;
90
+ if (!message) return;
91
+
92
+ // Check if this is an OpenRouter request based on the message content/model
93
+ const isOpenRouter = isOpenRouterRequest(
94
+ { type: 'before_provider_request', payload: message } as unknown as Parameters<
95
+ typeof isOpenRouterRequest
96
+ >[0],
97
+ ctx,
98
+ );
99
+ if (!isOpenRouter) return;
100
+
101
+ // Check if the message has usage data
102
+ const usage = (message as { usage?: unknown })['usage'] as
103
+ | {
104
+ input?: number;
105
+ output?: number;
106
+ cacheRead?: number;
107
+ cacheWrite?: number;
108
+ totalTokens?: number;
109
+ cost?: {
110
+ input?: number;
111
+ output?: number;
112
+ cacheRead?: number;
113
+ cacheWrite?: number;
114
+ total?: number;
115
+ };
116
+ }
117
+ | undefined;
118
+ if (!usage) return;
119
+
120
+ // Extract model from the message
121
+ const model = message['model'] as string | undefined;
122
+ const responseModel = message['responseModel'] as string | undefined;
123
+ const modelToLog = model || responseModel;
124
+
125
+ // Calculate total cost from usage.cost.total
126
+ const totalCost = usage.cost?.total;
127
+
128
+ const localEvent = {
129
+ id: crypto.randomUUID(),
130
+ generationId: message['responseId'],
131
+ sessionId: getCurrentSessionId(ctx),
132
+ completedAt: new Date().toISOString(),
133
+ model: modelToLog,
134
+ promptTokens: usage.input,
135
+ completionTokens: usage.output,
136
+ cacheReadTokens: usage.cacheRead,
137
+ cacheWriteTokens: usage.cacheWrite,
138
+ cost: totalCost,
139
+ } as LocalUsageEvent;
140
+
141
+ // Write to local JSONL - fail open (don't throw)
142
+ writeLocalUsage(localEvent).catch(() => {});
143
+ } catch {
144
+ // Fail open - silently ignore errors
145
+ }
146
+ });
147
+
82
148
  pi.on('session_shutdown', () => {
83
149
  stopBackgroundRefresh();
84
150
  });
@@ -0,0 +1,148 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import type { LocalUsageEvent, UsageAggregate } from './types.js';
5
+ import { ZERO_AGGREGATE } from './types.js';
6
+
7
+ export type { LocalUsageEvent };
8
+
9
+ const LOCAL_USAGE_DIR = path.join(os.homedir(), '.pi', 'openrouter', 'usage');
10
+
11
+ /**
12
+ * Get current UTC date as YYYY-MM-DD
13
+ */
14
+ export function getCurrentUtcDate(): string {
15
+ const now = new Date();
16
+ return now.toISOString().slice(0, 10);
17
+ }
18
+
19
+ /**
20
+ * Extract UTC date from ISO timestamp (YYYY-MM-DDTHH:mm:ss.sssZ)
21
+ */
22
+ export function getUtcDateFromTimestamp(isoString: string): string {
23
+ return isoString.slice(0, 10);
24
+ }
25
+
26
+ /**
27
+ * Add days to a YYYY-MM-DD date string, return new YYYY-MM-DD in UTC
28
+ */
29
+ export function addUtcDays(dateStr: string, days: number): string {
30
+ const date = new Date(dateStr + 'T00:00:00Z');
31
+ if (isNaN(date.getTime())) {
32
+ throw new Error(`Invalid date string: ${dateStr}`);
33
+ }
34
+ date.setUTCDate(date.getUTCDate() + days);
35
+ return date.toISOString().slice(0, 10);
36
+ }
37
+
38
+ /**
39
+ * Iterate dates from start to end (inclusive), return array of YYYY-MM-DD strings
40
+ */
41
+ export function* iterateDates(start: string, end: string): Generator<string> {
42
+ let current = start;
43
+ while (current <= end) {
44
+ yield current;
45
+ current = addUtcDays(current, 1);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Append a single LocalUsageEvent to the appropriate daily JSONL file.
51
+ * File is determined by UTC date from completedAt.
52
+ */
53
+ export async function writeLocalUsage(event: LocalUsageEvent): Promise<void> {
54
+ try {
55
+ const dateStr = getUtcDateFromTimestamp(event.completedAt);
56
+ const filePath = path.join(LOCAL_USAGE_DIR, `${dateStr}.jsonl`);
57
+
58
+ await fs.mkdir(LOCAL_USAGE_DIR, { recursive: true });
59
+
60
+ const line = JSON.stringify(event) + '\n';
61
+ await fs.appendFile(filePath, line, 'utf8');
62
+ } catch (err) {
63
+ // Fail open - log but don't throw to avoid breaking the user experience
64
+ console.error('[local-usage] Failed to write usage event:', err);
65
+ }
66
+ }
67
+
68
+ export interface ReadLocalUsageOptions {
69
+ /** Start date inclusive (YYYY-MM-DD) */
70
+ fromDateUtc: string;
71
+ /** End date inclusive (YYYY-MM-DD) */
72
+ toDateUtc: string;
73
+ }
74
+
75
+ /**
76
+ * Read local usage events from JSONL files for the given date range.
77
+ * Tolerates missing files, blank lines, and malformed lines.
78
+ */
79
+ export async function readLocalUsage(options: ReadLocalUsageOptions): Promise<LocalUsageEvent[]> {
80
+ const events: LocalUsageEvent[] = [];
81
+
82
+ for (const dateStr of iterateDates(options.fromDateUtc, options.toDateUtc)) {
83
+ const filePath = path.join(LOCAL_USAGE_DIR, `${dateStr}.jsonl`);
84
+
85
+ try {
86
+ const content = await fs.readFile(filePath, 'utf8');
87
+ const lines = content.split('\n');
88
+
89
+ for (const line of lines) {
90
+ const trimmed = line.trim();
91
+ if (!trimmed) continue;
92
+
93
+ try {
94
+ const event = JSON.parse(trimmed) as LocalUsageEvent;
95
+ events.push(event);
96
+ } catch (parseErr) {
97
+ // Skip malformed lines but continue
98
+ console.warn(`[local-usage] Malformed line in ${dateStr}.jsonl:`, parseErr);
99
+ }
100
+ }
101
+ } catch (err) {
102
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
103
+ // Missing file is OK - just no data for this date
104
+ continue;
105
+ }
106
+ console.error(`[local-usage] Failed to read ${dateStr}.jsonl:`, err);
107
+ // Continue to next date despite error
108
+ }
109
+ }
110
+
111
+ return events;
112
+ }
113
+
114
+ /**
115
+ * Aggregate local usage events into UsageAggregate.
116
+ * Deduplicates by id (first occurrence wins).
117
+ */
118
+ export function aggregateLocal(events: LocalUsageEvent[]): UsageAggregate {
119
+ if (events.length === 0) {
120
+ return ZERO_AGGREGATE;
121
+ }
122
+
123
+ // Deduplicate by id
124
+ const seen = new Set<string>();
125
+ const unique: LocalUsageEvent[] = [];
126
+
127
+ for (const event of events) {
128
+ if (seen.has(event.id)) continue;
129
+ seen.add(event.id);
130
+ unique.push(event);
131
+ }
132
+
133
+ // Aggregate
134
+ const result = unique.reduce(
135
+ (acc, event) => {
136
+ acc.requests += event.requests ?? 1;
137
+ acc.promptTokens += event.promptTokens || 0;
138
+ acc.completionTokens += event.completionTokens || 0;
139
+ acc.reasoningTokens += event.reasoningTokens || 0;
140
+ acc.cacheReadTokens += event.cacheReadTokens || 0;
141
+ acc.cacheWriteTokens += event.cacheWriteTokens || 0;
142
+ acc.cost += event.cost || 0;
143
+ return acc;
144
+ },
145
+ { ...ZERO_AGGREGATE },
146
+ );
147
+ return result;
148
+ }
@@ -188,9 +188,12 @@ export class UsageOverlayComponent {
188
188
  }
189
189
  lines.push(rowRightAligned(weekLeftBase, weekRight + ' ', this.width));
190
190
 
191
- // Today row on its own line
192
- const todayContent = ` Today $${fmt(summary.today)}`;
193
- lines.push(rowRightAligned(todayContent, ' ', this.width));
191
+ // Today row on its own line - shows tilde since from local logs
192
+ const todayReqStr =
193
+ summary.local.requests > 0 ? ` · ${fmtCount(summary.local.requests)} reqs` : '';
194
+ lines.push(
195
+ rowRightAligned(` Today ~$${fmt(summary.local.cost)}${todayReqStr}`, ' ', this.width),
196
+ );
194
197
  lines.push(emptyRow(this.width));
195
198
 
196
199
  // Top models (7d table)
@@ -227,8 +230,32 @@ export class UsageOverlayComponent {
227
230
  // Last refresh time at the bottom
228
231
  if (summary?.timestamp) {
229
232
  const refreshDate = new Date(summary.timestamp);
230
- const timestampStr = refreshDate.toLocaleTimeString();
231
- lines.push(row(` Last refreshed: ${timestampStr}`, this.width));
233
+ const timestampStr = refreshDate.toLocaleTimeString('en-US', {
234
+ hour: 'numeric',
235
+ minute: '2-digit',
236
+ second: '2-digit',
237
+ });
238
+
239
+ // Build footer parts (always show all parts)
240
+ const footerParts: string[] = [];
241
+ footerParts.push(`Updated ${timestampStr}`);
242
+
243
+ // Official through date - show if available
244
+ if (summary.officialThroughDate) {
245
+ const date = new Date(summary.officialThroughDate + 'T00:00:00Z');
246
+ if (!isNaN(date.getTime())) {
247
+ const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
248
+ footerParts.push(`API through ${dateStr}`);
249
+ }
250
+ } else {
251
+ // Show placeholder when no activity data is available
252
+ footerParts.push('API data pending');
253
+ }
254
+
255
+ // Today from tracked turns
256
+ footerParts.push('Today from tracked turns');
257
+
258
+ lines.push(row(` ${footerParts.join(' · ')}`, this.width));
232
259
  lines.push(emptyRow(this.width));
233
260
 
234
261
  // Warning if data is limited due to missing management key
@@ -33,8 +33,72 @@ export interface UsageSummary {
33
33
  byDay: Record<string, number>;
34
34
  timestamp: number;
35
35
  hasActivityData: boolean;
36
+
37
+ /** Date of latest official Activity API data (YYYY-MM-DD) */
38
+ officialThroughDate?: string | undefined;
39
+
40
+ /** Aggregate from Activity API */
41
+ official: UsageAggregate;
42
+
43
+ /** Aggregate from local JSONL after officialThroughDate */
44
+ local: UsageAggregate;
45
+
46
+ /** Combined official + local */
47
+ combined: UsageAggregate;
36
48
  }
37
49
 
50
+ export interface LocalUsageEvent {
51
+ /** UUID for deduplication */
52
+ id: string;
53
+
54
+ /** Generation ID from OpenRouter **/
55
+ generationId: string;
56
+
57
+ /** Existing pi-openrouter session ID */
58
+ sessionId: string;
59
+
60
+ /** ISO 8601 UTC timestamp */
61
+ completedAt: string;
62
+
63
+ /** Always 1 per completed turn; typed as number for flexibility */
64
+ requests?: number;
65
+
66
+ model?: string;
67
+
68
+ /**
69
+ * Actual OpenRouter routed provider (NOT "openrouter").
70
+ * Examples: "ionstream/fp8", "parasail/bf16", "inceptron/int4"
71
+ */
72
+ provider?: string;
73
+
74
+ promptTokens?: number;
75
+ completionTokens?: number;
76
+ reasoningTokens?: number;
77
+ cacheReadTokens?: number;
78
+ cacheWriteTokens?: number;
79
+ cost?: number;
80
+ }
81
+
82
+ export interface UsageAggregate {
83
+ requests: number;
84
+ promptTokens: number;
85
+ completionTokens: number;
86
+ reasoningTokens: number;
87
+ cacheReadTokens: number;
88
+ cacheWriteTokens: number;
89
+ cost: number;
90
+ }
91
+
92
+ export const ZERO_AGGREGATE: UsageAggregate = {
93
+ requests: 0,
94
+ promptTokens: 0,
95
+ completionTokens: 0,
96
+ reasoningTokens: 0,
97
+ cacheReadTokens: 0,
98
+ cacheWriteTokens: 0,
99
+ cost: 0,
100
+ };
101
+
38
102
  export interface CacheEntry<T> {
39
103
  data: T;
40
104
  timestamp: number;
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@robhowley/pi-openrouter",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
- "description": "OpenRouter usage TUI overlay with caps, spend, burn rate, models, and session_id tracking.",
5
+ "description": "OpenRouter usage TUI overlay with caps, burn rate, models, live tracking, and session_id tagging.",
6
+ "license": "MIT",
6
7
  "files": [
7
8
  "extensions",
8
9
  "README.md",