@robhowley/pi-openrouter 0.7.1 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-openrouter
2
2
 
3
- A [Pi](https://pi.dev/) extension for live OpenRouter visibility: TUI overlays for spend, credits, key limits, burn rate, and model usage, plus automatic `session_id` tagging for dashboard grouping.
3
+ A [Pi](https://pi.dev/) extension for live OpenRouter visibility and environment sync: usage/account TUI overlays, automatic `session_id` tagging, and user-scoped model catalog sync.
4
4
 
5
5
  ## Installation
6
6
 
@@ -13,13 +13,53 @@ pi install npm:@robhowley/pi-openrouter
13
13
  Set one of these environment variables:
14
14
 
15
15
  - `OPENROUTER_MANAGEMENT_KEY` (preferred), provides full usage data including model breakdowns
16
- - `OPENROUTER_API_KEY`, basic usage data only
16
+ - `OPENROUTER_API_KEY`, basic usage data plus user-scoped model sync
17
17
 
18
18
  ```shell
19
19
  export OPENROUTER_MANAGEMENT_KEY=sk-or-...
20
20
  ```
21
21
 
22
- ## Usage
22
+ ## Commands
23
+
24
+ ```bash
25
+ /openrouter usage # usage/spend overlay
26
+ /openrouter account # credits, key limits, account health
27
+ /openrouter session # current OpenRouter session_id
28
+ /openrouter models-sync # sync user-scoped OpenRouter models into Pi
29
+ /openrouter models-status # show model sync/cache status
30
+ /openrouter models-status --skipped # show skipped model reasons
31
+ ```
32
+
33
+ ## Model catalog sync
34
+
35
+ `pi-openrouter` can sync Pi’s OpenRouter model catalog from your user-scoped OpenRouter model list.
36
+
37
+ `/openrouter models-sync`
38
+
39
+ The sync uses OpenRouter’s authenticated user model catalog, so Pi can see the models available to your account instead of only the default provider list.
40
+
41
+ `/openrouter models-status`
42
+
43
+ Example status output:
44
+
45
+ ```text
46
+ OpenRouter models healthy
47
+ 363 registered · 2 skipped · cache age: 2m
48
+ ```
49
+
50
+ To see why models were skipped:
51
+
52
+ `/openrouter models-status --skipped`
53
+
54
+ Skipped models do not make the sync fail; models are skipped when required metadata cannot be safely mapped into Pi’s provider model config. The last successful catalog is cached so Pi can keep using it if a later refresh fails, and the cache persists across sessions. If a session starts with a cached catalog that has not been registered yet, status will show:
55
+
56
+ ```text
57
+ OpenRouter models cached
58
+ 368 models in cache · age: 4m
59
+ Run '/openrouter models-sync' to register models
60
+ ```
61
+
62
+ ## Usage overlay
23
63
 
24
64
  Type `/openrouter usage` in Pi to open the usage overlay.
25
65
 
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { fetchUserModels, isConfigured, getApiKey, ApiError, AuthError } from '../client.js';
3
+ import { restoreEnv } from './fixtures.js';
4
+ import type { Mock } from 'vitest';
5
+
6
+ // Mock SDK at the module level
7
+ vi.mock('@openrouter/sdk/sdk/sdk.js', () => ({
8
+ OpenRouter: vi.fn(),
9
+ }));
10
+
11
+ /**
12
+ * Factory function to create a minimal mock SDK client.
13
+ * Reduces boilerplate from ~25 lines to 1 line per test.
14
+ */
15
+ function createMockSDKClient(overrides: { listForUser?: Mock } = {}) {
16
+ return {
17
+ models: { listForUser: overrides.listForUser ?? vi.fn() },
18
+ credits: {},
19
+ analytics: {},
20
+ chat: {},
21
+ embeddings: {},
22
+ images: {},
23
+ fine_tuning: {},
24
+ batches: {},
25
+ files: {},
26
+ audio: {},
27
+ moderation: {},
28
+ beta: {},
29
+ webhooks: {},
30
+ fineTunes: {},
31
+ jobs: {},
32
+ uploads: {},
33
+ assistants: {},
34
+ threads: {},
35
+ runs: {},
36
+ messages: {},
37
+ vectorStores: {},
38
+ tools: {},
39
+ };
40
+ }
41
+
42
+ describe('fetchUserModels', () => {
43
+ beforeEach(() => {
44
+ vi.resetAllMocks();
45
+ restoreEnv();
46
+ });
47
+
48
+ it('should throw AuthError when API key not set', async () => {
49
+ delete process.env['OPENROUTER_API_KEY'];
50
+
51
+ await expect(fetchUserModels()).rejects.toThrow('OPENROUTER_API_KEY not set');
52
+ await expect(fetchUserModels()).rejects.toBeInstanceOf(AuthError);
53
+ });
54
+
55
+ it('should fetch models with SDK', async () => {
56
+ process.env['OPENROUTER_API_KEY'] = 'test-key';
57
+
58
+ const mockResponse = {
59
+ data: [
60
+ {
61
+ id: 'openai/gpt-4',
62
+ name: 'GPT-4',
63
+ context_length: 8192,
64
+ pricing: { prompt: '0.00003', completion: '0.00006' },
65
+ },
66
+ ],
67
+ };
68
+
69
+ // Mock SDK OpenRouter class
70
+ const MockOpenRouter = vi.mocked((await import('@openrouter/sdk/sdk/sdk.js')).OpenRouter);
71
+ const mockClient = createMockSDKClient({
72
+ listForUser: vi.fn().mockResolvedValue(mockResponse),
73
+ });
74
+ MockOpenRouter.mockImplementation(() => mockClient as any);
75
+
76
+ const result = await fetchUserModels();
77
+
78
+ expect(result!.data).toHaveLength(1);
79
+ expect(result!.data[0]!.id).toBe('openai/gpt-4');
80
+ expect(mockClient.models.listForUser).toHaveBeenCalled();
81
+ });
82
+
83
+ it('should throw ApiError on 401 unauthorized', async () => {
84
+ process.env['OPENROUTER_API_KEY'] = 'invalid-key';
85
+
86
+ const MockOpenRouter = vi.mocked((await import('@openrouter/sdk/sdk/sdk.js')).OpenRouter);
87
+ const mockClient = createMockSDKClient({
88
+ listForUser: vi.fn().mockRejectedValue(new Error('Unauthorized')),
89
+ });
90
+ MockOpenRouter.mockImplementation(() => mockClient as any);
91
+
92
+ const error = await fetchUserModels().catch((e) => e);
93
+ expect(error).toBeInstanceOf(ApiError);
94
+ expect(error.message).toContain('Unauthorized');
95
+ expect((error as ApiError).statusCode).toBe(401);
96
+ });
97
+
98
+ it('should throw ApiError on rate limit (429)', async () => {
99
+ process.env['OPENROUTER_API_KEY'] = 'test-key';
100
+
101
+ const MockOpenRouter = vi.mocked((await import('@openrouter/sdk/sdk/sdk.js')).OpenRouter);
102
+ const mockClient = createMockSDKClient({
103
+ listForUser: vi.fn().mockRejectedValue(new Error('Rate limited')),
104
+ });
105
+ MockOpenRouter.mockImplementation(() => mockClient as any);
106
+
107
+ const error = await fetchUserModels().catch((e) => e);
108
+ expect(error).toBeInstanceOf(ApiError);
109
+ expect(error.message).toContain('Rate limited');
110
+ expect((error as ApiError).statusCode).toBe(429);
111
+ });
112
+
113
+ it('should throw ApiError on server error', async () => {
114
+ process.env['OPENROUTER_API_KEY'] = 'test-key';
115
+
116
+ const MockOpenRouter = vi.mocked((await import('@openrouter/sdk/sdk/sdk.js')).OpenRouter);
117
+ const mockClient = createMockSDKClient({
118
+ listForUser: vi.fn().mockRejectedValue(new Error('Server error')),
119
+ });
120
+ MockOpenRouter.mockImplementation(() => mockClient as any);
121
+
122
+ const error = await fetchUserModels().catch((e) => e);
123
+ expect(error).toBeInstanceOf(ApiError);
124
+ expect(error.message).toContain('Server error');
125
+ expect((error as ApiError).statusCode).toBe(500);
126
+ });
127
+ });
128
+
129
+ describe('isConfigured', () => {
130
+ beforeEach(() => {
131
+ restoreEnv();
132
+ });
133
+
134
+ it('should return true when API key is set', () => {
135
+ process.env['OPENROUTER_API_KEY'] = 'test-key';
136
+ expect(isConfigured()).toBe(true);
137
+ });
138
+
139
+ it('should return false when API key is not set', () => {
140
+ delete process.env['OPENROUTER_API_KEY'];
141
+ expect(isConfigured()).toBe(false);
142
+ });
143
+
144
+ it('should return false when API key is empty string', () => {
145
+ process.env['OPENROUTER_API_KEY'] = '';
146
+ expect(isConfigured()).toBe(false);
147
+ });
148
+ });
149
+
150
+ describe('getApiKey', () => {
151
+ beforeEach(() => {
152
+ restoreEnv();
153
+ });
154
+
155
+ it('should return the API key when set', () => {
156
+ process.env['OPENROUTER_API_KEY'] = 'test-key';
157
+ expect(getApiKey()).toBe('test-key');
158
+ });
159
+
160
+ it('should return undefined when API key is not set', () => {
161
+ delete process.env['OPENROUTER_API_KEY'];
162
+ expect(getApiKey()).toBeUndefined();
163
+ });
164
+
165
+ it('should return empty string when API key is empty', () => {
166
+ process.env['OPENROUTER_API_KEY'] = '';
167
+ expect(getApiKey()).toBe('');
168
+ });
169
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Shared test fixtures for OpenRouter extension tests.
3
+ *
4
+ * Centralizes common test data shapes to reduce duplication across test files.
5
+ * Import: import { createValidModel, createActivityItem, ... } from './fixtures.js'
6
+ */
7
+
8
+ import type { OpenRouterModel, ModelsCache, PiModelConfig } from '../models/types.js';
9
+ import type { ActivityItem } from '@openrouter/sdk/models/index.js';
10
+
11
+ // =============================================================================
12
+ // Environment Helpers
13
+ // =============================================================================
14
+
15
+ /**
16
+ * Snapshot of process.env captured at module load time.
17
+ * Used as the baseline for restoreEnv() in tests.
18
+ */
19
+ const originalEnvSnapshot: Record<string, string | undefined> = { ...process.env };
20
+
21
+ /**
22
+ * Restore process.env to its original state.
23
+ * Call in beforeEach or afterEach to ensure clean environment between tests.
24
+ */
25
+ export function restoreEnv(): void {
26
+ process.env = { ...originalEnvSnapshot };
27
+ }
28
+
29
+ /**
30
+ * Clear a specific environment variable.
31
+ */
32
+ export function clearEnv(key: string): void {
33
+ delete process.env[key];
34
+ }
35
+
36
+ /**
37
+ * Set an environment variable for testing.
38
+ */
39
+ export function setEnv(key: string, value: string): void {
40
+ process.env[key] = value;
41
+ }
42
+
43
+ // =============================================================================
44
+ // Model Fixtures
45
+ // =============================================================================
46
+
47
+ /**
48
+ * Creates a valid OpenRouterModel with sensible defaults.
49
+ * Use overrides to customize specific properties for test cases.
50
+ */
51
+ export function createValidModel(overrides?: Partial<OpenRouterModel>): OpenRouterModel {
52
+ return {
53
+ id: 'test/model',
54
+ name: 'Test Model',
55
+ context_length: 128000,
56
+ pricing: {
57
+ prompt: '0.0000005',
58
+ completion: '0.0000015',
59
+ },
60
+ ...overrides,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Creates a valid ModelsCache with a single model.
66
+ */
67
+ export function createMockCache(overrides: Partial<ModelsCache> = {}): ModelsCache {
68
+ return {
69
+ models: [createValidModel()],
70
+ timestamp: Date.now() - 1000, // 1 second ago
71
+ ...overrides,
72
+ };
73
+ }
74
+
75
+ // =============================================================================
76
+ // Activity/Analytics Fixtures
77
+ // =============================================================================
78
+
79
+ /**
80
+ * Creates a UTC date string (YYYY-MM-DD) relative to now.
81
+ * @param daysAgo - Days to subtract from current UTC date (0 for today)
82
+ * @returns Date string in YYYY-MM-DD format using UTC
83
+ */
84
+ export function createTestDate(daysAgo: number): string {
85
+ const now = new Date();
86
+ const targetDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000);
87
+ return `${targetDate.getUTCFullYear()}-${String(targetDate.getUTCMonth() + 1).padStart(2, '0')}-${String(targetDate.getUTCDate()).padStart(2, '0')}`;
88
+ }
89
+
90
+ /**
91
+ * Creates an ActivityItem with sensible defaults.
92
+ * Use overrides to customize specific properties for test cases.
93
+ */
94
+ export function createActivityItem(overrides?: Partial<ActivityItem>): ActivityItem {
95
+ return {
96
+ date: createTestDate(0),
97
+ model: 'gpt-4',
98
+ modelPermaslug: 'gpt-4-perma',
99
+ endpointId: 'ep-1',
100
+ usage: 5.0,
101
+ byokUsageInference: 0,
102
+ requests: 10,
103
+ promptTokens: 1000,
104
+ completionTokens: 100,
105
+ reasoningTokens: 0,
106
+ providerName: 'openai',
107
+ ...overrides,
108
+ };
109
+ }
110
+
111
+ // =============================================================================
112
+ // PiModelConfig Fixtures
113
+ // =============================================================================
114
+
115
+ /**
116
+ * Creates a valid PiModelConfig with sensible defaults.
117
+ */
118
+ export function createPiModelConfig(overrides?: Partial<PiModelConfig>): PiModelConfig {
119
+ return {
120
+ id: 'test/model',
121
+ name: 'Test Model',
122
+ reasoning: false,
123
+ input: ['text'],
124
+ cost: {
125
+ input: 0.5,
126
+ output: 1.5,
127
+ cacheRead: 0,
128
+ cacheWrite: 0,
129
+ },
130
+ contextWindow: 128000,
131
+ maxTokens: 4096,
132
+ ...overrides,
133
+ };
134
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { aggregateUsage } from '../format.js';
3
3
  import { renderSpendSparkline } from '../chart.js';
4
+ import { createTestDate, createActivityItem } from './fixtures.js';
4
5
  import type { ActivityItem } from '@openrouter/sdk/models/index.js';
5
6
 
6
7
  describe('aggregateUsage', () => {
@@ -13,24 +14,9 @@ describe('aggregateUsage', () => {
13
14
  // Get today's date in YYYY-MM-DD format using UTC date
14
15
  // This matches how the API returns dates (YYYY-MM-DD without timezone)
15
16
  // and how the implementation calculates 'today' (using UTC)
16
- const now = new Date();
17
- const todayStr = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-${String(now.getUTCDate()).padStart(2, '0')}`;
17
+ const todayStr = createTestDate(0);
18
18
 
19
- const analytics: ActivityItem[] = [
20
- {
21
- date: todayStr,
22
- model: 'gpt-4',
23
- modelPermaslug: 'gpt-4-perma',
24
- endpointId: 'ep-1',
25
- usage: 6.55,
26
- byokUsageInference: 0,
27
- requests: 10,
28
- promptTokens: 1000,
29
- completionTokens: 100,
30
- reasoningTokens: 0,
31
- providerName: 'openai',
32
- },
33
- ];
19
+ const analytics: ActivityItem[] = [createActivityItem({ date: todayStr, usage: 6.55 })];
34
20
 
35
21
  const result = aggregateUsage(credits, analytics);
36
22
 
@@ -43,37 +29,27 @@ describe('aggregateUsage', () => {
43
29
  totalCredits: 100,
44
30
  };
45
31
  // Use a date that's within the last 7 days of when the test runs.
46
- // Get today's date in UTC and subtract a few days to ensure it's in the week window.
47
- const now = new Date();
48
- const testDate = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); // 3 days ago
49
- const date = `${testDate.getUTCFullYear()}-${String(testDate.getUTCMonth() + 1).padStart(2, '0')}-${String(testDate.getUTCDate()).padStart(2, '0')}`;
32
+ const date = createTestDate(3); // 3 days ago
50
33
  const analytics: ActivityItem[] = [
51
- {
52
- date: date,
34
+ createActivityItem({
35
+ date,
53
36
  model: 'model-1',
54
37
  modelPermaslug: 'model-1-perma',
55
38
  endpointId: 'ep-1',
56
39
  usage: 5.42,
57
- byokUsageInference: 0,
58
- requests: 10,
59
- promptTokens: 1000,
60
- completionTokens: 100,
61
- reasoningTokens: 0,
62
40
  providerName: 'provider-1',
63
- },
64
- {
65
- date: date,
41
+ }),
42
+ createActivityItem({
43
+ date,
66
44
  model: 'model-2',
67
45
  modelPermaslug: 'model-2-perma',
68
46
  endpointId: 'ep-2',
69
47
  usage: 3.11,
70
- byokUsageInference: 0,
71
48
  requests: 5,
72
49
  promptTokens: 500,
73
50
  completionTokens: 50,
74
- reasoningTokens: 0,
75
51
  providerName: 'provider-2',
76
- },
52
+ }),
77
53
  ];
78
54
 
79
55
  const result = aggregateUsage(credits, analytics);
@@ -119,35 +95,30 @@ describe('aggregateUsage', () => {
119
95
  totalUsage: 10,
120
96
  totalCredits: 100,
121
97
  };
122
- // Use a fixed date that's definitely in the past
123
- const date = '2026-05-04';
98
+ // Use a dynamic date that's within the last 7 days
99
+ const date = createTestDate(3); // 3 days ago
124
100
  const analytics: ActivityItem[] = [
125
- {
126
- date: date,
101
+ createActivityItem({
102
+ date,
127
103
  model: 'gpt-4',
128
104
  modelPermaslug: 'gpt-4-perma',
129
- endpointId: 'ep-1',
130
105
  usage: 5.0,
131
- byokUsageInference: 0,
132
106
  requests: 5,
133
107
  promptTokens: 100,
134
108
  completionTokens: 50,
135
- reasoningTokens: 0,
136
109
  providerName: 'openai',
137
- },
138
- {
139
- date: date,
110
+ }),
111
+ createActivityItem({
112
+ date,
140
113
  model: 'claude-3',
141
114
  modelPermaslug: 'claude-3-perma',
142
115
  endpointId: 'ep-2',
143
116
  usage: 3.0,
144
- byokUsageInference: 0,
145
117
  requests: 3,
146
118
  promptTokens: 60,
147
119
  completionTokens: 30,
148
- reasoningTokens: 0,
149
120
  providerName: 'anthropic',
150
- },
121
+ }),
151
122
  ];
152
123
 
153
124
  const result = aggregateUsage(credits, analytics);
@@ -218,32 +189,27 @@ describe('aggregateUsage', () => {
218
189
  };
219
190
  const date = '2026-05-01';
220
191
  const analytics: ActivityItem[] = [
221
- {
222
- date: date,
192
+ createActivityItem({
193
+ date,
223
194
  model: 'gpt-4',
224
195
  modelPermaslug: 'gpt-4-perma',
225
- endpointId: 'ep-1',
226
196
  usage: 5.0,
227
- byokUsageInference: 0,
228
197
  requests: 5,
229
198
  promptTokens: 100,
230
199
  completionTokens: 50,
231
- reasoningTokens: 0,
232
200
  providerName: 'openai',
233
- },
234
- {
235
- date: date,
201
+ }),
202
+ createActivityItem({
203
+ date,
236
204
  model: 'claude-3',
237
205
  modelPermaslug: 'claude-3-perma',
238
206
  endpointId: 'ep-2',
239
207
  usage: 3.0,
240
- byokUsageInference: 0,
241
208
  requests: 3,
242
209
  promptTokens: 60,
243
210
  completionTokens: 30,
244
- reasoningTokens: 0,
245
- providerName: 'openai', // Same provider, different endpoint
246
- },
211
+ providerName: 'openai',
212
+ }),
247
213
  ];
248
214
 
249
215
  const result = aggregateUsage(credits, analytics);
@@ -31,89 +31,9 @@ describe('formatSessionId', () => {
31
31
  // Request Detection Tests
32
32
  // =============================================================================
33
33
 
34
- // Helper to create mock event
35
- function createEvent(
36
- payload: Record<string, unknown>,
37
- url?: string,
38
- provider?: Record<string, unknown>,
39
- ) {
40
- const event: any = { payload };
41
- if (url) event.url = url;
42
- if (provider) event.provider = provider;
43
- return event;
44
- }
45
-
46
- // Helper to create mock context
47
- function createContext(model: string | Record<string, unknown>) {
48
- return { model } as any;
49
- }
50
-
51
34
  describe('isOpenRouterRequest', () => {
52
- // Method 1: Check model string (e.g., "openrouter/anthropic/claude-3.5-sonnet")
53
- it('detects OpenRouter by model prefix', () => {
54
- const event = createEvent({ model: 'openrouter/anthropic/claude-sonnet-4' });
55
- expect(isOpenRouterRequest(event, {})).toBe(true);
56
- });
57
-
58
- it('does not detect non-OpenRouter by model prefix', () => {
59
- const event = createEvent({ model: 'anthropic/claude-sonnet-4' });
60
- expect(isOpenRouterRequest(event, {})).toBe(false);
61
- });
62
-
63
- // Method 2: Check baseUrl from context.model
64
- it('detects OpenRouter by baseUrl', () => {
65
- const event = createEvent({ model: 'qwen/qwen3-coder-next' });
66
- const ctx = createContext({ baseUrl: 'https://openrouter.ai/api/v1' });
67
- expect(isOpenRouterRequest(event, ctx)).toBe(true);
68
- });
69
-
70
- it('does not detect non-OpenRouter by baseUrl', () => {
71
- const event = createEvent({ model: 'qwen/qwen3-coder-next' });
72
- const ctx = createContext({ baseUrl: 'https://api.anthropic.com' });
73
- expect(isOpenRouterRequest(event, ctx)).toBe(false);
74
- });
75
-
76
- // Method 3: Check for ZDR provider (Shopify routes to OpenRouter via ZDR)
77
- it('detects OpenRouter by ZDR provider', () => {
78
- const event = createEvent({ model: 'qwen/qwen3-coder-next' }, undefined, { zdr: true });
79
- expect(isOpenRouterRequest(event, {})).toBe(true);
80
- });
81
-
82
- it('does not detect non-ZDR provider', () => {
83
- // Use provider name that won't match Method 5 (not "openrouter")
84
- const event = createEvent({ model: 'qwen/qwen3-coder-next', provider: 'anthropic' });
85
- expect(isOpenRouterRequest(event, {})).toBe(false);
86
- });
87
-
88
- // Method 4: Check URL
89
- it('detects OpenRouter by URL', () => {
90
- const event = createEvent(
91
- { model: 'anthropic/claude-sonnet_4', messages: [] },
92
- 'https://openrouter.ai/api/v1/chat/completions',
93
- );
94
- expect(isOpenRouterRequest(event, {})).toBe(true);
95
- });
96
-
97
- it('does not detect non-OpenRouter by URL', () => {
98
- const event = createEvent(
99
- { model: 'anthropic/claude-sonnet_4', messages: [] },
100
- 'https://api.anthropic.com/v1/messages',
101
- );
102
- expect(isOpenRouterRequest(event, {})).toBe(false);
103
- });
104
-
105
- // Combined methods
106
- it('detects by multiple methods simultaneously', () => {
107
- const event = createEvent(
108
- { model: 'openrouter/anthropic/claude-sonnet-4' },
109
- 'https://openrouter.ai/api/v1/chat/completions',
110
- );
111
- const ctx = createContext({ baseUrl: 'https://openrouter.ai/api/v1' });
112
- expect(isOpenRouterRequest(event, ctx)).toBe(true);
113
- });
114
-
115
35
  // =============================================================================
116
- // Parameterized Tests - All Detection Methods
36
+ // Parameterized Tests - All Detection Methods (single source of truth)
117
37
  // =============================================================================
118
38
 
119
39
  const detectionCases: DetectionTestCase[] = [