@robhowley/pi-openrouter 0.3.1 → 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 that adds an `/openrouter-usage` command for viewing OpenRouter spend, caps, burn rate, and model breakdowns in a terminal overlay.
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,35 @@ 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
- ## Features
38
+ ## Session tracking
39
39
 
40
- - Ephemeral TUI overlay doesn't clutter chat history
41
- - Auto-refreshing cache — data stays fresh without repeated API calls
42
- - Graceful degradation works with API key only (no model breakdowns)
40
+ `pi-openrouter` automatically tags OpenRouter requests with `session_id` field set to the Pi session's ID.
41
+
42
+ View the Pi session ID with
43
+
44
+ ```bash
45
+ /session # [uuid]
46
+ ```
47
+
48
+ The session can be tracked in OpenRouter's logs under the following ID:
49
+
50
+ ```bash
51
+ /openrouter-session
52
+
53
+ # OpenRouter session_id
54
+ pi:[uuid]
55
+ ```
56
+
57
+ This enables session-level tracking in the OpenRouter Logs → Sessions page.
43
58
 
44
59
  ## License
45
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
 
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isOpenRouterRequest, formatSessionId } from '../session.js';
3
+
4
+ // =============================================================================
5
+ // Session ID Formatting Tests
6
+ // =============================================================================
7
+
8
+ describe('formatSessionId', () => {
9
+ it('adds pi: prefix if missing', () => {
10
+ expect(formatSessionId('abc123')).toBe('pi:abc123');
11
+ });
12
+
13
+ it('does not add duplicate pi: prefix', () => {
14
+ expect(formatSessionId('pi:abc123')).toBe('pi:abc123');
15
+ });
16
+ });
17
+
18
+ // =============================================================================
19
+ // Request Detection Tests
20
+ // =============================================================================
21
+
22
+ // Helper to create mock event
23
+ function createEvent(
24
+ payload: Record<string, unknown>,
25
+ url?: string,
26
+ provider?: Record<string, unknown>,
27
+ ) {
28
+ const event: any = { payload };
29
+ if (url) event.url = url;
30
+ if (provider) event.provider = provider;
31
+ return event;
32
+ }
33
+
34
+ // Helper to create mock context
35
+ function createContext(model: string | Record<string, unknown>) {
36
+ return { model } as any;
37
+ }
38
+
39
+ describe('isOpenRouterRequest', () => {
40
+ // Method 1: Check model string (e.g., "openrouter/anthropic/claude-3.5-sonnet")
41
+ it('detects OpenRouter by model prefix', () => {
42
+ const event = createEvent({ model: 'openrouter/anthropic/claude-sonnet-4' });
43
+ expect(isOpenRouterRequest(event, {})).toBe(true);
44
+ });
45
+
46
+ it('does not detect non-OpenRouter by model prefix', () => {
47
+ const event = createEvent({ model: 'anthropic/claude-sonnet-4' });
48
+ expect(isOpenRouterRequest(event, {})).toBe(false);
49
+ });
50
+
51
+ // Method 2: Check baseUrl from context.model
52
+ it('detects OpenRouter by baseUrl', () => {
53
+ const event = createEvent({ model: 'qwen/qwen3-coder-next' });
54
+ const ctx = createContext({ baseUrl: 'https://openrouter.ai/api/v1' });
55
+ expect(isOpenRouterRequest(event, ctx)).toBe(true);
56
+ });
57
+
58
+ it('does not detect non-OpenRouter by baseUrl', () => {
59
+ const event = createEvent({ model: 'qwen/qwen3-coder-next' });
60
+ const ctx = createContext({ baseUrl: 'https://api.anthropic.com' });
61
+ expect(isOpenRouterRequest(event, ctx)).toBe(false);
62
+ });
63
+
64
+ // Method 3: Check for ZDR provider (Shopify routes to OpenRouter via ZDR)
65
+ it('detects OpenRouter by ZDR provider', () => {
66
+ const event = createEvent({ model: 'qwen/qwen3-coder-next' }, undefined, { zdr: true });
67
+ expect(isOpenRouterRequest(event, {})).toBe(true);
68
+ });
69
+
70
+ it('does not detect non-ZDR provider', () => {
71
+ const event = createEvent({ model: 'qwen/qwen3-coder-next', provider: 'openrouter' });
72
+ expect(isOpenRouterRequest(event, {})).toBe(false);
73
+ });
74
+
75
+ // Method 4: Check URL
76
+ it('detects OpenRouter by URL', () => {
77
+ const event = createEvent(
78
+ { model: 'anthropic/claude-sonnet_4', messages: [] },
79
+ 'https://openrouter.ai/api/v1/chat/completions',
80
+ );
81
+ expect(isOpenRouterRequest(event, {})).toBe(true);
82
+ });
83
+
84
+ it('does not detect non-OpenRouter by URL', () => {
85
+ const event = createEvent(
86
+ { model: 'anthropic/claude-sonnet_4', messages: [] },
87
+ 'https://api.anthropic.com/v1/messages',
88
+ );
89
+ expect(isOpenRouterRequest(event, {})).toBe(false);
90
+ });
91
+
92
+ // Combined methods
93
+ it('detects by multiple methods simultaneously', () => {
94
+ const event = createEvent(
95
+ { model: 'openrouter/anthropic/claude-sonnet-4' },
96
+ 'https://openrouter.ai/api/v1/chat/completions',
97
+ );
98
+ const ctx = createContext({ baseUrl: 'https://openrouter.ai/api/v1' });
99
+ expect(isOpenRouterRequest(event, ctx)).toBe(true);
100
+ });
101
+ });
@@ -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
 
@@ -68,13 +65,92 @@ export async function fetchAndAggregate(): Promise<UsageSummary | null> {
68
65
  } catch (err) {
69
66
  // getActivity() requires a management key; suppress this expected error
70
67
  if (!(err instanceof Error) || !err.message.includes('management key')) {
71
- console.log('Activity fetch failed:', err);
68
+ console.log('Activity fetch failed');
72
69
  }
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
 
@@ -94,9 +170,9 @@ function scheduleRefresh(): void {
94
170
  scheduleRefresh();
95
171
  }
96
172
  }
97
- } catch (err) {
173
+ } catch {
98
174
  consecutiveFailures++;
99
- console.log(`Background refresh failed (${consecutiveFailures}/${MAX_RETRY_COUNT}):`, err);
175
+ console.log(`Background refresh failed (${consecutiveFailures}/${MAX_RETRY_COUNT})`);
100
176
 
101
177
  // Stop after max retries reached
102
178
  if (consecutiveFailures >= MAX_RETRY_COUNT) {
@@ -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,
@@ -8,10 +8,141 @@ import {
8
8
  } from './cache.js';
9
9
  import { AuthError } from './client.js';
10
10
  import { UsageOverlayComponent } from './overlay.js';
11
+ import { formatSessionId, isOpenRouterRequest, type OpenRouterSessionState } from './session.js';
12
+ import { writeLocalUsage, type LocalUsageEvent } from './local-usage.js';
13
+ import crypto from 'node:crypto';
14
+
15
+ // Store the current session state for use in command handlers
16
+ let currentSessionState: OpenRouterSessionState | null = null;
17
+ let sessionTrackingInstalled = false;
18
+
19
+ // =============================================================================
20
+ // Session State Management
21
+ // =============================================================================
22
+
23
+ function getCurrentSessionId(ctx: { sessionManager: { getSessionId(): string } }): string {
24
+ if (currentSessionState) {
25
+ return currentSessionState.sessionId;
26
+ }
27
+
28
+ try {
29
+ const sessionId = ctx.sessionManager.getSessionId();
30
+ let formattedSessionId: string;
31
+ if (sessionId && sessionId !== '') {
32
+ formattedSessionId = formatSessionId(sessionId);
33
+ } else {
34
+ formattedSessionId = formatSessionId(crypto.randomUUID());
35
+ }
36
+
37
+ currentSessionState = { sessionId: formattedSessionId };
38
+ return formattedSessionId;
39
+ } catch {
40
+ // Generate fallback on any error
41
+ const fallbackId = formatSessionId(crypto.randomUUID());
42
+ currentSessionState = { sessionId: fallbackId };
43
+ return fallbackId;
44
+ }
45
+ }
11
46
 
12
47
  export default function (pi: ExtensionAPI) {
13
- pi.on('session_start', async (_event, ctx) => {
14
- ctx.ui.notify('OpenRouter extension loaded', 'info');
48
+ // Install before_provider_request hook once
49
+ if (!sessionTrackingInstalled) {
50
+ sessionTrackingInstalled = true;
51
+
52
+ pi.on('before_provider_request', (event, ctx) => {
53
+ try {
54
+ // Validate the payload exists
55
+ const ev = event as unknown as Record<string, unknown>;
56
+ const payload = ev['payload'] as Record<string, unknown> | undefined;
57
+ if (!payload) {
58
+ return;
59
+ }
60
+
61
+ // Check if this is an OpenRouter request
62
+ const isOpenRouter = isOpenRouterRequest(event, ctx);
63
+ if (!isOpenRouter) {
64
+ return;
65
+ }
66
+
67
+ // Do not overwrite existing session_id
68
+ if ('session_id' in payload && payload['session_id'] !== undefined) {
69
+ return;
70
+ }
71
+
72
+ // Add session_id to the payload (OpenRouter-specific field)
73
+ return {
74
+ ...payload,
75
+ session_id: getCurrentSessionId(ctx),
76
+ };
77
+ } catch {
78
+ // Fail open - silently ignore errors
79
+ return;
80
+ }
81
+ });
82
+ }
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
+ }
15
146
  });
16
147
 
17
148
  pi.on('session_shutdown', () => {
@@ -22,11 +153,20 @@ export default function (pi: ExtensionAPI) {
22
153
  description: 'Show OpenRouter usage: caps, spend, burn rate, and model breakdowns',
23
154
  getArgumentCompletions: () => null,
24
155
  handler: async (args, ctx) => {
25
- startBackgroundRefresh(); // Start cache refresh on first use
156
+ startBackgroundRefresh();
26
157
  const subcommand = args.trim() || undefined;
27
158
  await showUsageOverlay(ctx, subcommand);
28
159
  },
29
160
  });
161
+
162
+ pi.registerCommand('openrouter-session', {
163
+ description: 'Show the current OpenRouter session ID for request grouping',
164
+ getArgumentCompletions: () => null,
165
+ handler: async (_args, ctx) => {
166
+ const idToShow = getCurrentSessionId(ctx);
167
+ ctx.ui.notify(`OpenRouter session_id\n${idToShow}`, 'info');
168
+ },
169
+ });
30
170
  }
31
171
 
32
172
  async function showUsageOverlay(ctx: ExtensionContext, _subcommand?: string) {
@@ -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
@@ -0,0 +1,60 @@
1
+ import type { BeforeProviderRequestEvent } from '@mariozechner/pi-coding-agent';
2
+
3
+ // =============================================================================
4
+ // Types & State
5
+ // =============================================================================
6
+
7
+ export type OpenRouterSessionState = {
8
+ sessionId: string;
9
+ };
10
+
11
+ // =============================================================================
12
+ // State Factory
13
+ // =============================================================================
14
+
15
+ export function formatSessionId(sessionId: string): string {
16
+ if (sessionId.startsWith('pi:')) {
17
+ return sessionId;
18
+ }
19
+ return `pi:${sessionId}`;
20
+ }
21
+
22
+ // =============================================================================
23
+ // Detection Logic
24
+ // =============================================================================
25
+
26
+ export function isOpenRouterRequest(event: BeforeProviderRequestEvent, _ctx: unknown): boolean {
27
+ const ev = event as unknown as Record<string, unknown>;
28
+
29
+ // Method 1: Check model string (e.g., "openrouter/anthropic/claude-3.5-sonnet")
30
+ const payload = ev['payload'] as Record<string, unknown> | undefined;
31
+ const model = String(payload?.['model'] ?? '');
32
+ if (model.includes('openrouter/')) {
33
+ return true;
34
+ }
35
+
36
+ // Method 2: Check baseUrl from context.model
37
+ // OpenRouter models have baseUrl starting with https://openrouter.ai/api/v1
38
+ const context = _ctx as Record<string, unknown>;
39
+ const ctxModel = context['model'] as Record<string, unknown> | undefined;
40
+ const baseUrl = ctxModel?.['baseUrl'] as string | undefined;
41
+ if (baseUrl?.includes('openrouter.ai')) {
42
+ return true;
43
+ }
44
+
45
+ // Method 3: Check for ZDR provider (Shopify routes to OpenRouter via ZDR)
46
+ const provider = ev['provider'] as Record<string, unknown> | undefined;
47
+ if (provider?.['zdr'] === true) {
48
+ return true;
49
+ }
50
+
51
+ // Method 4: Check URL
52
+ const url = String(
53
+ (ev['url'] as string | undefined) ?? (ev['endpoint'] as string | undefined) ?? '',
54
+ );
55
+ if (url.includes('openrouter.ai')) {
56
+ return true;
57
+ }
58
+
59
+ return false;
60
+ }
@@ -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.3.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
- "description": "Terminal overlay for OpenRouter usage: caps, spend, burn rate, and model breakdowns.",
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",