@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 +43 -3
- package/extensions/openrouter/__tests__/client.test.ts +169 -0
- package/extensions/openrouter/__tests__/fixtures.ts +134 -0
- package/extensions/openrouter/__tests__/format.test.ts +25 -59
- package/extensions/openrouter/__tests__/session.test.ts +1 -81
- package/extensions/openrouter/client.ts +70 -12
- package/extensions/openrouter/index.ts +120 -6
- package/extensions/openrouter/models/__tests__/cache.test.ts +140 -0
- package/extensions/openrouter/models/__tests__/mapper.test.ts +221 -0
- package/extensions/openrouter/models/__tests__/sync.test.ts +313 -0
- package/extensions/openrouter/models/cache.ts +105 -0
- package/extensions/openrouter/models/mapper.ts +182 -0
- package/extensions/openrouter/models/sync.ts +312 -0
- package/extensions/openrouter/models/types.ts +148 -0
- package/package.json +2 -2
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robhowley/pi-openrouter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Live OpenRouter TUI overlays
|
|
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",
|