@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.
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Sync engine for OpenRouter models.
3
+ * Orchestrates model fetch, mapping, registration, and cache management.
4
+ */
5
+
6
+ import { fetchUserModels } from '../client.js';
7
+ import { mapOpenRouterModels, sdkModelToOpenRouterModel } from './mapper.js';
8
+ import { loadCache, saveCache } from './cache.js';
9
+ import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
10
+ import type {
11
+ SyncResult,
12
+ PiModelConfig,
13
+ ModelsCache,
14
+ OpenRouterModel,
15
+ SkipReason,
16
+ } from './types.js';
17
+ import { existsSync, readFileSync } from 'node:fs';
18
+ import { ROUTER_DEFINITIONS } from './types.js';
19
+ import { join } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+
22
+ // Store the current sync state for status display.
23
+ let currentSyncState: SyncResult | null = null;
24
+
25
+ /**
26
+ * Check if model sync is enabled via user config.
27
+ * Default is true (sync enabled) if config is not set.
28
+ *
29
+ * Reads from ~/.pi/agent/settings.json (global settings).
30
+ */
31
+ export function isSyncEnabled(): boolean {
32
+ // Get global settings path
33
+ const globalSettingsPath = join(homedir(), '.pi', 'agent', 'settings.json');
34
+
35
+ if (!existsSync(globalSettingsPath)) {
36
+ return true; // Default to enabled if no settings file
37
+ }
38
+
39
+ try {
40
+ const settings = JSON.parse(readFileSync(globalSettingsPath, 'utf-8'));
41
+ // Default is true (enabled) - only disabled if explicitly set to false
42
+ return settings['openrouterModelSync'] !== false;
43
+ } catch {
44
+ return true; // Default to enabled if settings file can't be read
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Set the current sync state.
50
+ * Called after each sync operation.
51
+ */
52
+ export function setSyncState(result: SyncResult): void {
53
+ currentSyncState = result;
54
+ }
55
+
56
+ /**
57
+ * Get the current sync state for status display.
58
+ */
59
+ export function getSyncState(): SyncResult | null {
60
+ return currentSyncState;
61
+ }
62
+
63
+ /**
64
+ * Register mapped models with Pi's OpenRouter provider.
65
+ *
66
+ * Uses modelRegistry.registerProvider() to add models to the built-in openrouter provider.
67
+ * The models array replaces all existing models for the provider.
68
+ */
69
+ async function registerModelsWithProvider(
70
+ ctx: ExtensionContext,
71
+ configs: PiModelConfig[],
72
+ ): Promise<void> {
73
+ // Register models with Pi's OpenRouter provider
74
+ // This replaces all existing models for the provider with our synced ones
75
+ ctx.modelRegistry.registerProvider('openrouter', {
76
+ baseUrl: 'https://openrouter.ai/api/v1',
77
+ apiKey: 'OPENROUTER_API_KEY',
78
+ api: 'openai-completions',
79
+ models: configs,
80
+ authHeader: true,
81
+ });
82
+
83
+ console.log(`[pi-openrouter] Registered ${configs.length} models with OpenRouter provider`);
84
+ }
85
+
86
+ /**
87
+ * Built-in router models derived from ROUTER_DEFINITIONS in types.ts.
88
+ * This ensures sync with mapper.ts skip logic.
89
+ */
90
+
91
+ const BUILTIN_ROUTER_MODELS: PiModelConfig[] = ROUTER_DEFINITIONS.map((r) => ({
92
+ id: r.id,
93
+ name: r.name,
94
+ reasoning: r.reasoning,
95
+ input: [...r.input],
96
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
97
+ contextWindow: r.contextLength,
98
+ maxTokens: r.maxTokens,
99
+ }));
100
+
101
+ /**
102
+ * Convert router definitions to OpenRouterModel format for cache storage.
103
+ */
104
+ function getRouterCacheModels(): OpenRouterModel[] {
105
+ return ROUTER_DEFINITIONS.map((r) => ({
106
+ id: r.id,
107
+ name: r.name,
108
+ architecture: {
109
+ input_modalities: [...r.input],
110
+ output_modalities: [...r.output],
111
+ },
112
+ context_length: r.contextLength,
113
+ pricing: { prompt: '0', completion: '0' },
114
+ supported_parameters: r.reasoning ? ['reasoning'] : [],
115
+ }));
116
+ }
117
+
118
+ /**
119
+ * Execute a full sync operation:
120
+ * 1. Fetch models from OpenRouter API
121
+ * 2. Map to Pi model config
122
+ * 3. Register with provider
123
+ * 4. Update cache
124
+ *
125
+ * On API failure, falls back to cached models if available.
126
+ *
127
+ * @param ctx - Extension context for provider registration
128
+ * @returns SyncResult with details of the operation
129
+ */
130
+ export async function syncModels(_ctx: ExtensionContext): Promise<SyncResult> {
131
+ // Note: Config check (isSyncEnabled) is now handled at the command level
132
+ // in index.ts. This allows tests to run without file system dependencies.
133
+
134
+ // Attempt 1: Fetch from API
135
+ try {
136
+ const response = await fetchUserModels();
137
+ const { configs, skipped, skippedDetails } = mapOpenRouterModels(response.data);
138
+
139
+ // Add built-in router aliases that don't appear in /models/user endpoint
140
+ const configsWithRouters = [...configs, ...BUILTIN_ROUTER_MODELS];
141
+
142
+ // Register with Pi's OpenRouter provider
143
+ await registerModelsWithProvider(_ctx, configsWithRouters);
144
+
145
+ // Convert SDK Model[] to OpenRouterModel[] for cache storage
146
+ const cacheModels: OpenRouterModel[] = response.data.map(sdkModelToOpenRouterModel);
147
+
148
+ // Update last-good cache (include routers and skip details)
149
+ const cache: ModelsCache = {
150
+ models: [...cacheModels, ...getRouterCacheModels()],
151
+ skippedDetails: skippedDetails,
152
+ timestamp: Date.now(),
153
+ };
154
+ await saveCache(cache);
155
+
156
+ const result: SyncResult = {
157
+ success: true,
158
+ registeredCount: configsWithRouters.length,
159
+ skippedCount: skipped,
160
+ skippedDetails: skippedDetails,
161
+ source: 'api',
162
+ cacheUpdated: true,
163
+ cacheAgeMs: 0, // Cache was just updated
164
+ error: null,
165
+ };
166
+
167
+ setSyncState(result);
168
+ return result;
169
+ } catch (error) {
170
+ // API failed - try cache fallback
171
+ const errorMsg = error instanceof Error ? error.message : String(error);
172
+
173
+ const cache = await loadCache();
174
+
175
+ if (cache) {
176
+ // Attempt 2: Use cached models
177
+ const { configs, skipped } = mapOpenRouterModels(cache.models);
178
+
179
+ await registerModelsWithProvider(_ctx, configs);
180
+
181
+ // Use cached skip details if available
182
+ const cachedSkipDetails = cache.skippedDetails || [];
183
+
184
+ const result: SyncResult = {
185
+ success: false,
186
+ registeredCount: configs.length,
187
+ skippedCount: skipped,
188
+ source: 'cache',
189
+ cacheUpdated: false,
190
+ cacheAgeMs: Date.now() - cache.timestamp,
191
+ error: errorMsg,
192
+ skippedDetails: cachedSkipDetails,
193
+ };
194
+
195
+ setSyncState(result);
196
+ return result;
197
+ }
198
+
199
+ // Attempt 3: No cache available - complete failure
200
+ const result: SyncResult = {
201
+ success: false,
202
+ registeredCount: 0,
203
+ skippedCount: 0,
204
+ source: 'none',
205
+ cacheUpdated: false,
206
+ cacheAgeMs: null,
207
+ error: errorMsg,
208
+ };
209
+
210
+ setSyncState(result);
211
+ return result;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Get a human-readable status string for the current sync state.
217
+ */
218
+ export function getStatusText(): string {
219
+ const state = getSyncState();
220
+
221
+ if (!state) {
222
+ return 'OpenRouter models: not synced';
223
+ }
224
+
225
+ // Derive status from result
226
+ let status: string;
227
+ if (state.success) {
228
+ status = 'healthy';
229
+ } else if (state.source === 'cache') {
230
+ status = 'cached';
231
+ } else {
232
+ status = 'broken';
233
+ }
234
+
235
+ return `OpenRouter models: ${status} (${state.registeredCount} registered)`;
236
+ }
237
+
238
+ /**
239
+ * Check if models are currently available (synced or cached).
240
+ * Checks in-memory state first, then falls back to cache file on disk.
241
+ */
242
+ export async function areModelsAvailable(): Promise<boolean> {
243
+ const state = getSyncState();
244
+ if (state) {
245
+ return state.registeredCount > 0 || state.source === 'cache';
246
+ }
247
+
248
+ // Check cache file on disk
249
+ const cache = await loadCache();
250
+ return cache !== null && cache.models.length > 0;
251
+ }
252
+
253
+ /**
254
+ * Get skip reasons from the current sync state or cache.
255
+ * Note: For models-status (synchronous), we can't await here.
256
+ * For async usage, use getSkipReasonsAsync instead.
257
+ */
258
+ export function getSkipReasons(maxResults: number = 10): SkipReason[] {
259
+ const state = getSyncState();
260
+ if (!state) return [];
261
+
262
+ // Prefer in-memory state
263
+ if (state.skippedDetails && state.skippedDetails.length > 0) {
264
+ return state.skippedDetails.slice(0, maxResults);
265
+ }
266
+
267
+ return [];
268
+ }
269
+
270
+ /**
271
+ * Async version of getSkipReasons that reads from cache if needed.
272
+ */
273
+ export async function getSkipReasonsAsync(maxResults: number = 10): Promise<SkipReason[]> {
274
+ const state = getSyncState();
275
+
276
+ // First check in-memory state
277
+ if (state?.skippedDetails && state.skippedDetails.length > 0) {
278
+ return state.skippedDetails.slice(0, maxResults);
279
+ }
280
+
281
+ // Fall back to cache if not available in state
282
+ const cache = await loadCache();
283
+ if (cache?.skippedDetails && cache.skippedDetails.length > 0) {
284
+ return cache.skippedDetails.slice(0, maxResults);
285
+ }
286
+
287
+ return [];
288
+ }
289
+
290
+ /**
291
+ * Format skip reasons for display.
292
+ */
293
+ export function formatSkipReasons(reasons: SkipReason[]): string {
294
+ if (reasons.length === 0) return '';
295
+
296
+ const lines: string[] = [];
297
+ for (const reason of reasons) {
298
+ lines.push(` - ${reason.id}: ${reason.reason}`);
299
+ }
300
+ return lines.join('\n');
301
+ }
302
+
303
+ /**
304
+ * Group skip reasons by reason type and return counts.
305
+ */
306
+ export function groupSkipReasons(reasons: SkipReason[]): Record<string, number> {
307
+ const counts: Record<string, number> = {};
308
+ for (const reason of reasons) {
309
+ counts[reason.reason] = (counts[reason.reason] || 0) + 1;
310
+ }
311
+ return counts;
312
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Raw OpenRouter model from /api/v1/models/user
3
+ */
4
+ export interface OpenRouterModel {
5
+ id: string;
6
+ name?: string;
7
+ architecture?: {
8
+ input_modalities?: string[];
9
+ output_modalities?: string[];
10
+ };
11
+ context_length: number;
12
+ pricing: {
13
+ prompt: string; // per-token price as string
14
+ completion: string;
15
+ input_cache_read?: string;
16
+ input_cache_write?: string;
17
+ };
18
+ supported_parameters?: string[];
19
+ top_provider?: {
20
+ context_length?: number;
21
+ max_completion_tokens?: number;
22
+ };
23
+ per_request_limits?: {
24
+ completion_tokens?: number;
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Response wrapper from /models/user endpoint
30
+ */
31
+ export interface OpenRouterModelsResponse {
32
+ data: OpenRouterModel[];
33
+ }
34
+
35
+ /**
36
+ * Mapped Pi model configuration for provider registration
37
+ */
38
+ export interface PiModelConfig {
39
+ id: string;
40
+ name: string;
41
+ reasoning: boolean;
42
+ input: ('text' | 'image')[];
43
+ cost: {
44
+ input: number; // $ per 1M tokens
45
+ output: number;
46
+ cacheRead: number;
47
+ cacheWrite: number;
48
+ };
49
+ contextWindow: number;
50
+ maxTokens: number;
51
+ }
52
+
53
+ /**
54
+ * Result of a sync operation
55
+ */
56
+ export interface SyncResult {
57
+ success: boolean;
58
+ registeredCount: number;
59
+ skippedCount: number;
60
+ skippedDetails?: SkipReason[]; // Track why models were skipped
61
+ source: 'api' | 'cache' | 'none';
62
+ cacheUpdated: boolean;
63
+ cacheAgeMs: number | null;
64
+ error: string | null;
65
+ }
66
+
67
+ /**
68
+ * Cache file structure - using our OpenRouterModel type for consistency
69
+ */
70
+ export interface ModelsCache {
71
+ models: OpenRouterModel[];
72
+ skippedDetails?: SkipReason[]; // New field for tracking skip reasons
73
+ timestamp: number;
74
+ }
75
+
76
+ /**
77
+ * Reason a model was skipped during mapping.
78
+ */
79
+ export interface SkipReason {
80
+ id: string;
81
+ reason: string;
82
+ }
83
+
84
+ /**
85
+ * Result of batch mapping operation
86
+ */
87
+ export interface MapResult {
88
+ configs: PiModelConfig[];
89
+ skipped: number;
90
+ skippedDetails: SkipReason[];
91
+ }
92
+
93
+ // =============================================================================
94
+ // Built-in Router Definitions (Single Source of Truth)
95
+ // =============================================================================
96
+
97
+ /**
98
+ * Canonical router definitions for OpenRouter's special routing models.
99
+ * These don't appear in /models/user API but should always be available.
100
+ */
101
+ export const ROUTER_DEFINITIONS = [
102
+ {
103
+ id: 'openrouter/auto',
104
+ name: 'Auto Router',
105
+ reasoning: true,
106
+ input: ['text', 'image'] as const,
107
+ output: ['text'] as const,
108
+ contextLength: 2000000,
109
+ maxTokens: 4096,
110
+ },
111
+ {
112
+ id: 'openrouter/free',
113
+ name: 'Free Models Router',
114
+ reasoning: true,
115
+ input: ['text', 'image'] as const,
116
+ output: ['text'] as const,
117
+ contextLength: 200000,
118
+ maxTokens: 4096,
119
+ },
120
+ {
121
+ id: 'openrouter/owl-alpha',
122
+ name: 'Owl Alpha',
123
+ reasoning: false,
124
+ input: ['text'] as const,
125
+ output: ['text'] as const,
126
+ contextLength: 1048756,
127
+ maxTokens: 262144,
128
+ },
129
+ ] as const;
130
+
131
+ /**
132
+ * Router IDs extracted from ROUTER_DEFINITIONS for quick lookup.
133
+ * Use this for skip checks and filtering.
134
+ */
135
+ export const ROUTER_ALIASES: readonly string[] = ROUTER_DEFINITIONS.map((r) => r.id);
136
+
137
+ // =============================================================================
138
+ // Time Constants
139
+ // =============================================================================
140
+
141
+ /** Milliseconds per minute */
142
+ export const MS_PER_MINUTE = 60000;
143
+
144
+ /** Milliseconds per hour */
145
+ export const MS_PER_HOUR = 3600000;
146
+
147
+ /** Milliseconds per day */
148
+ export const MS_PER_DAY = 86400000;
@@ -25,15 +25,30 @@ export function formatSessionId(sessionId: string): string {
25
25
 
26
26
  export function isOpenRouterRequest(event: BeforeProviderRequestEvent, _ctx: unknown): boolean {
27
27
  const ev = event as unknown as Record<string, unknown>;
28
-
29
- // Method 1: Check model string (e.g., "openrouter/anthropic/claude-3.5-sonnet")
30
28
  const payload = ev['payload'] as Record<string, unknown> | undefined;
29
+
30
+ // Method 1: Check if provider is explicitly "openrouter" (Pi coding agent first-class)
31
+ // Provider could be in event.provider, event.payload.provider, as string or object with name
32
+ const eventProvider = ev['provider'];
33
+ const payloadProvider = payload?.['provider'];
34
+ const providerName =
35
+ typeof eventProvider === 'string'
36
+ ? eventProvider
37
+ : typeof payloadProvider === 'string'
38
+ ? payloadProvider
39
+ : ((eventProvider as Record<string, unknown> | undefined)?.['name'] ??
40
+ (payloadProvider as Record<string, unknown> | undefined)?.['name']);
41
+ if (providerName === 'openrouter') {
42
+ return true;
43
+ }
44
+
45
+ // Method 2: Check model string (e.g., "openrouter/anthropic/claude-3.5-sonnet")
31
46
  const model = String(payload?.['model'] ?? '');
32
47
  if (model.includes('openrouter/')) {
33
48
  return true;
34
49
  }
35
50
 
36
- // Method 2: Check baseUrl from context.model
51
+ // Method 3: Check baseUrl from context.model
37
52
  // OpenRouter models have baseUrl starting with https://openrouter.ai/api/v1
38
53
  const context = _ctx as Record<string, unknown>;
39
54
  const ctxModel = context['model'] as Record<string, unknown> | undefined;
@@ -42,13 +57,13 @@ export function isOpenRouterRequest(event: BeforeProviderRequestEvent, _ctx: unk
42
57
  return true;
43
58
  }
44
59
 
45
- // Method 3: Check for ZDR provider (Shopify routes to OpenRouter via ZDR)
60
+ // Method 4: Check for ZDR provider (Shopify routes to OpenRouter via ZDR)
46
61
  const provider = ev['provider'] as Record<string, unknown> | undefined;
47
62
  if (provider?.['zdr'] === true) {
48
63
  return true;
49
64
  }
50
65
 
51
- // Method 4: Check URL
66
+ // Method 5: Check URL (fallback for events where provider info is missing)
52
67
  const url = String(
53
68
  (ev['url'] as string | undefined) ?? (ev['endpoint'] as string | undefined) ?? '',
54
69
  );
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@robhowley/pi-openrouter",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
- "description": "Live OpenRouter TUI overlays for spend, credits, key limits, burn rate, model usage, and session tagging.",
5
+ "description": "Live OpenRouter spend/account TUI overlays, user-scoped model sync, and session tagging for Pi.",
6
6
  "license": "MIT",
7
7
  "files": [
8
8
  "extensions",