@robhowley/pi-openrouter 0.7.0 → 0.8.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.
@@ -1,6 +1,18 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { isOpenRouterRequest, formatSessionId } from '../session.js';
3
3
 
4
+ // =============================================================================
5
+ // Parameterized Test Types
6
+ // =============================================================================
7
+
8
+ interface DetectionTestCase {
9
+ name: string;
10
+ event: Record<string, unknown>;
11
+ ctx?: Record<string, unknown>;
12
+ expected: boolean;
13
+ description: string;
14
+ }
15
+
4
16
  // =============================================================================
5
17
  // Session ID Formatting Tests
6
18
  // =============================================================================
@@ -19,83 +31,311 @@ describe('formatSessionId', () => {
19
31
  // Request Detection Tests
20
32
  // =============================================================================
21
33
 
22
- // Helper to create mock event
23
- function createEvent(
24
- payload: Record<string, unknown>,
25
- url?: string,
26
- provider?: Record<string, unknown>,
27
- ) {
28
- const event: any = { payload };
29
- if (url) event.url = url;
30
- if (provider) event.provider = provider;
31
- return event;
32
- }
34
+ describe('isOpenRouterRequest', () => {
35
+ // =============================================================================
36
+ // Parameterized Tests - All Detection Methods (single source of truth)
37
+ // =============================================================================
33
38
 
34
- // Helper to create mock context
35
- function createContext(model: string | Record<string, unknown>) {
36
- return { model } as any;
37
- }
39
+ const detectionCases: DetectionTestCase[] = [
40
+ // Method 1: Model string prefix
41
+ {
42
+ name: 'method1: openrouter/ prefix',
43
+ event: { payload: { model: 'openrouter/anthropic/claude-3' } },
44
+ ctx: {},
45
+ expected: true,
46
+ description: 'Model with openrouter/ prefix should be detected',
47
+ },
48
+ {
49
+ name: 'method1: no prefix - should fail',
50
+ event: { payload: { model: 'anthropic/claude-3' } },
51
+ ctx: {},
52
+ expected: false,
53
+ description: 'Model without openrouter/ prefix should not be detected by method 1',
54
+ },
55
+ {
56
+ name: 'method1: similar but not prefix',
57
+ event: { payload: { model: 'my-openrouter-model' } },
58
+ ctx: {},
59
+ expected: false,
60
+ description: 'Model containing openrouter but not as prefix should not match',
61
+ },
38
62
 
39
- describe('isOpenRouterRequest', () => {
40
- // Method 1: Check model string (e.g., "openrouter/anthropic/claude-3.5-sonnet")
41
- it('detects OpenRouter by model prefix', () => {
42
- const event = createEvent({ model: 'openrouter/anthropic/claude-sonnet-4' });
43
- expect(isOpenRouterRequest(event, {})).toBe(true);
44
- });
63
+ // Method 2: baseUrl in context.model
64
+ {
65
+ name: 'method2: baseUrl contains openrouter.ai',
66
+ event: { payload: { model: 'qwen/coder' } },
67
+ ctx: { model: { baseUrl: 'https://openrouter.ai/api/v1' } },
68
+ expected: true,
69
+ description: 'Context with openrouter.ai baseUrl should be detected',
70
+ },
71
+ {
72
+ name: 'method2: different baseUrl',
73
+ event: { payload: { model: 'claude-3' } },
74
+ ctx: { model: { baseUrl: 'https://api.anthropic.com' } },
75
+ expected: false,
76
+ description: 'Non-OpenRouter baseUrl should not be detected',
77
+ },
78
+ {
79
+ name: 'method2: missing baseUrl',
80
+ event: { payload: { model: 'claude-3' } },
81
+ ctx: { model: {} },
82
+ expected: false,
83
+ description: 'Missing baseUrl should not be detected by method 2',
84
+ },
85
+ {
86
+ name: 'method2: no model in context',
87
+ event: { payload: { model: 'claude-3' } },
88
+ ctx: {},
89
+ expected: false,
90
+ description: 'Empty context should not crash method 2',
91
+ },
45
92
 
46
- it('does not detect non-OpenRouter by model prefix', () => {
47
- const event = createEvent({ model: 'anthropic/claude-sonnet-4' });
48
- expect(isOpenRouterRequest(event, {})).toBe(false);
49
- });
93
+ // Method 3: ZDR provider
94
+ {
95
+ name: 'method3: ZDR provider flag',
96
+ event: { payload: { model: 'qwen/coder' }, provider: { zdr: true } },
97
+ ctx: {},
98
+ expected: true,
99
+ description: 'Provider with zdr: true should be detected',
100
+ },
101
+ {
102
+ name: 'method3: non-ZDR provider',
103
+ event: { payload: { model: 'qwen/coder' }, provider: { zdr: false } },
104
+ ctx: {},
105
+ expected: false,
106
+ description: 'Provider with zdr: false should not be detected',
107
+ },
108
+ {
109
+ name: 'method3: no provider object',
110
+ event: { payload: { model: 'qwen/coder' } },
111
+ ctx: {},
112
+ expected: false,
113
+ description: 'Missing provider should not be detected by method 3',
114
+ },
50
115
 
51
- // Method 2: Check baseUrl from context.model
52
- it('detects OpenRouter by baseUrl', () => {
53
- const event = createEvent({ model: 'qwen/qwen3-coder-next' });
54
- const ctx = createContext({ baseUrl: 'https://openrouter.ai/api/v1' });
55
- expect(isOpenRouterRequest(event, ctx)).toBe(true);
56
- });
116
+ // Method 4: URL check
117
+ {
118
+ name: 'method4: url contains openrouter.ai',
119
+ event: { payload: { model: 'qwen/coder' }, url: 'https://openrouter.ai/api/v1/chat' },
120
+ ctx: {},
121
+ expected: true,
122
+ description: 'URL containing openrouter.ai should be detected',
123
+ },
124
+ {
125
+ name: 'method4: endpoint property (alternative to url)',
126
+ event: { payload: { model: 'qwen/coder' }, endpoint: 'https://openrouter.ai/api/v1/chat' },
127
+ ctx: {},
128
+ expected: true,
129
+ description: 'Endpoint property should also be checked (fallback to url)',
130
+ },
131
+ {
132
+ name: 'method4: non-OpenRouter url',
133
+ event: { payload: { model: 'qwen/coder' }, url: 'https://api.anthropic.com/v1/messages' },
134
+ ctx: {},
135
+ expected: false,
136
+ description: 'Non-OpenRouter URL should not be detected',
137
+ },
138
+ {
139
+ name: 'method4: url with openrouter.ai in path (not just domain)',
140
+ event: {
141
+ payload: { model: 'qwen/coder' },
142
+ url: 'https://proxy.example.com/v1/openrouter.ai/endpoint',
143
+ },
144
+ ctx: {},
145
+ expected: true,
146
+ description: 'URL containing openrouter.ai anywhere in string should match',
147
+ },
148
+ {
149
+ name: 'method4: url without openrouter.ai string',
150
+ event: { payload: { model: 'qwen/coder' }, url: 'https://example.com/api' },
151
+ ctx: {},
152
+ expected: false,
153
+ description: 'URL without openrouter.ai should not be detected',
154
+ },
57
155
 
58
- it('does not detect non-OpenRouter by baseUrl', () => {
59
- const event = createEvent({ model: 'qwen/qwen3-coder-next' });
60
- const ctx = createContext({ baseUrl: 'https://api.anthropic.com' });
61
- expect(isOpenRouterRequest(event, ctx)).toBe(false);
62
- });
156
+ // Method 5: Provider name check (Pi coding agent uses "openrouter" provider)
157
+ {
158
+ name: 'method5: provider as string "openrouter" at event level',
159
+ event: { payload: { model: 'claude-3' }, provider: 'openrouter' },
160
+ ctx: {},
161
+ expected: true,
162
+ description: 'Provider name "openrouter" as string should be detected',
163
+ },
164
+ {
165
+ name: 'method5: provider as string "openrouter" in payload',
166
+ event: { payload: { model: 'claude-3', provider: 'openrouter' } },
167
+ ctx: {},
168
+ expected: true,
169
+ description: 'Provider name "openrouter" in payload should be detected',
170
+ },
171
+ {
172
+ name: 'method5: provider object with name "openrouter"',
173
+ event: { payload: { model: 'claude-3' }, provider: { name: 'openrouter' } },
174
+ ctx: {},
175
+ expected: true,
176
+ description: 'Provider object with name "openrouter" should be detected',
177
+ },
178
+ {
179
+ name: 'method5: provider in payload with object name',
180
+ event: { payload: { model: 'claude-3', provider: { name: 'openrouter' } } },
181
+ ctx: {},
182
+ expected: true,
183
+ description: 'Provider object in payload with name "openrouter" should be detected',
184
+ },
185
+ {
186
+ name: 'method5: different provider name',
187
+ event: { payload: { model: 'claude-3', provider: 'anthropic' } },
188
+ ctx: {},
189
+ expected: false,
190
+ description: 'Different provider name should not be detected',
191
+ },
192
+ {
193
+ name: 'method5: similar but not exact provider name',
194
+ event: { payload: { model: 'claude-3', provider: 'openrouter-proxy' } },
195
+ ctx: {},
196
+ expected: false,
197
+ description: 'Provider name containing but not exactly "openrouter" should not match',
198
+ },
199
+ {
200
+ name: 'method4: url with openrouter.ai in path (not just domain)',
201
+ event: {
202
+ payload: { model: 'qwen/coder' },
203
+ url: 'https://proxy.example.com/v1/openrouter.ai/endpoint',
204
+ },
205
+ ctx: {},
206
+ expected: true,
207
+ description: 'URL containing openrouter.ai anywhere in string should match',
208
+ },
209
+ {
210
+ name: 'method4: url without openrouter.ai string',
211
+ event: { payload: { model: 'qwen/coder' }, url: 'https://example.com/api' },
212
+ ctx: {},
213
+ expected: false,
214
+ description: 'URL without openrouter.ai should not be detected',
215
+ },
63
216
 
64
- // Method 3: Check for ZDR provider (Shopify routes to OpenRouter via ZDR)
65
- it('detects OpenRouter by ZDR provider', () => {
66
- const event = createEvent({ model: 'qwen/qwen3-coder-next' }, undefined, { zdr: true });
67
- expect(isOpenRouterRequest(event, {})).toBe(true);
68
- });
217
+ // Edge cases - missing all detection methods
218
+ {
219
+ name: 'edge: empty event',
220
+ event: {},
221
+ ctx: {},
222
+ expected: false,
223
+ description: 'Empty event should not be detected',
224
+ },
225
+ {
226
+ name: 'edge: payload only with no model',
227
+ event: { payload: { messages: [] } },
228
+ ctx: {},
229
+ expected: false,
230
+ description: 'Event with payload but no model should not be detected',
231
+ },
232
+ {
233
+ name: 'edge: null model',
234
+ event: { payload: { model: null } },
235
+ ctx: {},
236
+ expected: false,
237
+ description: 'Null model should be handled as string "null" and not match',
238
+ },
239
+ {
240
+ name: 'edge: undefined model',
241
+ event: { payload: {} },
242
+ ctx: {},
243
+ expected: false,
244
+ description: 'Undefined model should not crash and not be detected',
245
+ },
69
246
 
70
- it('does not detect non-ZDR provider', () => {
71
- const event = createEvent({ model: 'qwen/qwen3-coder-next', provider: 'openrouter' });
72
- expect(isOpenRouterRequest(event, {})).toBe(false);
73
- });
247
+ // turn_end specific - simulating real turn_end event structure
248
+ {
249
+ name: 'turn_end style: url at event level with resolved model',
250
+ event: {
251
+ type: 'turn_end',
252
+ payload: { model: 'qwen/qwen3-coder-next', responseId: 'gen-123' },
253
+ url: 'https://openrouter.ai/api/v1/chat/completions',
254
+ message: { model: 'qwen/qwen3-coder-next', usage: {} },
255
+ },
256
+ ctx: {},
257
+ expected: true,
258
+ description: 'turn_end event structure with URL should be detected',
259
+ },
260
+ {
261
+ name: 'turn_end style: no url but endpoint',
262
+ event: {
263
+ type: 'turn_end',
264
+ payload: { model: 'moonshotai/kimi-k2.5' },
265
+ endpoint: 'https://openrouter.ai/api/v1/chat/completions',
266
+ },
267
+ ctx: {},
268
+ expected: true,
269
+ description: 'turn_end with endpoint instead of url should be detected',
270
+ },
271
+ {
272
+ name: 'turn_end style: no url or endpoint (would fail)',
273
+ event: {
274
+ type: 'turn_end',
275
+ payload: { model: 'moonshotai/kimi-k2.5' },
276
+ },
277
+ ctx: {},
278
+ expected: false,
279
+ description: 'turn_end without URL/endpoint and without openrouter/ prefix would fail',
280
+ },
74
281
 
75
- // Method 4: Check URL
76
- it('detects OpenRouter by URL', () => {
77
- const event = createEvent(
78
- { model: 'anthropic/claude-sonnet_4', messages: [] },
79
- 'https://openrouter.ai/api/v1/chat/completions',
80
- );
81
- expect(isOpenRouterRequest(event, {})).toBe(true);
82
- });
282
+ // Multiple methods at once
283
+ {
284
+ name: 'multi: all methods satisfied',
285
+ event: {
286
+ payload: { model: 'openrouter/anthropic/claude-3' },
287
+ url: 'https://openrouter.ai/api/v1/chat',
288
+ provider: { zdr: true },
289
+ },
290
+ ctx: { model: { baseUrl: 'https://openrouter.ai/api/v1' } },
291
+ expected: true,
292
+ description: 'All detection methods satisfied should return true',
293
+ },
294
+ {
295
+ name: 'multi: only method 4 (url) satisfied',
296
+ event: {
297
+ payload: { model: 'any-model-name' },
298
+ url: 'https://openrouter.ai/api/v1',
299
+ },
300
+ ctx: {},
301
+ expected: true,
302
+ description: 'Only URL method satisfied should be sufficient',
303
+ },
304
+ {
305
+ name: 'multi: only method 2 (baseUrl) satisfied',
306
+ event: { payload: { model: 'any-model' } },
307
+ ctx: { model: { baseUrl: 'https://openrouter.ai/api/v1' } },
308
+ expected: true,
309
+ description: 'Only baseUrl method satisfied should be sufficient',
310
+ },
311
+ {
312
+ name: 'multi: only method 3 (zdr) satisfied',
313
+ event: {
314
+ payload: { model: 'any-model' },
315
+ provider: { zdr: true },
316
+ },
317
+ ctx: {},
318
+ expected: true,
319
+ description: 'Only ZDR method satisfied should be sufficient',
320
+ },
83
321
 
84
- it('does not detect non-OpenRouter by URL', () => {
85
- const event = createEvent(
86
- { model: 'anthropic/claude-sonnet_4', messages: [] },
87
- 'https://api.anthropic.com/v1/messages',
88
- );
89
- expect(isOpenRouterRequest(event, {})).toBe(false);
90
- });
322
+ // Cache mismatch - model appears openrouter but URL doesn't (edge case)
323
+ {
324
+ name: 'edge: model says openrouter but URL says different',
325
+ event: {
326
+ payload: { model: 'openrouter/anthropic/claude-3' },
327
+ url: 'https://api.anthropic.com/v1/messages',
328
+ },
329
+ expected: true,
330
+ description: 'Model prefix takes precedence - should still detect as OpenRouter',
331
+ },
332
+ ];
91
333
 
92
- // Combined methods
93
- it('detects by multiple methods simultaneously', () => {
94
- const event = createEvent(
95
- { model: 'openrouter/anthropic/claude-sonnet-4' },
96
- 'https://openrouter.ai/api/v1/chat/completions',
97
- );
98
- const ctx = createContext({ baseUrl: 'https://openrouter.ai/api/v1' });
99
- expect(isOpenRouterRequest(event, ctx)).toBe(true);
100
- });
334
+ // Run all parameterized tests
335
+ for (const testCase of detectionCases) {
336
+ it(testCase.name, () => {
337
+ const result = isOpenRouterRequest(testCase.event as any, testCase.ctx as any);
338
+ expect(result).toBe(testCase.expected);
339
+ });
340
+ }
101
341
  });
@@ -1,6 +1,8 @@
1
1
  import type { ActivityResponse } from '@openrouter/sdk/models/index.js';
2
2
  import type { GetCreditsResponse } from '@openrouter/sdk/models/operations/index.js';
3
3
  import { OpenRouter } from '@openrouter/sdk/sdk/sdk.js';
4
+ import type { ModelsListResponse } from '@openrouter/sdk/models/index.js';
5
+ import { UnauthorizedResponseError } from '@openrouter/sdk/models/errors/index.js';
4
6
 
5
7
  let client: OpenRouter | null = null;
6
8
 
@@ -34,23 +36,76 @@ export async function getActivity(): Promise<ActivityResponse['data'] | null> {
34
36
  }
35
37
  }
36
38
 
37
- interface SDKError {
38
- status?: number;
39
- message?: string;
39
+ /**
40
+ * Fetch the authenticated user's model catalog from OpenRouter.
41
+ * Uses the SDK for consistent error handling and retry behavior.
42
+ */
43
+ export async function fetchUserModels(): Promise<ModelsListResponse> {
44
+ const key = getApiKey();
45
+ if (!key) {
46
+ throw new AuthError('OPENROUTER_API_KEY not set');
47
+ }
48
+
49
+ try {
50
+ const sdkClient = new OpenRouter({ apiKey: key });
51
+ const response = await sdkClient.models.listForUser({ bearer: key }, {});
52
+ return response as ModelsListResponse;
53
+ } catch (err: unknown) {
54
+ throw mapSdkError(err);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Check if the OpenRouter API key is configured.
60
+ */
61
+ export function isConfigured(): boolean {
62
+ return !!getApiKey();
40
63
  }
41
64
 
42
- function isSDKError(err: unknown): err is SDKError {
43
- return err !== null && typeof err === 'object' && 'status' in err;
65
+ /**
66
+ * Get the OpenRouter API key from environment.
67
+ */
68
+ export function getApiKey(): string | undefined {
69
+ return process.env['OPENROUTER_API_KEY'];
44
70
  }
45
71
 
72
+ /**
73
+ * Map SDK errors to our error types with proper status codes.
74
+ */
46
75
  function mapSdkError(err: unknown): Error {
47
- if (isSDKError(err)) {
48
- const status = err.status;
49
- const message = err.message ?? 'Unknown error';
50
- if (status === 401) return new AuthError(message);
51
- return new ApiError(`${status}: ${message}`);
76
+ // Handle UnauthorizedResponseError (401)
77
+ if (err instanceof UnauthorizedResponseError) {
78
+ return new ApiError('Unauthorized: Invalid or expired API key', 401);
52
79
  }
53
- if (err instanceof Error) return err;
80
+
81
+ // Handle other SDK errors with statusCode
82
+ if (err instanceof Error && 'statusCode' in err) {
83
+ const statusCode = (err as { statusCode: number }).statusCode;
84
+ if (statusCode === 401) {
85
+ return new AuthError(err.message || 'Unauthorized');
86
+ }
87
+ return new ApiError(err.message || 'API error', statusCode);
88
+ }
89
+
90
+ // Map error messages to appropriate status codes
91
+ if (err instanceof Error) {
92
+ const message = err.message.toLowerCase();
93
+ if (message.includes('unauthorized')) {
94
+ return new ApiError('Unauthorized: Invalid or expired API key', 401);
95
+ }
96
+ if (message.includes('rate limit') || message.includes('rate limited')) {
97
+ return new ApiError('Rate limited: Too many requests', 429);
98
+ }
99
+ if (
100
+ message.includes('server error') ||
101
+ message.includes('internal') ||
102
+ message.includes('service unavailable')
103
+ ) {
104
+ return new ApiError(err.message || 'Server error', 500);
105
+ }
106
+ return new ApiError(err.message || 'API error', 500);
107
+ }
108
+
54
109
  return new Error(String(err));
55
110
  }
56
111
 
@@ -62,7 +117,10 @@ export class AuthError extends Error {
62
117
  }
63
118
 
64
119
  export class ApiError extends Error {
65
- constructor(message: string) {
120
+ constructor(
121
+ message: string,
122
+ public readonly statusCode?: number,
123
+ ) {
66
124
  super(message);
67
125
  this.name = 'ApiError';
68
126
  }