@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 +46 -0
- package/extensions/openrouter/__tests__/format.test.ts +218 -0
- package/extensions/openrouter/cache.ts +115 -0
- package/extensions/openrouter/client.ts +65 -0
- package/extensions/openrouter/format.ts +88 -0
- package/extensions/openrouter/index.ts +94 -0
- package/extensions/openrouter/overlay.ts +334 -0
- package/extensions/openrouter/types.ts +18 -0
- package/package.json +47 -0
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
|
+
}
|