@robhowley/pi-openrouter 0.1.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 ADDED
@@ -0,0 +1,46 @@
1
+ # pi-openrouter
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.
4
+
5
+ ## Installation
6
+
7
+ ```shell
8
+ pi install npm:@robhowley/pi-openrouter
9
+ ```
10
+
11
+ ## Requirements
12
+
13
+ Set one of these environment variables:
14
+
15
+ - `OPENROUTER_MANAGEMENT_KEY` (preferred) — provides full usage data including model breakdowns
16
+ - `OPENROUTER_API_KEY` — basic usage data only
17
+
18
+ ```shell
19
+ export OPENROUTER_MANAGEMENT_KEY=sk-or-...
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Type `/openrouter-usage` in Pi to open the usage overlay.
25
+
26
+ The overlay shows:
27
+ - **Month spend** vs cap with percentage
28
+ - **7-day spend** with burn rate projection
29
+ - **Today's spend**
30
+ - **Top models** (7d and 30d)
31
+ - **Usage by provider** (30d)
32
+ - **Usage by day** (last 7 days)
33
+
34
+ The extension refreshes data in the background every 30 seconds (with exponential backoff on errors).
35
+
36
+ Press `q`, `Esc`, or `Ctrl+C` to close the overlay.
37
+
38
+ ## Features
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)
43
+
44
+ ## License
45
+
46
+ MIT
@@ -0,0 +1,218 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { aggregateUsage } from '../format.js';
3
+ import type { ActivityItem } from '@openrouter/sdk/models/index.js';
4
+
5
+ describe('aggregateUsage', () => {
6
+ it('should calculate from analytics', () => {
7
+ const credits = {
8
+ totalUsage: 38.42,
9
+ totalCredits: 100,
10
+ };
11
+ // Use a date that's within the last 7 days of when the test runs.
12
+ // We use a date from the past month that's been long enough to survive timezone offsets.
13
+ const date = '2026-05-01';
14
+ const analytics: ActivityItem[] = [
15
+ {
16
+ date: date,
17
+ model: 'model-1',
18
+ modelPermaslug: 'model-1-perma',
19
+ endpointId: 'ep-1',
20
+ usage: 5.42,
21
+ byokUsageInference: 0,
22
+ requests: 10,
23
+ promptTokens: 1000,
24
+ completionTokens: 100,
25
+ reasoningTokens: 0,
26
+ providerName: 'provider-1',
27
+ },
28
+ {
29
+ date: date,
30
+ model: 'model-2',
31
+ modelPermaslug: 'model-2-perma',
32
+ endpointId: 'ep-2',
33
+ usage: 3.11,
34
+ byokUsageInference: 0,
35
+ requests: 5,
36
+ promptTokens: 500,
37
+ completionTokens: 50,
38
+ reasoningTokens: 0,
39
+ providerName: 'provider-2',
40
+ },
41
+ ];
42
+
43
+ const result = aggregateUsage(credits, analytics);
44
+
45
+ expect(result.month).toBe(38.42);
46
+ // Data from May 1 should be in the week (but may not be in "today" depending on timezone)
47
+ expect(result.week).toBeGreaterThan(0);
48
+ // Today's data might not be from May 1 due to timezone, so just check month
49
+ });
50
+
51
+ it('should calculate burn rate correctly', () => {
52
+ const credits = {
53
+ totalUsage: 38.42,
54
+ totalCredits: 100,
55
+ };
56
+ const analytics: ActivityItem[] = [];
57
+
58
+ const result = aggregateUsage(credits, analytics);
59
+
60
+ expect(result.burnRate).toBe(0);
61
+ expect(result.week).toBe(0);
62
+ expect(result.today).toBe(0);
63
+ });
64
+
65
+ it('should handle empty analytics', () => {
66
+ const credits = {
67
+ totalUsage: 18.21,
68
+ totalCredits: 30,
69
+ };
70
+
71
+ const result = aggregateUsage(credits, []);
72
+
73
+ expect(result.month).toBe(18.21);
74
+ expect(result.week).toBe(0);
75
+ expect(result.today).toBe(0);
76
+ expect(result.topModels7d).toEqual([]);
77
+ expect(result.byModel).toEqual({});
78
+ expect(result.byKey).toEqual({});
79
+ expect(result.byDay).toEqual({});
80
+ });
81
+
82
+ it('should aggregate by model', () => {
83
+ const credits = {
84
+ totalUsage: 10,
85
+ totalCredits: 100,
86
+ };
87
+ // Use a fixed date that's definitely in the past
88
+ const date = '2026-05-03';
89
+ const analytics: ActivityItem[] = [
90
+ {
91
+ date: date,
92
+ model: 'gpt-4',
93
+ modelPermaslug: 'gpt-4-perma',
94
+ endpointId: 'ep-1',
95
+ usage: 5.0,
96
+ byokUsageInference: 0,
97
+ requests: 5,
98
+ promptTokens: 100,
99
+ completionTokens: 50,
100
+ reasoningTokens: 0,
101
+ providerName: 'openai',
102
+ },
103
+ {
104
+ date: date,
105
+ model: 'claude-3',
106
+ modelPermaslug: 'claude-3-perma',
107
+ endpointId: 'ep-2',
108
+ usage: 3.0,
109
+ byokUsageInference: 0,
110
+ requests: 3,
111
+ promptTokens: 60,
112
+ completionTokens: 30,
113
+ reasoningTokens: 0,
114
+ providerName: 'anthropic',
115
+ },
116
+ ];
117
+
118
+ const result = aggregateUsage(credits, analytics);
119
+
120
+ expect(result.byModel).toEqual({
121
+ 'gpt-4': 5.0,
122
+ 'claude-3': 3.0,
123
+ });
124
+ expect(result.topModels7d).toHaveLength(2);
125
+ const first = result.topModels7d[0]!;
126
+ expect(first.name).toBe('gpt-4');
127
+ expect(first.spend).toBe(5.0);
128
+ });
129
+
130
+ it('should aggregate by provider name (not endpoint ID)', () => {
131
+ const credits = {
132
+ totalUsage: 10,
133
+ totalCredits: 100,
134
+ };
135
+ const date = '2026-05-01';
136
+ const analytics: ActivityItem[] = [
137
+ {
138
+ date: date,
139
+ model: 'gpt-4',
140
+ modelPermaslug: 'gpt-4-perma',
141
+ endpointId: 'ep-1',
142
+ usage: 5.0,
143
+ byokUsageInference: 0,
144
+ requests: 5,
145
+ promptTokens: 100,
146
+ completionTokens: 50,
147
+ reasoningTokens: 0,
148
+ providerName: 'openai',
149
+ },
150
+ {
151
+ date: date,
152
+ model: 'claude-3',
153
+ modelPermaslug: 'claude-3-perma',
154
+ endpointId: 'ep-2',
155
+ usage: 3.0,
156
+ byokUsageInference: 0,
157
+ requests: 3,
158
+ promptTokens: 60,
159
+ completionTokens: 30,
160
+ reasoningTokens: 0,
161
+ providerName: 'openai', // Same provider, different endpoint
162
+ },
163
+ ];
164
+
165
+ const result = aggregateUsage(credits, analytics);
166
+
167
+ // byKey should use providerName, not endpointId
168
+ expect(result.byKey).toEqual({
169
+ openai: 8.0, // Total from both endpoints
170
+ });
171
+ // Should NOT contain endpoint IDs
172
+ expect(result.byKey).not.toHaveProperty('ep-1');
173
+ expect(result.byKey).not.toHaveProperty('ep-2');
174
+ });
175
+
176
+ it('should include 30d model data', () => {
177
+ const credits = {
178
+ totalUsage: 100,
179
+ totalCredits: 200,
180
+ };
181
+ // Data from recent dates for 30d
182
+ const analytics: ActivityItem[] = [
183
+ {
184
+ date: '2026-04-15',
185
+ model: 'gpt-4',
186
+ modelPermaslug: 'gpt-4-perma',
187
+ endpointId: 'ep-1',
188
+ usage: 50.0,
189
+ byokUsageInference: 0,
190
+ requests: 10,
191
+ promptTokens: 1000,
192
+ completionTokens: 100,
193
+ reasoningTokens: 0,
194
+ providerName: 'openai',
195
+ },
196
+ {
197
+ date: '2026-04-20',
198
+ model: 'claude-3',
199
+ modelPermaslug: 'claude-3-perma',
200
+ endpointId: 'ep-2',
201
+ usage: 30.0,
202
+ byokUsageInference: 0,
203
+ requests: 5,
204
+ promptTokens: 500,
205
+ completionTokens: 50,
206
+ reasoningTokens: 0,
207
+ providerName: 'anthropic',
208
+ },
209
+ ];
210
+
211
+ const result = aggregateUsage(credits, analytics);
212
+
213
+ // topModels30d should be populated
214
+ expect(result.topModels30d).toHaveLength(2);
215
+ expect(result.topModels30d[0]).toEqual({ name: 'gpt-4', spend: 50.0 });
216
+ expect(result.topModels30d[1]).toEqual({ name: 'claude-3', spend: 30.0 });
217
+ });
218
+ });
@@ -0,0 +1,115 @@
1
+ import type { UsageSummary } from './types.js';
2
+ import type { ActivityItem } from '@openrouter/sdk/models/index.js';
3
+ import { aggregateUsage } from './format.js';
4
+ import { getCredits, getActivity } from './client.js';
5
+
6
+ export const CACHE_TTL_MS = 45000;
7
+ export const BACKGROUND_REFRESH_INTERVAL_MS = 30000;
8
+
9
+ interface CacheEntry<T> {
10
+ data: T;
11
+ timestamp: number;
12
+ }
13
+
14
+ export class TTLCache<T> {
15
+ private cache = new Map<string, CacheEntry<T>>();
16
+
17
+ constructor(private ttlMs: number = CACHE_TTL_MS) {}
18
+
19
+ get(key: string): T | undefined {
20
+ const entry = this.cache.get(key);
21
+ if (!entry) return undefined;
22
+
23
+ if (Date.now() - entry.timestamp > this.ttlMs) {
24
+ this.cache.delete(key);
25
+ return undefined;
26
+ }
27
+
28
+ return entry.data;
29
+ }
30
+
31
+ getTimestamp(key: string): number | undefined {
32
+ const entry = this.cache.get(key);
33
+ if (!entry) return undefined;
34
+
35
+ if (Date.now() - entry.timestamp > this.ttlMs) {
36
+ this.cache.delete(key);
37
+ return undefined;
38
+ }
39
+
40
+ return entry.timestamp;
41
+ }
42
+
43
+ set(key: string, data: T): void {
44
+ this.cache.set(key, { data, timestamp: Date.now() });
45
+ }
46
+ }
47
+
48
+ export const usageCache = new TTLCache<UsageSummary>(CACHE_TTL_MS);
49
+
50
+ let refreshInterval: NodeJS.Timeout | null = null;
51
+ let consecutiveFailures = 0;
52
+ const MAX_RETRY_BACKOFF = 5; // Max 2^5 = 32x base interval (16 min)
53
+ const MAX_RETRY_COUNT = 4; // Stop after 4 consecutive failures
54
+
55
+ function getBackoffInterval(): number {
56
+ const backoffMultiplier = Math.min(consecutiveFailures, MAX_RETRY_BACKOFF);
57
+ return BACKGROUND_REFRESH_INTERVAL_MS * Math.pow(2, backoffMultiplier);
58
+ }
59
+
60
+ export async function fetchAndAggregate(): Promise<UsageSummary> {
61
+ const credits = await getCredits();
62
+ let analytics: ActivityItem[] | null = null;
63
+ try {
64
+ analytics = await getActivity();
65
+ } catch (err) {
66
+ console.log('Activity fetch failed (management key required):', err);
67
+ }
68
+ const timestamp = Date.now();
69
+ return aggregateUsage(credits, analytics ?? [], timestamp);
70
+ }
71
+
72
+ function scheduleRefresh(): void {
73
+ const delay = consecutiveFailures > 0 ? getBackoffInterval() : BACKGROUND_REFRESH_INTERVAL_MS;
74
+
75
+ refreshInterval = setInterval(async () => {
76
+ try {
77
+ const summary = await fetchAndAggregate();
78
+ usageCache.set('usage', summary);
79
+
80
+ // Reset failure count on success and restart with normal interval
81
+ if (consecutiveFailures > 0) {
82
+ consecutiveFailures = 0;
83
+ stopBackgroundRefresh();
84
+ scheduleRefresh();
85
+ }
86
+ } catch (err) {
87
+ consecutiveFailures++;
88
+ console.log(`Background refresh failed (${consecutiveFailures}/${MAX_RETRY_COUNT}):`, err);
89
+
90
+ // Stop after max retries reached
91
+ if (consecutiveFailures >= MAX_RETRY_COUNT) {
92
+ console.log('Max retries reached, stopping background refresh');
93
+ stopBackgroundRefresh();
94
+ // TODO: Fire UI notification for persistent failure
95
+ return;
96
+ }
97
+
98
+ // Restart with backoff interval
99
+ stopBackgroundRefresh();
100
+ scheduleRefresh();
101
+ }
102
+ }, delay);
103
+ }
104
+
105
+ export function startBackgroundRefresh(): void {
106
+ if (refreshInterval) return;
107
+ scheduleRefresh();
108
+ }
109
+
110
+ export function stopBackgroundRefresh(): void {
111
+ if (refreshInterval) {
112
+ clearInterval(refreshInterval);
113
+ refreshInterval = null;
114
+ }
115
+ }
@@ -0,0 +1,65 @@
1
+ import type { ActivityResponse } from '@openrouter/sdk/models/index.js';
2
+ import type { GetCreditsResponse } from '@openrouter/sdk/models/operations/index.js';
3
+ import { OpenRouter } from '@openrouter/sdk/sdk/sdk.js';
4
+
5
+ let client: OpenRouter | null = null;
6
+
7
+ function getClient(): OpenRouter {
8
+ if (client) return client;
9
+ const apiKey = process.env['OPENROUTER_MANAGEMENT_KEY'] || process.env['OPENROUTER_API_KEY'];
10
+ if (!apiKey) throw new AuthError('OPENROUTER_API_KEY or OPENROUTER_MANAGEMENT_KEY not set');
11
+ client = new OpenRouter({ apiKey });
12
+ return client;
13
+ }
14
+
15
+ export async function getCredits(): Promise<GetCreditsResponse['data']> {
16
+ try {
17
+ const response = await getClient().credits.getCredits();
18
+ return response.data;
19
+ } catch (err) {
20
+ throw mapSdkError(err);
21
+ }
22
+ }
23
+
24
+ export async function getActivity(): Promise<ActivityResponse['data']> {
25
+ try {
26
+ const response = await getClient().analytics.getUserActivity();
27
+ return response.data;
28
+ } catch (err) {
29
+ throw mapSdkError(err);
30
+ }
31
+ }
32
+
33
+ interface SDKError {
34
+ status?: number;
35
+ message?: string;
36
+ }
37
+
38
+ function isSDKError(err: unknown): err is SDKError {
39
+ return err !== null && typeof err === 'object' && 'status' in err;
40
+ }
41
+
42
+ function mapSdkError(err: unknown): Error {
43
+ if (isSDKError(err)) {
44
+ const status = err.status;
45
+ const message = err.message ?? 'Unknown error';
46
+ if (status === 401) return new AuthError(message);
47
+ return new ApiError(`${status}: ${message}`);
48
+ }
49
+ if (err instanceof Error) return err;
50
+ return new Error(String(err));
51
+ }
52
+
53
+ export class AuthError extends Error {
54
+ constructor(message: string) {
55
+ super(message);
56
+ this.name = 'AuthError';
57
+ }
58
+ }
59
+
60
+ export class ApiError extends Error {
61
+ constructor(message: string) {
62
+ super(message);
63
+ this.name = 'ApiError';
64
+ }
65
+ }
@@ -0,0 +1,88 @@
1
+ import type { ActivityItem } from '@openrouter/sdk/models/index.js';
2
+ import type { UsageSummary } from './types.js';
3
+
4
+ export function aggregateUsage(
5
+ credits: { totalUsage: number; totalCredits?: number },
6
+ analytics: ActivityItem[],
7
+ timestamp: number = Date.now(),
8
+ ): UsageSummary {
9
+ const now = new Date();
10
+ const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
11
+ const startOfWeek = new Date(startOfDay);
12
+ startOfWeek.setDate(startOfWeek.getDate() - 7);
13
+
14
+ const weekData = analytics.filter((d) => {
15
+ const ts = new Date(d.date);
16
+ return ts >= startOfWeek;
17
+ });
18
+
19
+ const todayData = analytics.filter((d) => {
20
+ const ts = new Date(d.date);
21
+ return ts >= startOfDay;
22
+ });
23
+
24
+ const week = sumSpend(weekData);
25
+ const today = sumSpend(todayData);
26
+ const month = credits.totalUsage;
27
+
28
+ // Top models by 7d spend
29
+ const modelSpend7d = aggregateByModel(weekData);
30
+ const topModels7d = Object.entries(modelSpend7d)
31
+ .map(([name, spend]) => ({ name, spend }))
32
+ .sort((a, b) => b.spend - a.spend)
33
+ .slice(0, 3);
34
+
35
+ // Top models by 30d spend
36
+ const modelSpend30d = aggregateByModel(analytics);
37
+ const topModels30d = Object.entries(modelSpend30d)
38
+ .map(([name, spend]) => ({ name, spend }))
39
+ .sort((a, b) => b.spend - a.spend)
40
+ .slice(0, 3);
41
+
42
+ return {
43
+ today,
44
+ week,
45
+ month,
46
+ cap: credits.totalCredits ?? 0,
47
+ burnRate: (week / 7) * 30,
48
+ topModels7d,
49
+ topModels30d,
50
+ byModel: aggregateByModel(analytics),
51
+ byKey: aggregateByProvider(analytics),
52
+ byDay: aggregateByDay(analytics),
53
+ timestamp,
54
+ };
55
+ }
56
+
57
+ function sumSpend(data: ActivityItem[]): number {
58
+ return data.reduce((sum, d) => sum + d.usage, 0);
59
+ }
60
+
61
+ function aggregateByModel(data: ActivityItem[]): Record<string, number> {
62
+ return data.reduce(
63
+ (acc, d) => {
64
+ acc[d.model] = (acc[d.model] || 0) + d.usage;
65
+ return acc;
66
+ },
67
+ {} as Record<string, number>,
68
+ );
69
+ }
70
+
71
+ function aggregateByProvider(data: ActivityItem[]): Record<string, number> {
72
+ return data.reduce(
73
+ (acc, d) => {
74
+ acc[d.providerName] = (acc[d.providerName] || 0) + d.usage;
75
+ return acc;
76
+ },
77
+ {} as Record<string, number>,
78
+ );
79
+ }
80
+
81
+ function aggregateByDay(data: ActivityItem[]): Record<string, number> {
82
+ const byDay: Record<string, number> = {};
83
+ for (const d of data) {
84
+ const day = d.date;
85
+ byDay[day] = (byDay[day] || 0) + d.usage;
86
+ }
87
+ return byDay;
88
+ }
@@ -0,0 +1,94 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
2
+ import type { UsageSummary } from './types.js';
3
+ import {
4
+ usageCache,
5
+ startBackgroundRefresh,
6
+ stopBackgroundRefresh,
7
+ fetchAndAggregate,
8
+ } from './cache.js';
9
+ import { AuthError } from './client.js';
10
+ import { UsageOverlayComponent } from './overlay.js';
11
+
12
+ export default function (pi: ExtensionAPI) {
13
+ startBackgroundRefresh();
14
+
15
+ pi.on('session_start', async (_event, ctx) => {
16
+ ctx.ui.notify('OpenRouter extension loaded', 'info');
17
+ });
18
+
19
+ pi.on('session_shutdown', () => {
20
+ stopBackgroundRefresh();
21
+ });
22
+
23
+ pi.registerCommand('openrouter-usage', {
24
+ description: 'Show OpenRouter usage: caps, spend, burn rate, and model breakdowns',
25
+ getArgumentCompletions: () => null,
26
+ handler: async (args, ctx) => {
27
+ const subcommand = args.trim() || undefined;
28
+ await showUsageOverlay(ctx, subcommand);
29
+ },
30
+ });
31
+ }
32
+
33
+ async function showUsageOverlay(ctx: ExtensionContext, _subcommand?: string) {
34
+ const cachedSummary = usageCache.get('usage');
35
+ const lastFetchTimestamp = usageCache.getTimestamp('usage');
36
+ const cachedMinutesAgo = lastFetchTimestamp
37
+ ? Math.round((Date.now() - lastFetchTimestamp) / 60000)
38
+ : null;
39
+
40
+ if (cachedSummary) {
41
+ await showOverlay(ctx, cachedSummary, null, cachedMinutesAgo);
42
+ return;
43
+ }
44
+
45
+ let error: string | null = null;
46
+ let summary: UsageSummary | null = null;
47
+
48
+ try {
49
+ summary = await fetchAndAggregate();
50
+ usageCache.set('usage', summary);
51
+
52
+ await showOverlay(ctx, summary, null, 0);
53
+ } catch (error_) {
54
+ const err = error_ as Error;
55
+ error =
56
+ err instanceof AuthError
57
+ ? 'OpenRouter API key not found. Set OPENROUTER_MANAGEMENT_KEY (preferred) or OPENROUTER_API_KEY to use /usage.'
58
+ : `API Error: ${err.message}`;
59
+ await showOverlay(ctx, null, error, cachedMinutesAgo || 0);
60
+ }
61
+ }
62
+
63
+ async function showOverlay(
64
+ ctx: ExtensionContext,
65
+ summary: UsageSummary | null,
66
+ error: string | null,
67
+ cachedMinutesAgo: number | null,
68
+ ) {
69
+ await ctx.ui.custom<void>(
70
+ (_tui, theme, _keybindings, done) => {
71
+ const overlayComponent = new UsageOverlayComponent(
72
+ summary,
73
+ error,
74
+ cachedMinutesAgo,
75
+ theme,
76
+ done,
77
+ () => _tui.requestRender(),
78
+ );
79
+
80
+ return {
81
+ handleInput: (data: string) => {
82
+ overlayComponent.handleInput(data);
83
+ _tui.requestRender();
84
+ },
85
+ render: (width: number) => overlayComponent.render(width),
86
+ invalidate: () => overlayComponent.invalidate(),
87
+ dispose: () => {
88
+ overlayComponent.dispose();
89
+ },
90
+ };
91
+ },
92
+ { overlay: true },
93
+ );
94
+ }
@@ -0,0 +1,334 @@
1
+ import { matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
2
+ import type { Theme } from '@mariozechner/pi-coding-agent';
3
+ import type { UsageSummary } from './types.js';
4
+ import { usageCache } from './cache.js';
5
+
6
+ const MIN_WIDTH = 44;
7
+ const MAX_WIDTH = 80;
8
+
9
+ export class UsageOverlayComponent {
10
+ private lines: string[];
11
+ private theme: Theme;
12
+ private onClose: () => void;
13
+ private width: number;
14
+ private summary: UsageSummary | null;
15
+ private error: string | null;
16
+ private cachedMinutesAgo: number | null;
17
+ private refreshTimer: NodeJS.Timeout | null = null;
18
+ private requestRender: () => void;
19
+ private isDisposed = false;
20
+
21
+ constructor(
22
+ summary: UsageSummary | null,
23
+ error: string | null,
24
+ cachedMinutesAgo: number | null,
25
+ theme: Theme,
26
+ onClose: () => void,
27
+ requestRender: () => void,
28
+ ) {
29
+ this.theme = theme;
30
+ this.onClose = onClose;
31
+ this.requestRender = requestRender;
32
+ this.summary = summary;
33
+ this.error = error;
34
+ this.cachedMinutesAgo = cachedMinutesAgo;
35
+ this.width = this.calculateWidth(summary);
36
+ this.lines = this.buildLines(summary, error, cachedMinutesAgo);
37
+
38
+ // Set up timer to rebuild lines every 30 seconds to update "last refreshed" time
39
+ this.refreshTimer = setInterval(() => {
40
+ this.invalidate();
41
+ // Force re-render by calling requestRender on the TUI
42
+ // We need to store a reference to requestRender to do this
43
+ }, 30000);
44
+ }
45
+
46
+ dispose(): void {
47
+ this.isDisposed = true;
48
+ if (this.refreshTimer) {
49
+ clearInterval(this.refreshTimer);
50
+ this.refreshTimer = null;
51
+ }
52
+ }
53
+
54
+ handleInput(data: string): void {
55
+ // Close on q, escape, or ctrl+c
56
+ if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c') || data === 'q') {
57
+ this.onClose();
58
+ }
59
+ }
60
+
61
+ render(width: number): string[] {
62
+ // Center the overlay if terminal is wider
63
+ const padding = Math.max(0, Math.floor((width - this.width) / 2));
64
+ const pad = ' '.repeat(padding);
65
+
66
+ return this.lines.map((line) => truncateToWidth(pad + line, width));
67
+ }
68
+
69
+ invalidate(): void {
70
+ if (this.isDisposed) return;
71
+ // Rebuild lines to update "last refreshed" time from fresh cached data
72
+ const freshSummary = usageCache.get('usage');
73
+ this.lines = this.buildLines(freshSummary || this.summary, this.error, this.cachedMinutesAgo);
74
+ this.requestRender();
75
+ }
76
+
77
+ private calculateWidth(summary: UsageSummary | null): number {
78
+ if (!summary) return MIN_WIDTH;
79
+
80
+ let maxWidth = MIN_WIDTH;
81
+
82
+ // Calculate width needed for top models table
83
+ if (summary.topModels7d.length > 0 || summary.topModels30d.length > 0) {
84
+ const allModelNames = [
85
+ ...summary.topModels7d.map((m) => m.name),
86
+ ...summary.topModels30d.map((m) => m.name),
87
+ ];
88
+ const maxModelNameLen = allModelNames.reduce((max, name) => Math.max(max, name.length), 0);
89
+ const amountWidth = 8; // "$X.XX" + padding
90
+ const rowWidth = 2 + maxModelNameLen + 2 + amountWidth + 2 + amountWidth + 2;
91
+ maxWidth = Math.max(maxWidth, rowWidth);
92
+ }
93
+
94
+ // Calculate width needed for provider table
95
+ if (summary.byKey && Object.keys(summary.byKey).length > 0) {
96
+ const maxProviderLen = Object.keys(summary.byKey).reduce(
97
+ (max, name) => Math.max(max, name.length),
98
+ 0,
99
+ );
100
+ const amountWidth = 8;
101
+ const rowWidth = 2 + maxProviderLen + 2 + amountWidth + 2;
102
+ maxWidth = Math.max(maxWidth, rowWidth);
103
+ }
104
+
105
+ // Calculate width needed for by-day table
106
+ if (summary.byDay && Object.keys(summary.byDay).length > 0) {
107
+ maxWidth = Math.max(maxWidth, 21);
108
+ }
109
+
110
+ // Ensure we have room for main stats
111
+ // "Month $X.XX / $X.XX cap (XX%)" - max ~35 chars
112
+ // "burn ~$X.XX" = 13 chars + space + "Today $X.XX" = 11
113
+ // Need: 35 + 1 + 13 + 2 (borders) = 51, or 35 + 1 + 11 + 2 = 49
114
+ // Use 46 as it fits both cases with proper padding
115
+ maxWidth = Math.max(maxWidth, 46);
116
+
117
+ return Math.min(maxWidth, MAX_WIDTH);
118
+ }
119
+
120
+ private buildLines(
121
+ summary: UsageSummary | null,
122
+ error: string | null,
123
+ cachedMinutesAgo: number | null,
124
+ ): string[] {
125
+ const th = this.theme;
126
+ const lines: string[] = [];
127
+
128
+ if (error) {
129
+ lines.push(boxTop(this.width));
130
+ lines.push(row('OpenRouter Usage', this.width));
131
+ lines.push(emptyRow(this.width));
132
+ lines.push(row(th.fg('error', error), this.width));
133
+ if (cachedMinutesAgo !== null) {
134
+ lines.push(
135
+ row(th.fg('dim', `(last successful fetch: ${cachedMinutesAgo}m ago)`), this.width),
136
+ );
137
+ }
138
+ lines.push(boxBottom(this.width));
139
+ lines.push(plainRow(th.fg('dim', 'Esc to close'), this.width));
140
+ return lines;
141
+ }
142
+
143
+ if (!summary) {
144
+ lines.push(boxTop(this.width));
145
+ lines.push(row('OpenRouter Usage', this.width));
146
+ lines.push(emptyRow(this.width));
147
+ lines.push(row(th.fg('dim', 'No usage data available.'), this.width));
148
+ lines.push(boxBottom(this.width));
149
+ lines.push(plainRow(th.fg('dim', 'Esc to close'), this.width));
150
+ return lines;
151
+ }
152
+
153
+ // Summary view (subcommand views TODO)
154
+ lines.push(boxTop(this.width));
155
+ lines.push(row('OpenRouter Usage', this.width));
156
+ lines.push(emptyRow(this.width));
157
+
158
+ // Month row: amount stays with label, cap percentage right-aligned
159
+ const monthLeftBase = `Month $${fmt(summary.month)} / $${fmt(summary.cap)}`;
160
+ const monthRight = `cap (${summary.cap > 0 ? Math.round((summary.month / summary.cap) * 100) : 0}%)`;
161
+ lines.push(rowRightAligned(monthLeftBase, monthRight, this.width));
162
+
163
+ // 7d row: amount stays with label, burn rate right-aligned
164
+ const weekLeftBase = `7d $${fmt(summary.week)}`;
165
+ const weekRight = `burn ~$${fmt(summary.burnRate)}`;
166
+ lines.push(rowRightAligned(weekLeftBase, weekRight, this.width));
167
+
168
+ // Today row on its own line
169
+ const todayContent = `Today $${fmt(summary.today)}`;
170
+ lines.push(rowRightAligned(todayContent, '', this.width));
171
+ lines.push(emptyRow(this.width));
172
+
173
+ // Top models - 7d and 30d as columns
174
+ if (summary.topModels7d.length > 0 || summary.topModels30d.length > 0) {
175
+ // Calculate column widths
176
+ const allModelNames = [
177
+ ...summary.topModels7d.map((m) => m.name),
178
+ ...summary.topModels30d.map((m) => m.name),
179
+ ];
180
+ const maxModelNameLen = allModelNames.reduce((max, name) => Math.max(max, name.length), 0);
181
+ const headerModelWidth = Math.max(7, maxModelNameLen);
182
+ const amountWidth = 8; // "$X.XX" + padding
183
+
184
+ lines.push(row('Top models', this.width));
185
+ lines.push(
186
+ row(
187
+ ` Model${' '.repeat(headerModelWidth - 5)} ${'7d'.padStart(amountWidth)} ${'30d'.padStart(amountWidth)}`,
188
+ this.width,
189
+ ),
190
+ );
191
+ lines.push(
192
+ row(
193
+ ` ${'-'.repeat(headerModelWidth)} ${'-'.repeat(amountWidth)} ${'-'.repeat(amountWidth)}`,
194
+ this.width,
195
+ ),
196
+ );
197
+
198
+ // Build spend map from 7d data
199
+ const spendMap = new Map<string, { spend7d: number; spend30d: number }>();
200
+ for (const m of summary.topModels7d) {
201
+ spendMap.set(m.name, { spend7d: m.spend, spend30d: 0 });
202
+ }
203
+ // Add/update with 30d data
204
+ for (const m of summary.topModels30d) {
205
+ const existing = spendMap.get(m.name);
206
+ spendMap.set(m.name, {
207
+ spend7d: existing?.spend7d ?? 0,
208
+ spend30d: m.spend,
209
+ });
210
+ }
211
+
212
+ // Sort by 30d spend (primary), then 7d (secondary)
213
+ const sortedModels = Array.from(spendMap.entries())
214
+ .sort((a, b) => b[1].spend30d - a[1].spend30d || b[1].spend7d - a[1].spend7d)
215
+ .slice(0, 6); // Show top 6 models
216
+
217
+ for (const [name, spends] of sortedModels) {
218
+ const spend7dStr = spends.spend7d > 0 ? `$${fmt(spends.spend7d)}` : '-';
219
+ const spend30dStr = spends.spend30d > 0 ? `$${fmt(spends.spend30d)}` : '-';
220
+ const modelLabel = name; // Don't truncate - let the row function handle it
221
+ lines.push(
222
+ row(
223
+ ` ${modelLabel}${' '.repeat(Math.max(0, headerModelWidth - name.length))} ${spend7dStr.padStart(amountWidth)} ${spend30dStr.padStart(amountWidth)}`,
224
+ this.width,
225
+ ),
226
+ );
227
+ }
228
+ lines.push(emptyRow(this.width));
229
+ }
230
+
231
+ // Usage by Provider
232
+ if (summary.byKey && Object.keys(summary.byKey).length > 0) {
233
+ const sortedProviders = Object.entries(summary.byKey)
234
+ .sort((a, b) => b[1] - a[1])
235
+ .slice(0, 6);
236
+ const maxProviderLen = sortedProviders.reduce((max, [name]) => Math.max(max, name.length), 0);
237
+
238
+ lines.push(row('By provider', this.width));
239
+ lines.push(row(` Provider${' '.repeat(maxProviderLen - 8)} 30d`, this.width));
240
+ lines.push(row(` ${'-'.repeat(maxProviderLen)} ------`, this.width));
241
+
242
+ for (const [provider, spend] of sortedProviders) {
243
+ lines.push(
244
+ row(
245
+ ` ${provider}${' '.repeat(maxProviderLen - provider.length)} $${fmt(spend)}`,
246
+ this.width,
247
+ ),
248
+ );
249
+ }
250
+ lines.push(emptyRow(this.width));
251
+ }
252
+
253
+ // Usage by Day (7d)
254
+ if (summary.byDay && Object.keys(summary.byDay).length > 0) {
255
+ const sortedDays = Object.entries(summary.byDay)
256
+ .sort((a, b) => a[0].localeCompare(b[0]))
257
+ .slice(-7); // Last 7 days
258
+ const maxDateLen = sortedDays.reduce((max, [date]) => Math.max(max, date.length), 0);
259
+
260
+ lines.push(row('By day', this.width));
261
+ lines.push(row(` Date${' '.repeat(maxDateLen - 4)} Amount`, this.width));
262
+ lines.push(row(` ${'-'.repeat(maxDateLen)} ------`, this.width));
263
+
264
+ for (const [day, spend] of sortedDays) {
265
+ lines.push(
266
+ row(` ${day}${' '.repeat(maxDateLen - day.length)} $${fmt(spend)}`, this.width),
267
+ );
268
+ }
269
+ lines.push(emptyRow(this.width));
270
+ }
271
+
272
+ // Last refresh time at the bottom
273
+ if (summary?.timestamp) {
274
+ const refreshDate = new Date(summary.timestamp);
275
+ const timestampStr = refreshDate.toLocaleTimeString();
276
+ lines.push(row(`Last refreshed: ${timestampStr}`, this.width));
277
+ lines.push(emptyRow(this.width));
278
+ }
279
+
280
+ lines.push(boxBottom(this.width));
281
+ lines.push(plainRow(th.fg('dim', 'Esc to close'), this.width));
282
+ return lines;
283
+ }
284
+ }
285
+
286
+ // Helper functions
287
+ function boxTop(width: number): string {
288
+ return `┌${'─'.repeat(width - 2)}┐`;
289
+ }
290
+
291
+ function boxBottom(width: number): string {
292
+ return `└${'─'.repeat(width - 2)}┘`;
293
+ }
294
+
295
+ function emptyRow(width: number): string {
296
+ return `│${' '.repeat(width - 2)}│`;
297
+ }
298
+
299
+ function row(content: string, width: number): string {
300
+ const truncated = content.length > width - 2 ? content.slice(0, width - 2) : content;
301
+ return `│${truncated}${' '.repeat(width - 2 - truncated.length)}│`;
302
+ }
303
+
304
+ function plainRow(content: string, width: number): string {
305
+ const truncated = content.length > width - 2 ? content.slice(0, width - 2) : content;
306
+ return ` ${truncated}${' '.repeat(width - 1 - truncated.length)} `;
307
+ }
308
+
309
+ // Helper to create a row with left content padded to align right content
310
+ function rowRightAligned(leftContent: string, rightContent: string, width: number): string {
311
+ const boxInnerWidth = width - 2; // -2 for box borders
312
+ const rightWidth = rightContent.length;
313
+
314
+ if (rightWidth === 0) {
315
+ // No right content - just pad left to full width
316
+ const leftPadded = leftContent.padEnd(boxInnerWidth, ' ');
317
+ return row(leftPadded, width);
318
+ }
319
+
320
+ // Account for the space between left and right content
321
+ const remainingWidth = boxInnerWidth - rightWidth - 1;
322
+
323
+ // Pad left content to align right content
324
+ const leftPadded =
325
+ leftContent.length > remainingWidth
326
+ ? leftContent.slice(0, remainingWidth - 3) + '...'
327
+ : leftContent.padEnd(remainingWidth, ' ');
328
+
329
+ return row(`${leftPadded} ${rightContent}`, width);
330
+ }
331
+
332
+ function fmt(value: number): string {
333
+ return value.toFixed(2);
334
+ }
@@ -0,0 +1,18 @@
1
+ export interface UsageSummary {
2
+ today: number;
3
+ week: number;
4
+ month: number;
5
+ cap: number;
6
+ burnRate: number;
7
+ topModels7d: { name: string; spend: number }[];
8
+ topModels30d: { name: string; spend: number }[];
9
+ byModel?: Record<string, number>;
10
+ byKey?: Record<string, number>;
11
+ byDay?: Record<string, number>;
12
+ timestamp: number;
13
+ }
14
+
15
+ export interface CacheEntry<T> {
16
+ data: T;
17
+ timestamp: number;
18
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@robhowley/pi-openrouter",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Terminal overlay for OpenRouter usage: caps, spend, burn rate, and model breakdowns.",
6
+ "files": [
7
+ "extensions",
8
+ "README.md",
9
+ "../LICENSE"
10
+ ],
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "keywords": [
15
+ "pi-package",
16
+ "openrouter"
17
+ ],
18
+ "pi": {
19
+ "extensions": [
20
+ "./extensions/openrouter"
21
+ ]
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/robhowley/pi-userland.git",
26
+ "directory": "packages/pi-openrouter"
27
+ },
28
+ "homepage": "https://github.com/robhowley/pi-userland/tree/main/packages/pi-openrouter",
29
+ "scripts": {
30
+ "lint": "eslint extensions/",
31
+ "format:check": "prettier --check extensions/",
32
+ "format:write": "prettier --write extensions/",
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "vitest run __tests__"
35
+ },
36
+ "dependencies": {
37
+ "@mariozechner/pi-tui": "*",
38
+ "@openrouter/sdk": "^0.12.21"
39
+ },
40
+ "peerDependencies": {
41
+ "@mariozechner/pi-ai": "*",
42
+ "@mariozechner/pi-coding-agent": "*"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.15.17"
46
+ }
47
+ }