@robhowley/pi-openrouter 0.7.1 → 0.8.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.
@@ -0,0 +1,182 @@
1
+ import type { OpenRouterModel, PiModelConfig, SkipReason, MapResult } from './types.js';
2
+ import { ROUTER_ALIASES } from './types.js';
3
+ import type { Model as SDKModel } from '@openrouter/sdk/models/index.js';
4
+
5
+ const COST_PER_MILLION = 1_000_000;
6
+ const DEFAULT_MAX_TOKENS = 4096;
7
+
8
+ /**
9
+ * Convert SDK Model to our OpenRouterModel type for compatibility.
10
+ * Handles SDK's camelCase naming convention.
11
+ */
12
+ export function sdkModelToOpenRouterModel(model: SDKModel): OpenRouterModel {
13
+ const topProvider = model.topProvider
14
+ ? {
15
+ context_length: model.topProvider.contextLength ?? 0,
16
+ max_completion_tokens: model.topProvider.maxCompletionTokens ?? 0,
17
+ }
18
+ : undefined;
19
+
20
+ const perRequestLimits = model.perRequestLimits
21
+ ? {
22
+ completion_tokens: model.perRequestLimits.completionTokens ?? 0,
23
+ }
24
+ : undefined;
25
+
26
+ // Build the object conditionally to avoid undefined property issues
27
+ const result: OpenRouterModel = {
28
+ id: model.id,
29
+ name: model.name,
30
+ architecture: {
31
+ input_modalities: model.architecture.inputModalities ?? [],
32
+ output_modalities: model.architecture.outputModalities ?? [],
33
+ },
34
+ context_length: model.contextLength ?? 0,
35
+ pricing: {
36
+ prompt: String(model.pricing.prompt ?? 0),
37
+ completion: String(model.pricing.completion ?? 0),
38
+ input_cache_read: String(model.pricing.inputCacheRead ?? 0),
39
+ input_cache_write: String(model.pricing.inputCacheWrite ?? 0),
40
+ },
41
+ supported_parameters: model.supportedParameters,
42
+ };
43
+
44
+ // Conditionally add optional properties to avoid explicit undefined
45
+ if (topProvider) {
46
+ result.top_provider = topProvider;
47
+ }
48
+ if (perRequestLimits) {
49
+ result.per_request_limits = perRequestLimits;
50
+ }
51
+
52
+ return result;
53
+ }
54
+
55
+ /**
56
+ * Normalize input model to OpenRouterModel format.
57
+ */
58
+ function normalizeModel(model: OpenRouterModel | SDKModel): OpenRouterModel {
59
+ return 'contextLength' in model
60
+ ? sdkModelToOpenRouterModel(model as SDKModel)
61
+ : (model as OpenRouterModel);
62
+ }
63
+
64
+ /**
65
+ * Validation result for a model check.
66
+ */
67
+ type ValidationResult =
68
+ | { valid: true; model: OpenRouterModel; contextWindow: number }
69
+ | { valid: false; reason: string; modelId: string };
70
+
71
+ /**
72
+ * Validate a model and return either a valid result with extracted context window
73
+ * or a failure reason.
74
+ */
75
+ function validateModel(model: OpenRouterModel): ValidationResult {
76
+ // Check: missing required id
77
+ if (!model.id) {
78
+ return { valid: false, reason: 'missing id', modelId: 'unknown' };
79
+ }
80
+
81
+ // Check: missing required pricing fields
82
+ if (!model.pricing?.prompt) {
83
+ return { valid: false, reason: 'missing prompt pricing', modelId: model.id };
84
+ }
85
+ if (!model.pricing?.completion) {
86
+ return { valid: false, reason: 'missing completion pricing', modelId: model.id };
87
+ }
88
+
89
+ // Check: missing context window (both primary and fallback)
90
+ const contextWindow = model.top_provider?.context_length ?? model.context_length;
91
+ if (!contextWindow) {
92
+ return { valid: false, reason: 'missing context window', modelId: model.id };
93
+ }
94
+
95
+ // Check: explicitly non-text output (if specified)
96
+ const outputModalities = model.architecture?.output_modalities;
97
+ if (outputModalities && !outputModalities.includes('text')) {
98
+ return { valid: false, reason: 'non-text output modalities', modelId: model.id };
99
+ }
100
+
101
+ return { valid: true, model, contextWindow };
102
+ }
103
+
104
+ /**
105
+ * Build PiModelConfig from a validated OpenRouterModel.
106
+ */
107
+ function buildPiConfig(model: OpenRouterModel, contextWindow: number): PiModelConfig {
108
+ const supportedParams = model.supported_parameters ?? [];
109
+ const hasReasoning =
110
+ supportedParams.includes('reasoning') || supportedParams.includes('include_reasoning');
111
+ const inputModalities = model.architecture?.input_modalities;
112
+ const supportsImages = inputModalities?.includes('image') ?? false;
113
+
114
+ return {
115
+ id: model.id,
116
+ name: model.name ?? model.id,
117
+ reasoning: hasReasoning,
118
+ input: supportsImages ? ['text', 'image'] : ['text'],
119
+ cost: {
120
+ input: Number(model.pricing.prompt) * COST_PER_MILLION,
121
+ output: Number(model.pricing.completion) * COST_PER_MILLION,
122
+ cacheRead: Number(model.pricing.input_cache_read ?? 0) * COST_PER_MILLION,
123
+ cacheWrite: Number(model.pricing.input_cache_write ?? 0) * COST_PER_MILLION,
124
+ },
125
+ contextWindow,
126
+ maxTokens:
127
+ model.top_provider?.max_completion_tokens ??
128
+ model.per_request_limits?.completion_tokens ??
129
+ DEFAULT_MAX_TOKENS,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Maps multiple OpenRouter models, tracking skips.
135
+ */
136
+ export function mapOpenRouterModels(models: OpenRouterModel[] | SDKModel[]): MapResult {
137
+ const configs: PiModelConfig[] = [];
138
+ let skipped = 0;
139
+ const skippedDetails: SkipReason[] = [];
140
+
141
+ for (const rawModel of models) {
142
+ const model = normalizeModel(rawModel);
143
+
144
+ // Skip router aliases - they're added manually after mapping
145
+ if (ROUTER_ALIASES.includes(model.id)) {
146
+ continue;
147
+ }
148
+
149
+ const validation = validateModel(model);
150
+
151
+ if (!validation.valid) {
152
+ skipped++;
153
+ skippedDetails.push({ id: validation.modelId, reason: validation.reason });
154
+ continue;
155
+ }
156
+
157
+ configs.push(buildPiConfig(model, validation.contextWindow));
158
+ }
159
+
160
+ return { configs, skipped, skippedDetails };
161
+ }
162
+
163
+ /**
164
+ * Maps a single OpenRouter model to Pi model config.
165
+ * Returns null if the model should be skipped.
166
+ */
167
+ export function mapOpenRouterModel(model: OpenRouterModel | SDKModel): PiModelConfig | null {
168
+ const normalized = normalizeModel(model);
169
+
170
+ // Router aliases are handled separately, skip them here
171
+ if (ROUTER_ALIASES.includes(normalized.id)) {
172
+ return null;
173
+ }
174
+
175
+ const validation = validateModel(normalized);
176
+
177
+ if (!validation.valid) {
178
+ return null;
179
+ }
180
+
181
+ return buildPiConfig(normalized, validation.contextWindow);
182
+ }
@@ -0,0 +1,310 @@
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
+ export 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
+
84
+ /**
85
+ * Built-in router models derived from ROUTER_DEFINITIONS in types.ts.
86
+ * This ensures sync with mapper.ts skip logic.
87
+ */
88
+
89
+ const BUILTIN_ROUTER_MODELS: PiModelConfig[] = ROUTER_DEFINITIONS.map((r) => ({
90
+ id: r.id,
91
+ name: r.name,
92
+ reasoning: r.reasoning,
93
+ input: [...r.input],
94
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
95
+ contextWindow: r.contextLength,
96
+ maxTokens: r.maxTokens,
97
+ }));
98
+
99
+ /**
100
+ * Convert router definitions to OpenRouterModel format for cache storage.
101
+ */
102
+ function getRouterCacheModels(): OpenRouterModel[] {
103
+ return ROUTER_DEFINITIONS.map((r) => ({
104
+ id: r.id,
105
+ name: r.name,
106
+ architecture: {
107
+ input_modalities: [...r.input],
108
+ output_modalities: [...r.output],
109
+ },
110
+ context_length: r.contextLength,
111
+ pricing: { prompt: '0', completion: '0' },
112
+ supported_parameters: r.reasoning ? ['reasoning'] : [],
113
+ }));
114
+ }
115
+
116
+ /**
117
+ * Execute a full sync operation:
118
+ * 1. Fetch models from OpenRouter API
119
+ * 2. Map to Pi model config
120
+ * 3. Register with provider
121
+ * 4. Update cache
122
+ *
123
+ * On API failure, falls back to cached models if available.
124
+ *
125
+ * @param ctx - Extension context for provider registration
126
+ * @returns SyncResult with details of the operation
127
+ */
128
+ export async function syncModels(_ctx: ExtensionContext): Promise<SyncResult> {
129
+ // Note: Config check (isSyncEnabled) is now handled at the command level
130
+ // in index.ts. This allows tests to run without file system dependencies.
131
+
132
+ // Attempt 1: Fetch from API
133
+ try {
134
+ const response = await fetchUserModels();
135
+ const { configs, skipped, skippedDetails } = mapOpenRouterModels(response.data);
136
+
137
+ // Add built-in router aliases that don't appear in /models/user endpoint
138
+ const configsWithRouters = [...configs, ...BUILTIN_ROUTER_MODELS];
139
+
140
+ // Register with Pi's OpenRouter provider
141
+ await registerModelsWithProvider(_ctx, configsWithRouters);
142
+
143
+ // Convert SDK Model[] to OpenRouterModel[] for cache storage
144
+ const cacheModels: OpenRouterModel[] = response.data.map(sdkModelToOpenRouterModel);
145
+
146
+ // Update last-good cache (include routers and skip details)
147
+ const cache: ModelsCache = {
148
+ models: [...cacheModels, ...getRouterCacheModels()],
149
+ skippedDetails: skippedDetails,
150
+ timestamp: Date.now(),
151
+ };
152
+ await saveCache(cache);
153
+
154
+ const result: SyncResult = {
155
+ success: true,
156
+ registeredCount: configsWithRouters.length,
157
+ skippedCount: skipped,
158
+ skippedDetails: skippedDetails,
159
+ source: 'api',
160
+ cacheUpdated: true,
161
+ cacheAgeMs: 0, // Cache was just updated
162
+ error: null,
163
+ };
164
+
165
+ setSyncState(result);
166
+ return result;
167
+ } catch (error) {
168
+ // API failed - try cache fallback
169
+ const errorMsg = error instanceof Error ? error.message : String(error);
170
+
171
+ const cache = await loadCache();
172
+
173
+ if (cache) {
174
+ // Attempt 2: Use cached models
175
+ const { configs, skipped } = mapOpenRouterModels(cache.models);
176
+
177
+ await registerModelsWithProvider(_ctx, configs);
178
+
179
+ // Use cached skip details if available
180
+ const cachedSkipDetails = cache.skippedDetails || [];
181
+
182
+ const result: SyncResult = {
183
+ success: false,
184
+ registeredCount: configs.length,
185
+ skippedCount: skipped,
186
+ source: 'cache',
187
+ cacheUpdated: false,
188
+ cacheAgeMs: Date.now() - cache.timestamp,
189
+ error: errorMsg,
190
+ skippedDetails: cachedSkipDetails,
191
+ };
192
+
193
+ setSyncState(result);
194
+ return result;
195
+ }
196
+
197
+ // Attempt 3: No cache available - complete failure
198
+ const result: SyncResult = {
199
+ success: false,
200
+ registeredCount: 0,
201
+ skippedCount: 0,
202
+ source: 'none',
203
+ cacheUpdated: false,
204
+ cacheAgeMs: null,
205
+ error: errorMsg,
206
+ };
207
+
208
+ setSyncState(result);
209
+ return result;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Get a human-readable status string for the current sync state.
215
+ */
216
+ export function getStatusText(): string {
217
+ const state = getSyncState();
218
+
219
+ if (!state) {
220
+ return 'OpenRouter models: not synced';
221
+ }
222
+
223
+ // Derive status from result
224
+ let status: string;
225
+ if (state.success) {
226
+ status = 'healthy';
227
+ } else if (state.source === 'cache') {
228
+ status = 'cached';
229
+ } else {
230
+ status = 'broken';
231
+ }
232
+
233
+ return `OpenRouter models: ${status} (${state.registeredCount} registered)`;
234
+ }
235
+
236
+ /**
237
+ * Check if models are currently available (synced or cached).
238
+ * Checks in-memory state first, then falls back to cache file on disk.
239
+ */
240
+ export async function areModelsAvailable(): Promise<boolean> {
241
+ const state = getSyncState();
242
+ if (state) {
243
+ return state.registeredCount > 0 || state.source === 'cache';
244
+ }
245
+
246
+ // Check cache file on disk
247
+ const cache = await loadCache();
248
+ return cache !== null && cache.models.length > 0;
249
+ }
250
+
251
+ /**
252
+ * Get skip reasons from the current sync state or cache.
253
+ * Note: For models-status (synchronous), we can't await here.
254
+ * For async usage, use getSkipReasonsAsync instead.
255
+ */
256
+ export function getSkipReasons(maxResults: number = 10): SkipReason[] {
257
+ const state = getSyncState();
258
+ if (!state) return [];
259
+
260
+ // Prefer in-memory state
261
+ if (state.skippedDetails && state.skippedDetails.length > 0) {
262
+ return state.skippedDetails.slice(0, maxResults);
263
+ }
264
+
265
+ return [];
266
+ }
267
+
268
+ /**
269
+ * Async version of getSkipReasons that reads from cache if needed.
270
+ */
271
+ export async function getSkipReasonsAsync(maxResults: number = 10): Promise<SkipReason[]> {
272
+ const state = getSyncState();
273
+
274
+ // First check in-memory state
275
+ if (state?.skippedDetails && state.skippedDetails.length > 0) {
276
+ return state.skippedDetails.slice(0, maxResults);
277
+ }
278
+
279
+ // Fall back to cache if not available in state
280
+ const cache = await loadCache();
281
+ if (cache?.skippedDetails && cache.skippedDetails.length > 0) {
282
+ return cache.skippedDetails.slice(0, maxResults);
283
+ }
284
+
285
+ return [];
286
+ }
287
+
288
+ /**
289
+ * Format skip reasons for display.
290
+ */
291
+ export function formatSkipReasons(reasons: SkipReason[]): string {
292
+ if (reasons.length === 0) return '';
293
+
294
+ const lines: string[] = [];
295
+ for (const reason of reasons) {
296
+ lines.push(` - ${reason.id}: ${reason.reason}`);
297
+ }
298
+ return lines.join('\n');
299
+ }
300
+
301
+ /**
302
+ * Group skip reasons by reason type and return counts.
303
+ */
304
+ export function groupSkipReasons(reasons: SkipReason[]): Record<string, number> {
305
+ const counts: Record<string, number> = {};
306
+ for (const reason of reasons) {
307
+ counts[reason.reason] = (counts[reason.reason] || 0) + 1;
308
+ }
309
+ return counts;
310
+ }
@@ -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;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@robhowley/pi-openrouter",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
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",