@robhowley/pi-openrouter 0.9.0 → 0.10.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.
Files changed (39) hide show
  1. package/README.md +46 -5
  2. package/extensions/openrouter/__tests__/cache.test.ts +769 -0
  3. package/extensions/openrouter/__tests__/client.test.ts +333 -15
  4. package/extensions/openrouter/__tests__/commands.test.ts +816 -0
  5. package/extensions/openrouter/__tests__/fixtures.ts +140 -1
  6. package/extensions/openrouter/__tests__/format.test.ts +19 -0
  7. package/extensions/openrouter/__tests__/hooks.test.ts +529 -0
  8. package/extensions/openrouter/__tests__/index.test.ts +112 -363
  9. package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
  10. package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
  11. package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
  12. package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
  13. package/extensions/openrouter/__tests__/session.test.ts +44 -43
  14. package/extensions/openrouter/__tests__/status-bar.test.ts +262 -0
  15. package/extensions/openrouter/account-client.ts +11 -61
  16. package/extensions/openrouter/cache.ts +203 -91
  17. package/extensions/openrouter/client.ts +49 -3
  18. package/extensions/openrouter/commands.ts +555 -0
  19. package/extensions/openrouter/format.ts +7 -4
  20. package/extensions/openrouter/hooks.ts +288 -0
  21. package/extensions/openrouter/index.ts +13 -990
  22. package/extensions/openrouter/local-usage.ts +158 -30
  23. package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
  24. package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
  25. package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
  26. package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
  27. package/extensions/openrouter/models/cache.ts +27 -2
  28. package/extensions/openrouter/models/mapper.ts +35 -69
  29. package/extensions/openrouter/models/override-commands.ts +434 -0
  30. package/extensions/openrouter/models/skip-hints.ts +19 -0
  31. package/extensions/openrouter/models/sync.ts +22 -10
  32. package/extensions/openrouter/models/types.ts +2 -1
  33. package/extensions/openrouter/normalizers.ts +128 -0
  34. package/extensions/openrouter/overlay.ts +19 -8
  35. package/extensions/openrouter/session-state.ts +110 -0
  36. package/extensions/openrouter/session.ts +16 -0
  37. package/extensions/openrouter/status-bar.ts +101 -0
  38. package/extensions/openrouter/types.ts +28 -9
  39. package/package.json +1 -1
@@ -0,0 +1,262 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { setLocalUsageDir, dedupeLocalUsageEvents } from '../local-usage.js';
6
+ import {
7
+ calculateOpenRouterStatusStats,
8
+ formatOpenRouterStatusBar,
9
+ loadOpenRouterStatusBar,
10
+ loadOpenRouterStatusStats,
11
+ type OpenRouterStatusStats,
12
+ } from '../status-bar.js';
13
+ import type { LocalUsageEvent } from '../types.js';
14
+
15
+ let testDir: string;
16
+
17
+ function createLocalUsageEvent(
18
+ id: string,
19
+ completedAt: string,
20
+ cost: number,
21
+ overrides: Partial<LocalUsageEvent> = {},
22
+ ): LocalUsageEvent {
23
+ return {
24
+ id,
25
+ generationId: `${id}-generation`,
26
+ sessionId: 'session-test',
27
+ completedAt,
28
+ requests: 1,
29
+ model: 'openrouter/anthropic/claude-sonnet-4',
30
+ provider: 'anthropic',
31
+ promptTokens: 10,
32
+ completionTokens: 5,
33
+ cost,
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ async function writeDailyFile(
39
+ dateUtc: string,
40
+ rows: Array<LocalUsageEvent | string>,
41
+ ): Promise<void> {
42
+ const content = rows
43
+ .map((row) => (typeof row === 'string' ? row : JSON.stringify(row)))
44
+ .join('\n');
45
+ await fs.writeFile(path.join(testDir, `${dateUtc}.jsonl`), `${content}\n`, 'utf8');
46
+ }
47
+
48
+ beforeEach(async () => {
49
+ testDir = path.join(
50
+ os.tmpdir(),
51
+ `pi-openrouter-status-bar-${Date.now()}-${Math.random().toString(36).slice(2)}`,
52
+ );
53
+ await fs.mkdir(testDir, { recursive: true });
54
+ setLocalUsageDir(testDir);
55
+ });
56
+
57
+ afterEach(async () => {
58
+ setLocalUsageDir(null);
59
+ vi.restoreAllMocks();
60
+ vi.doUnmock('../local-usage.js');
61
+ vi.doUnmock('../status-bar.js');
62
+
63
+ try {
64
+ await fs.rm(testDir, { recursive: true, force: true });
65
+ } catch {
66
+ // Ignore cleanup errors.
67
+ }
68
+ });
69
+
70
+ describe('calculateOpenRouterStatusStats', () => {
71
+ it('returns null for no local events', () => {
72
+ expect(calculateOpenRouterStatusStats([], '2026-05-22')).toBeNull();
73
+ });
74
+
75
+ it('returns null when the full 30-day window totals zero local spend', () => {
76
+ expect(
77
+ calculateOpenRouterStatusStats(
78
+ [
79
+ createLocalUsageEvent('today-zero', '2026-05-22T09:15:00.000Z', 0),
80
+ createLocalUsageEvent('older-zero', '2026-05-12T09:15:00.000Z', 0),
81
+ ],
82
+ '2026-05-22',
83
+ ),
84
+ ).toBeNull();
85
+ });
86
+
87
+ it('includes only UTC-today spend and divides by exactly 30 calendar days', () => {
88
+ const stats = calculateOpenRouterStatusStats(
89
+ [
90
+ createLocalUsageEvent('today', '2026-05-22T09:15:00.000Z', 3),
91
+ createLocalUsageEvent('yesterday', '2026-05-21T09:15:00.000Z', 9),
92
+ ],
93
+ '2026-05-22',
94
+ );
95
+
96
+ expect(stats).toEqual({
97
+ todayLocalSpend: 3,
98
+ averageLocalDailySpendLast30Days: 0.4,
99
+ burnRateMultiplier: 7.5,
100
+ });
101
+ });
102
+
103
+ it('uses only the today-29 through today window', () => {
104
+ const stats = calculateOpenRouterStatusStats(
105
+ [
106
+ createLocalUsageEvent('old', '2026-04-22T12:00:00.000Z', 100),
107
+ createLocalUsageEvent('boundary', '2026-04-23T12:00:00.000Z', 6),
108
+ createLocalUsageEvent('recent', '2026-05-21T12:00:00.000Z', 3),
109
+ createLocalUsageEvent('today', '2026-05-22T12:00:00.000Z', 1.5),
110
+ ],
111
+ '2026-05-22',
112
+ );
113
+
114
+ expect(stats).toEqual({
115
+ todayLocalSpend: 1.5,
116
+ averageLocalDailySpendLast30Days: 0.35,
117
+ burnRateMultiplier: 1.5 / 0.35,
118
+ });
119
+ });
120
+
121
+ it('deduplicates event ids exactly once across the full 30-day window', () => {
122
+ const events = [
123
+ createLocalUsageEvent('duplicate-id', '2026-05-20T12:00:00.000Z', 1),
124
+ createLocalUsageEvent('duplicate-id', '2026-05-22T12:00:00.000Z', 99),
125
+ createLocalUsageEvent('unique-id', '2026-05-22T13:00:00.000Z', 2),
126
+ ];
127
+
128
+ expect(dedupeLocalUsageEvents(events)).toHaveLength(2);
129
+ expect(calculateOpenRouterStatusStats(events, '2026-05-22')).toEqual({
130
+ todayLocalSpend: 2,
131
+ averageLocalDailySpendLast30Days: 0.1,
132
+ burnRateMultiplier: 20,
133
+ });
134
+ });
135
+ });
136
+
137
+ describe('formatOpenRouterStatusBar', () => {
138
+ it('formats the representative status text exactly', () => {
139
+ const stats: OpenRouterStatusStats = {
140
+ todayLocalSpend: 2.14,
141
+ averageLocalDailySpendLast30Days: 1.64,
142
+ burnRateMultiplier: 1.3,
143
+ };
144
+
145
+ expect(formatOpenRouterStatusBar(stats)).toBe('OR $2.14 today · 1.3x 30d avg');
146
+ });
147
+
148
+ it('formats $0.00 today · 0.0x 30d avg when prior 30-day spend exists but today spend is zero', () => {
149
+ const stats: OpenRouterStatusStats = {
150
+ todayLocalSpend: 0,
151
+ averageLocalDailySpendLast30Days: 0.5,
152
+ burnRateMultiplier: 0,
153
+ };
154
+
155
+ expect(formatOpenRouterStatusBar(stats)).toBe('OR $0.00 today · 0.0x 30d avg');
156
+ });
157
+
158
+ it('omits the multiplier when given a stats object with no denominator', () => {
159
+ expect(
160
+ formatOpenRouterStatusBar({
161
+ todayLocalSpend: 2.14,
162
+ averageLocalDailySpendLast30Days: 0,
163
+ burnRateMultiplier: null,
164
+ }),
165
+ ).toBe('OR $2.14 today');
166
+ });
167
+ });
168
+
169
+ describe('loadOpenRouterStatusStats and loadOpenRouterStatusBar', () => {
170
+ it('returns an empty result for empty local usage data', async () => {
171
+ const now = new Date('2026-05-22T12:00:00.000Z');
172
+
173
+ await expect(loadOpenRouterStatusStats(now)).resolves.toBeNull();
174
+ await expect(loadOpenRouterStatusBar(now)).resolves.toEqual({ kind: 'empty' });
175
+ });
176
+
177
+ it('returns an empty result when the 30-day window has only zero-cost local rows', async () => {
178
+ await writeDailyFile('2026-05-12', [
179
+ createLocalUsageEvent('older-zero', '2026-05-12T12:00:00.000Z', 0),
180
+ ]);
181
+ await writeDailyFile('2026-05-22', [
182
+ createLocalUsageEvent('today-zero', '2026-05-22T12:00:00.000Z', 0),
183
+ ]);
184
+
185
+ const now = new Date('2026-05-22T12:00:00.000Z');
186
+
187
+ await expect(loadOpenRouterStatusStats(now)).resolves.toBeNull();
188
+ await expect(loadOpenRouterStatusBar(now)).resolves.toEqual({ kind: 'empty' });
189
+ });
190
+
191
+ it('requests local usage only from today-29 through today', async () => {
192
+ vi.resetModules();
193
+ const actualLocalUsage =
194
+ await vi.importActual<typeof import('../local-usage.js')>('../local-usage.js');
195
+ const readLocalUsage = vi
196
+ .fn()
197
+ .mockResolvedValue([
198
+ createLocalUsageEvent('boundary', '2026-04-23T12:00:00.000Z', 1),
199
+ createLocalUsageEvent('today', '2026-05-22T12:00:00.000Z', 2),
200
+ ]);
201
+
202
+ vi.doMock('../local-usage.js', () => ({
203
+ ...actualLocalUsage,
204
+ readLocalUsage,
205
+ }));
206
+
207
+ const { loadOpenRouterStatusStats: loadMockedStats } = await import('../status-bar.js');
208
+
209
+ await expect(loadMockedStats(new Date('2026-05-22T12:00:00.000Z'))).resolves.toEqual({
210
+ todayLocalSpend: 2,
211
+ averageLocalDailySpendLast30Days: 0.1,
212
+ burnRateMultiplier: 20,
213
+ });
214
+ expect(readLocalUsage).toHaveBeenCalledTimes(1);
215
+ expect(readLocalUsage).toHaveBeenCalledWith({
216
+ fromDateUtc: '2026-04-23',
217
+ toDateUtc: '2026-05-22',
218
+ });
219
+ });
220
+
221
+ it('tolerates missing files and malformed rows while returning a ready status', async () => {
222
+ await writeDailyFile('2026-05-10', [
223
+ createLocalUsageEvent('older', '2026-05-10T12:00:00.000Z', 4),
224
+ '{not json}',
225
+ ]);
226
+ await writeDailyFile('2026-05-22', [
227
+ createLocalUsageEvent('today', '2026-05-22T12:00:00.000Z', 2),
228
+ '',
229
+ ]);
230
+
231
+ const now = new Date('2026-05-22T12:00:00.000Z');
232
+
233
+ await expect(loadOpenRouterStatusStats(now)).resolves.toEqual({
234
+ todayLocalSpend: 2,
235
+ averageLocalDailySpendLast30Days: 0.2,
236
+ burnRateMultiplier: 10,
237
+ });
238
+ await expect(loadOpenRouterStatusBar(now)).resolves.toEqual({
239
+ kind: 'ready',
240
+ text: 'OR $2.00 today · 10.0x 30d avg',
241
+ });
242
+ });
243
+
244
+ it('returns a failed result when the local usage read path throws unexpectedly', async () => {
245
+ vi.resetModules();
246
+
247
+ const readLocalUsage = vi.fn().mockRejectedValue(new Error('disk exploded'));
248
+ vi.doMock('../local-usage.js', async () => {
249
+ const actual = await vi.importActual<typeof import('../local-usage.js')>('../local-usage.js');
250
+ return {
251
+ ...actual,
252
+ readLocalUsage,
253
+ };
254
+ });
255
+
256
+ const { loadOpenRouterStatusBar: loadMockedBar } = await import('../status-bar.js');
257
+
258
+ await expect(loadMockedBar(new Date('2026-05-22T12:00:00.000Z'))).resolves.toEqual({
259
+ kind: 'failed',
260
+ });
261
+ });
262
+ });
@@ -3,6 +3,7 @@ import type { KeyInfo, KeyStatus } from './account-types.js';
3
3
 
4
4
  // Re-export error types from client.ts
5
5
  import { AuthError, ApiError } from './client.js';
6
+ import { normalizeSdkKeyMetadata } from './normalizers.js';
6
7
 
7
8
  let client: OpenRouter | null = null;
8
9
 
@@ -33,8 +34,6 @@ export async function getAccountCredits(): Promise<number | null> {
33
34
  // Key Management API
34
35
  // =============================================================================
35
36
 
36
- import type { GetCurrentKeyData, ListData } from '@openrouter/sdk/models/operations/index.js';
37
-
38
37
  // Workspace ID for the default workspace (empty string) - used when workspaceId is not specified
39
38
  const DEFAULT_WORKSPACE_ID = '';
40
39
 
@@ -61,7 +60,9 @@ export async function getAllKeys(): Promise<KeyInfo[] | null> {
61
60
  const response = await client.apiKeys.list({ workspaceId, includeDisabled: true });
62
61
  const rawKeys = response.data;
63
62
 
64
- const keys = rawKeys.map((raw) => rawToKeyInfo(raw, workspace.name));
63
+ const keys = rawKeys.map((raw) =>
64
+ keyMetadataToKeyInfo(normalizeSdkKeyMetadata(raw), workspace.name),
65
+ );
65
66
  allKeys.push(...keys);
66
67
  }
67
68
 
@@ -81,7 +82,7 @@ export async function getCurrentKey(): Promise<KeyInfo | null> {
81
82
  if (!client) return null;
82
83
  try {
83
84
  const response = await client.apiKeys.getCurrentKeyMetadata();
84
- return rawToKeyInfo(response.data, 'Current Workspace');
85
+ return keyMetadataToKeyInfo(normalizeSdkKeyMetadata(response.data), 'Current Workspace');
85
86
  } catch (err) {
86
87
  throw mapSdkError(err);
87
88
  }
@@ -91,60 +92,11 @@ export async function getCurrentKey(): Promise<KeyInfo | null> {
91
92
  // Helper Functions
92
93
  // =============================================================================
93
94
 
94
- function rawToKeyInfo(raw: GetCurrentKeyData | ListData, workspaceName: string): KeyInfo {
95
- const used = raw.usage ?? raw.usageMonthly ?? 0;
96
-
97
- // limit is number | null in both GetCurrentKeyData and ListData
98
- const limitValue = raw.limit;
99
-
100
- // remaining is number | null in both types
101
- const remainingValue = raw.limitRemaining;
102
-
103
- // Determine BYOK status
104
- let byok: 'incl' | 'excl' | '?' = '?';
105
- if (raw.includeByokInLimit === true) {
106
- byok = 'incl';
107
- } else if (raw.includeByokInLimit === false) {
108
- byok = 'excl';
109
- }
110
-
111
- // Determine reset cadence
112
- let resetCadence: 'monthly' | 'daily' | 'never' | 'partial' = 'partial';
113
- if (raw.limitReset) {
114
- const reset = raw.limitReset.toLowerCase();
115
- if (reset === 'monthly') {
116
- resetCadence = 'monthly';
117
- } else if (reset === 'daily') {
118
- resetCadence = 'daily';
119
- } else if (reset === 'never') {
120
- resetCadence = 'never';
121
- }
122
- }
123
-
124
- // Determine hash (ListData has hash, GetCurrentKeyData doesn't)
125
- const hash = 'hash' in raw ? (raw as ListData).hash : 'unknown';
126
-
127
- // Determine name (ListData has name, GetCurrentKeyData doesn't - use label as fallback)
128
- const name = 'name' in raw ? (raw as ListData).name : raw.label;
129
-
130
- // Get disabled status (ListData has it, GetCurrentKeyData doesn't)
131
- const disabled = 'disabled' in raw ? (raw as ListData).disabled : false;
132
-
133
- // For exactOptionalPropertyTypes, we need to handle optional properties carefully
134
- // The SDK returns number | null but KeyInfo expects number | undefined (or just number)
135
- // We use type assertion to tell TypeScript that limit/remaining are either number or not set
136
-
137
- // When limitValue is null, we set limit to undefined (or omit it)
138
- // When limitValue is a number, we keep it as is
139
- let limit: number | undefined;
140
- if (limitValue !== null) {
141
- limit = limitValue;
142
- }
143
-
144
- let remaining: number | undefined;
145
- if (remainingValue !== null) {
146
- remaining = remainingValue;
147
- }
95
+ function keyMetadataToKeyInfo(
96
+ metadata: ReturnType<typeof normalizeSdkKeyMetadata>,
97
+ workspaceName: string,
98
+ ): KeyInfo {
99
+ const { name, label, used, limit, remaining, resetCadence, byok, hash, disabled } = metadata;
148
100
 
149
101
  // Calculate status based on usage percentage
150
102
  let status: KeyStatus;
@@ -167,10 +119,9 @@ function rawToKeyInfo(raw: GetCurrentKeyData | ListData, workspaceName: string):
167
119
  }
168
120
  }
169
121
 
170
- // Create the object
171
122
  const keyInfo: KeyInfo = {
172
123
  name,
173
- label: raw.label,
124
+ label,
174
125
  status,
175
126
  used,
176
127
  spend: used, // spend is the same as usage (in USD)
@@ -181,7 +132,6 @@ function rawToKeyInfo(raw: GetCurrentKeyData | ListData, workspaceName: string):
181
132
  workspaceName,
182
133
  };
183
134
 
184
- // Set optional properties explicitly
185
135
  if (limit !== undefined) {
186
136
  keyInfo.limit = limit;
187
137
  }