@robhowley/pi-openrouter 0.9.0 → 0.9.1

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 (37) hide show
  1. package/README.md +39 -4
  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 +276 -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/account-client.ts +11 -61
  15. package/extensions/openrouter/cache.ts +203 -91
  16. package/extensions/openrouter/client.ts +49 -3
  17. package/extensions/openrouter/commands.ts +555 -0
  18. package/extensions/openrouter/format.ts +7 -4
  19. package/extensions/openrouter/hooks.ts +229 -0
  20. package/extensions/openrouter/index.ts +13 -990
  21. package/extensions/openrouter/local-usage.ts +145 -22
  22. package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
  23. package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
  24. package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
  25. package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
  26. package/extensions/openrouter/models/cache.ts +27 -2
  27. package/extensions/openrouter/models/mapper.ts +35 -69
  28. package/extensions/openrouter/models/override-commands.ts +434 -0
  29. package/extensions/openrouter/models/skip-hints.ts +19 -0
  30. package/extensions/openrouter/models/sync.ts +22 -10
  31. package/extensions/openrouter/models/types.ts +2 -1
  32. package/extensions/openrouter/normalizers.ts +128 -0
  33. package/extensions/openrouter/overlay.ts +19 -8
  34. package/extensions/openrouter/session-state.ts +110 -0
  35. package/extensions/openrouter/session.ts +16 -0
  36. package/extensions/openrouter/types.ts +28 -9
  37. package/package.json +1 -1
@@ -1,36 +1,38 @@
1
- import type { CacheEntry, UsageSummary, LocalUsageEvent, UsageAggregate } from './types.js';
1
+ import type { CacheEntry, UsageSummary, LocalUsageEvent } 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
5
  import { readLocalUsage, aggregateLocal, getCurrentUtcDate, addUtcDays } from './local-usage.js';
6
- import { ZERO_AGGREGATE } from './types.js';
6
+ import { combineUsageAggregates, createZeroAggregate } from './types.js';
7
7
 
8
8
  export const CACHE_TTL_MS = 45000;
9
9
  export const BACKGROUND_REFRESH_INTERVAL_MS = 30000;
10
10
 
11
+ interface CacheGetOptions {
12
+ allowStale?: boolean;
13
+ }
14
+
11
15
  export class TTLCache<T> {
12
16
  private cache = new Map<string, CacheEntry<T>>();
13
17
 
14
18
  constructor(private ttlMs: number = CACHE_TTL_MS) {}
15
19
 
16
- get(key: string): T | undefined {
20
+ get(key: string, options: CacheGetOptions = {}): T | undefined {
17
21
  const entry = this.cache.get(key);
18
22
  if (!entry) return undefined;
19
23
 
20
- if (Date.now() - entry.timestamp > this.ttlMs) {
21
- this.cache.delete(key);
24
+ if (!options.allowStale && Date.now() - entry.timestamp > this.ttlMs) {
22
25
  return undefined;
23
26
  }
24
27
 
25
28
  return entry.data;
26
29
  }
27
30
 
28
- getTimestamp(key: string): number | undefined {
31
+ getTimestamp(key: string, options: CacheGetOptions = {}): number | undefined {
29
32
  const entry = this.cache.get(key);
30
33
  if (!entry) return undefined;
31
34
 
32
- if (Date.now() - entry.timestamp > this.ttlMs) {
33
- this.cache.delete(key);
35
+ if (!options.allowStale && Date.now() - entry.timestamp > this.ttlMs) {
34
36
  return undefined;
35
37
  }
36
38
 
@@ -40,18 +42,175 @@ export class TTLCache<T> {
40
42
  set(key: string, data: T): void {
41
43
  this.cache.set(key, { data, timestamp: Date.now() });
42
44
  }
45
+
46
+ clear(key?: string): void {
47
+ if (key) {
48
+ this.cache.delete(key);
49
+ return;
50
+ }
51
+ this.cache.clear();
52
+ }
43
53
  }
44
54
 
45
55
  export const usageCache = new TTLCache<UsageSummary>(CACHE_TTL_MS);
46
56
 
47
- let refreshInterval: NodeJS.Timeout | null = null;
57
+ export type RefreshStatus = 'idle' | 'healthy' | 'refreshing' | 'stale' | 'failed';
58
+
59
+ export interface RefreshState {
60
+ status: RefreshStatus;
61
+ consecutiveFailures: number;
62
+ lastError: string | null;
63
+ lastSuccessAt: number | null;
64
+ nextDelayMs: number | null;
65
+ }
66
+
67
+ export interface StartBackgroundRefreshOptions {
68
+ onFailure?: (state: RefreshState) => void;
69
+ }
70
+
71
+ let refreshTimer: NodeJS.Timeout | null = null;
72
+ let refreshActive = false;
48
73
  let consecutiveFailures = 0;
74
+ let refreshFailureCallback: ((state: RefreshState) => void) | undefined;
75
+ let refreshState: RefreshState = {
76
+ status: 'idle',
77
+ consecutiveFailures: 0,
78
+ lastError: null,
79
+ lastSuccessAt: null,
80
+ nextDelayMs: null,
81
+ };
49
82
  const MAX_RETRY_BACKOFF = 5; // Max 2^5 = 32x base interval (16 min)
50
- const MAX_RETRY_COUNT = 4; // Stop after 4 consecutive failures
83
+ const RATE_LIMIT_BACKOFF_MULTIPLIER = 8; // 4 minutes with the default 30s interval
84
+
85
+ function getErrorMessage(error: unknown): string {
86
+ return error instanceof Error ? error.message : String(error);
87
+ }
51
88
 
52
- function getBackoffInterval(): number {
89
+ export function isRateLimitError(error: unknown): boolean {
90
+ const message = getErrorMessage(error).toLowerCase();
91
+ return (
92
+ message.includes('429') || message.includes('rate limit') || message.includes('rate-limit')
93
+ );
94
+ }
95
+
96
+ function getBackoffInterval(error?: unknown): number {
53
97
  const backoffMultiplier = Math.min(consecutiveFailures, MAX_RETRY_BACKOFF);
54
- return BACKGROUND_REFRESH_INTERVAL_MS * Math.pow(2, backoffMultiplier);
98
+ const exponentialDelay = BACKGROUND_REFRESH_INTERVAL_MS * Math.pow(2, backoffMultiplier);
99
+ const cappedDelay = Math.min(
100
+ exponentialDelay,
101
+ BACKGROUND_REFRESH_INTERVAL_MS * Math.pow(2, MAX_RETRY_BACKOFF),
102
+ );
103
+
104
+ if (!isRateLimitError(error)) {
105
+ return cappedDelay;
106
+ }
107
+
108
+ const rateLimitDelay = BACKGROUND_REFRESH_INTERVAL_MS * RATE_LIMIT_BACKOFF_MULTIPLIER;
109
+ return Math.min(
110
+ Math.max(cappedDelay, rateLimitDelay),
111
+ BACKGROUND_REFRESH_INTERVAL_MS * Math.pow(2, MAX_RETRY_BACKOFF),
112
+ );
113
+ }
114
+
115
+ export function getRefreshState(): RefreshState {
116
+ return { ...refreshState };
117
+ }
118
+
119
+ function scheduleRefresh(delay: number): void {
120
+ if (!refreshActive) return;
121
+ refreshState = { ...refreshState, nextDelayMs: delay };
122
+
123
+ refreshTimer = setTimeout(async () => {
124
+ refreshTimer = null;
125
+ await runBackgroundRefreshOnce();
126
+ if (refreshActive) {
127
+ scheduleRefresh(refreshState.nextDelayMs ?? BACKGROUND_REFRESH_INTERVAL_MS);
128
+ }
129
+ }, delay);
130
+ }
131
+
132
+ async function runBackgroundRefreshOnce(): Promise<void> {
133
+ refreshState = { ...refreshState, status: 'refreshing' };
134
+
135
+ try {
136
+ const summary = await fetchAndAggregate();
137
+ if (!summary) {
138
+ throw new Error('OpenRouter usage unavailable: no configured API key or credits response.');
139
+ }
140
+
141
+ usageCache.set('usage', summary);
142
+ consecutiveFailures = 0;
143
+ refreshState = {
144
+ status: 'healthy',
145
+ consecutiveFailures,
146
+ lastError: null,
147
+ lastSuccessAt: Date.now(),
148
+ nextDelayMs: BACKGROUND_REFRESH_INTERVAL_MS,
149
+ };
150
+ } catch (error) {
151
+ consecutiveFailures++;
152
+ const hasStaleData = usageCache.get('usage', { allowStale: true }) !== undefined;
153
+ refreshState = {
154
+ status: hasStaleData ? 'stale' : 'failed',
155
+ consecutiveFailures,
156
+ lastError: getErrorMessage(error),
157
+ lastSuccessAt: refreshState.lastSuccessAt,
158
+ nextDelayMs: getBackoffInterval(error),
159
+ };
160
+ refreshFailureCallback?.(getRefreshState());
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Extract the latest date from Activity API data.
166
+ * Returns undefined if analytics is null, empty, or has no valid dates.
167
+ */
168
+ function getOfficialThroughDate(analytics: ActivityItem[] | null): string | undefined {
169
+ if (!analytics || analytics.length === 0) return undefined;
170
+ let maxDate = '';
171
+ // Match YYYY-MM-DD or YYYY-MM-DD HH:MM:SS
172
+ const dateRE = /^\d{4}-\d{2}-\d{2}/;
173
+ for (let i = 0; i < analytics.length; i++) {
174
+ const d = analytics[i];
175
+ if (d && d.date && dateRE.test(d.date)) {
176
+ const datePart = d.date.slice(0, 10); // Extract YYYY-MM-DD
177
+ if (datePart > maxDate) {
178
+ maxDate = datePart;
179
+ }
180
+ }
181
+ }
182
+ return maxDate || undefined;
183
+ }
184
+
185
+ /**
186
+ * Compute the date range for reading local JSONL usage.
187
+ * Returns { fromDateUtc, toDateUtc } for the bounded window.
188
+ *
189
+ * Product decision:
190
+ * - If officialThroughDate exists: read from day after through today,
191
+ * but cap to 30-day window (never read more than today - 29 through today)
192
+ * - If no official data: read from today - 29 days through today (30 days total)
193
+ */
194
+ function getLocalUsageReadRange(
195
+ officialThroughDate: string | undefined,
196
+ now: string,
197
+ ): { fromDateUtc: string; toDateUtc: string } {
198
+ const fallbackStart = addUtcDays(now, -29); // 30-day window start
199
+
200
+ if (officialThroughDate) {
201
+ const dayAfterOfficial = addUtcDays(officialThroughDate, 1);
202
+ // Cap to 30-day window: use the later of dayAfterOfficial or fallbackStart
203
+ return {
204
+ fromDateUtc: dayAfterOfficial > fallbackStart ? dayAfterOfficial : fallbackStart,
205
+ toDateUtc: now,
206
+ };
207
+ } else {
208
+ // No official data: read bounded 30-day window (today - 29 through today)
209
+ return {
210
+ fromDateUtc: fallbackStart,
211
+ toDateUtc: now,
212
+ };
213
+ }
55
214
  }
56
215
 
57
216
  export async function fetchAndAggregate(): Promise<UsageSummary | null> {
@@ -69,22 +228,7 @@ export async function fetchAndAggregate(): Promise<UsageSummary | null> {
69
228
  const timestamp = Date.now();
70
229
 
71
230
  // Get official aggregate from Activity API data
72
- const officialThroughDate = (function (): string | undefined {
73
- if (!analytics || analytics.length === 0) return undefined;
74
- let maxDate = '';
75
- // Match YYYY-MM-DD or YYYY-MM-DD HH:MM:SS
76
- const dateRE = /^\d{4}-\d{2}-\d{2}/;
77
- for (let i = 0; i < analytics.length; i++) {
78
- const d = analytics[i];
79
- if (d && d.date && dateRE.test(d.date)) {
80
- const datePart = d.date.slice(0, 10); // Extract YYYY-MM-DD
81
- if (datePart > maxDate) {
82
- maxDate = datePart;
83
- }
84
- }
85
- }
86
- return maxDate || undefined;
87
- })();
231
+ const officialThroughDate = getOfficialThroughDate(analytics);
88
232
 
89
233
  // Compute official aggregate (only from Activity API data up to officialThroughDate)
90
234
  const officialAggregate =
@@ -106,38 +250,25 @@ export async function fetchAndAggregate(): Promise<UsageSummary | null> {
106
250
  }) as LocalUsageEvent,
107
251
  ),
108
252
  )
109
- : ZERO_AGGREGATE;
253
+ : createZeroAggregate();
110
254
 
111
- // Read local JSONL after officialThroughDate
255
+ // Read local JSONL for bounded recent window
256
+ // Always compute local read range when credits exist (Activity API may be absent/empty)
257
+ const now = getCurrentUtcDate();
258
+ const localReadRange = getLocalUsageReadRange(officialThroughDate, now);
112
259
  const localEvents: LocalUsageEvent[] = [];
113
- if (officialThroughDate) {
114
- // Read from the day after officialThroughDate to today
115
- const localFrom = addUtcDays(officialThroughDate, 1);
116
- const localTo = getCurrentUtcDate();
117
- try {
118
- const localEventsList = await readLocalUsage({
119
- fromDateUtc: localFrom,
120
- toDateUtc: localTo,
121
- });
122
- localEvents.push(...localEventsList);
123
- } catch {
124
- // Fail open - if local read fails, continue with empty local
125
- }
260
+ try {
261
+ const localEventsList = await readLocalUsage(localReadRange);
262
+ localEvents.push(...localEventsList);
263
+ } catch {
264
+ // Fail open - if local read fails, continue with empty local
126
265
  }
127
266
 
128
267
  // Aggregate local events
129
268
  const localAggregate = aggregateLocal(localEvents);
130
269
 
131
270
  // Combine official + local
132
- const combinedAggregate: UsageAggregate = {
133
- requests: officialAggregate.requests + localAggregate.requests,
134
- promptTokens: officialAggregate.promptTokens + localAggregate.promptTokens,
135
- completionTokens: officialAggregate.completionTokens + localAggregate.completionTokens,
136
- reasoningTokens: officialAggregate.reasoningTokens + localAggregate.reasoningTokens,
137
- cacheReadTokens: officialAggregate.cacheReadTokens + localAggregate.cacheReadTokens,
138
- cacheWriteTokens: officialAggregate.cacheWriteTokens + localAggregate.cacheWriteTokens,
139
- cost: officialAggregate.cost + localAggregate.cost,
140
- };
271
+ const combinedAggregate = combineUsageAggregates(officialAggregate, localAggregate);
141
272
 
142
273
  // Build full summary with local events included for 7d/30d totals
143
274
  const summary = aggregateUsage(credits, analytics ?? [], timestamp, localEvents);
@@ -150,47 +281,28 @@ export async function fetchAndAggregate(): Promise<UsageSummary | null> {
150
281
  return summary;
151
282
  }
152
283
 
153
- function scheduleRefresh(): void {
154
- const delay = consecutiveFailures > 0 ? getBackoffInterval() : BACKGROUND_REFRESH_INTERVAL_MS;
155
-
156
- refreshInterval = setInterval(async () => {
157
- try {
158
- const summary = await fetchAndAggregate();
159
- if (summary) {
160
- usageCache.set('usage', summary);
161
-
162
- // Reset failure count on success and restart with normal interval
163
- if (consecutiveFailures > 0) {
164
- consecutiveFailures = 0;
165
- stopBackgroundRefresh();
166
- scheduleRefresh();
167
- }
168
- }
169
- } catch {
170
- consecutiveFailures++;
171
-
172
- // Stop after max retries reached
173
- if (consecutiveFailures >= MAX_RETRY_COUNT) {
174
- stopBackgroundRefresh();
175
- // TODO: Fire UI notification for persistent failure
176
- return;
177
- }
178
-
179
- // Restart with backoff interval
180
- stopBackgroundRefresh();
181
- scheduleRefresh();
182
- }
183
- }, delay);
184
- }
185
-
186
- export function startBackgroundRefresh(): void {
187
- if (refreshInterval) return;
188
- scheduleRefresh();
284
+ export function startBackgroundRefresh(options: StartBackgroundRefreshOptions = {}): void {
285
+ if (options.onFailure) {
286
+ refreshFailureCallback = options.onFailure;
287
+ }
288
+ if (refreshActive || refreshTimer) return;
289
+ refreshActive = true;
290
+ scheduleRefresh(BACKGROUND_REFRESH_INTERVAL_MS);
189
291
  }
190
292
 
191
293
  export function stopBackgroundRefresh(): void {
192
- if (refreshInterval) {
193
- clearInterval(refreshInterval);
194
- refreshInterval = null;
294
+ refreshActive = false;
295
+ refreshFailureCallback = undefined;
296
+ if (refreshTimer) {
297
+ clearTimeout(refreshTimer);
298
+ refreshTimer = null;
195
299
  }
300
+ consecutiveFailures = 0;
301
+ refreshState = {
302
+ status: 'idle',
303
+ consecutiveFailures,
304
+ lastError: null,
305
+ lastSuccessAt: null,
306
+ nextDelayMs: null,
307
+ };
196
308
  }
@@ -8,7 +8,7 @@ let client: OpenRouter | null = null;
8
8
 
9
9
  function getClient(): OpenRouter | null {
10
10
  if (client) return client;
11
- const apiKey = process.env['OPENROUTER_MANAGEMENT_KEY'] || process.env['OPENROUTER_API_KEY'];
11
+ const apiKey = getUsageApiKey();
12
12
  if (!apiKey) return null;
13
13
  client = new OpenRouter({ apiKey });
14
14
  return client;
@@ -41,9 +41,11 @@ export async function getActivity(): Promise<ActivityResponse['data'] | null> {
41
41
  * Uses the SDK for consistent error handling and retry behavior.
42
42
  */
43
43
  export async function fetchUserModels(): Promise<ModelsListResponse> {
44
- const key = getApiKey();
44
+ const key = getModelSyncApiKey();
45
45
  if (!key) {
46
- throw new AuthError('OPENROUTER_API_KEY not set');
46
+ throw new AuthError(
47
+ 'OpenRouter API key not configured. Set OPENROUTER_API_KEY or OPENROUTER_MANAGEMENT_KEY.',
48
+ );
47
49
  }
48
50
 
49
51
  try {
@@ -69,6 +71,50 @@ export function getApiKey(): string | undefined {
69
71
  return process.env['OPENROUTER_API_KEY'];
70
72
  }
71
73
 
74
+ /**
75
+ * Get the API key for usage/account endpoints.
76
+ * Prefers OPENROUTER_MANAGEMENT_KEY for full analytics access.
77
+ */
78
+ export function getUsageApiKey(): string | undefined {
79
+ const mgmtKey = process.env['OPENROUTER_MANAGEMENT_KEY'];
80
+ const apiKey = process.env['OPENROUTER_API_KEY'];
81
+ // Treat empty strings as absent
82
+ return mgmtKey && mgmtKey.trim() !== ''
83
+ ? mgmtKey
84
+ : apiKey && apiKey.trim() !== ''
85
+ ? apiKey
86
+ : undefined;
87
+ }
88
+
89
+ /**
90
+ * Get the API key for model sync endpoint.
91
+ * Prefers OPENROUTER_API_KEY but falls back to OPENROUTER_MANAGEMENT_KEY.
92
+ */
93
+ export function getModelSyncApiKey(): string | undefined {
94
+ const apiKey = process.env['OPENROUTER_API_KEY'];
95
+ const mgmtKey = process.env['OPENROUTER_MANAGEMENT_KEY'];
96
+ // Treat empty strings as absent
97
+ return apiKey && apiKey.trim() !== ''
98
+ ? apiKey
99
+ : mgmtKey && mgmtKey.trim() !== ''
100
+ ? mgmtKey
101
+ : undefined;
102
+ }
103
+
104
+ /**
105
+ * Check if model sync is configured.
106
+ */
107
+ export function isConfiguredForModelSync(): boolean {
108
+ return !!getModelSyncApiKey();
109
+ }
110
+
111
+ /**
112
+ * Check if usage/account endpoints are configured.
113
+ */
114
+ export function isConfiguredForUsage(): boolean {
115
+ return !!getUsageApiKey();
116
+ }
117
+
72
118
  /**
73
119
  * Map SDK errors to our error types with proper status codes.
74
120
  */