@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 +25 -10
- package/extensions/openrouter/__tests__/format.test.ts +4 -6
- package/extensions/openrouter/__tests__/session.test.ts +101 -0
- package/extensions/openrouter/cache.ts +86 -10
- package/extensions/openrouter/format.ts +60 -21
- package/extensions/openrouter/index.ts +143 -3
- package/extensions/openrouter/local-usage.ts +148 -0
- package/extensions/openrouter/overlay.ts +32 -5
- package/extensions/openrouter/session.ts +60 -0
- package/extensions/openrouter/types.ts +64 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-openrouter
|
|
2
2
|
|
|
3
|
-
A [Pi](https://pi.dev/) extension
|
|
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)
|
|
16
|
-
- `OPENROUTER_API_KEY
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
-
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
68
|
+
console.log('Activity fetch failed');
|
|
72
69
|
}
|
|
73
70
|
hasActivityData = false;
|
|
74
71
|
}
|
|
75
72
|
const timestamp = Date.now();
|
|
76
|
-
|
|
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
|
|
173
|
+
} catch {
|
|
98
174
|
consecutiveFailures++;
|
|
99
|
-
console.log(`Background refresh failed (${consecutiveFailures}/${MAX_RETRY_COUNT})
|
|
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
|
|
5
|
-
function
|
|
6
|
-
|
|
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
|
-
|
|
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.
|
|
21
|
+
startOfWeek.setUTCDate(startOfWeek.getUTCDate() - 7);
|
|
21
22
|
|
|
22
23
|
const weekData = analytics.filter((d) => {
|
|
23
|
-
// API dates are YYYY-MM-DD; compare by
|
|
24
|
-
return d.date >=
|
|
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
|
|
29
|
-
return d.date >=
|
|
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
|
|
33
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
50
|
-
byDay: aggregateByDay(
|
|
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 +=
|
|
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
|
-
|
|
14
|
-
|
|
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();
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
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
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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",
|