@robhowley/pi-openrouter 0.9.0 → 0.10.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 +46 -5
- package/extensions/openrouter/__tests__/cache.test.ts +769 -0
- package/extensions/openrouter/__tests__/client.test.ts +333 -15
- package/extensions/openrouter/__tests__/commands.test.ts +816 -0
- package/extensions/openrouter/__tests__/fixtures.ts +140 -1
- package/extensions/openrouter/__tests__/format.test.ts +19 -0
- package/extensions/openrouter/__tests__/hooks.test.ts +529 -0
- package/extensions/openrouter/__tests__/index.test.ts +112 -363
- package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
- package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
- package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
- package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
- package/extensions/openrouter/__tests__/session.test.ts +44 -43
- package/extensions/openrouter/__tests__/status-bar.test.ts +262 -0
- package/extensions/openrouter/account-client.ts +11 -61
- package/extensions/openrouter/cache.ts +203 -91
- package/extensions/openrouter/client.ts +49 -3
- package/extensions/openrouter/commands.ts +555 -0
- package/extensions/openrouter/format.ts +7 -4
- package/extensions/openrouter/hooks.ts +288 -0
- package/extensions/openrouter/index.ts +13 -990
- package/extensions/openrouter/local-usage.ts +158 -30
- package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
- package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
- package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
- package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
- package/extensions/openrouter/models/cache.ts +27 -2
- package/extensions/openrouter/models/mapper.ts +35 -69
- package/extensions/openrouter/models/override-commands.ts +434 -0
- package/extensions/openrouter/models/skip-hints.ts +19 -0
- package/extensions/openrouter/models/sync.ts +22 -10
- package/extensions/openrouter/models/types.ts +2 -1
- package/extensions/openrouter/normalizers.ts +128 -0
- package/extensions/openrouter/overlay.ts +19 -8
- package/extensions/openrouter/session-state.ts +110 -0
- package/extensions/openrouter/session.ts +16 -0
- package/extensions/openrouter/status-bar.ts +101 -0
- package/extensions/openrouter/types.ts +28 -9
- package/package.json +1 -1
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BeforeProviderRequestEvent,
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from '@mariozechner/pi-coding-agent';
|
|
6
|
+
import { stopBackgroundRefresh } from './cache.js';
|
|
7
|
+
import { isOpenRouterRequest } from './session.js';
|
|
8
|
+
import { createSessionState, type SessionState } from './session-state.js';
|
|
9
|
+
import { writeLocalUsage, type LocalUsageEvent } from './local-usage.js';
|
|
10
|
+
import { loadCache, getCacheAgeMs, formatDuration } from './models/cache.js';
|
|
11
|
+
import { mapOpenRouterModels } from './models/mapper.js';
|
|
12
|
+
import { includeBuiltinRouterModels, isSyncEnabled } from './models/sync.js';
|
|
13
|
+
import { loadOpenRouterStatusBar } from './status-bar.js';
|
|
14
|
+
|
|
15
|
+
let sessionState: SessionState | null = null;
|
|
16
|
+
let sessionTrackingInstalled = false;
|
|
17
|
+
let openRouterStatusRolloverTimer: ReturnType<typeof setTimeout> | null = null;
|
|
18
|
+
|
|
19
|
+
type StatusContext = Pick<ExtensionContext, 'hasUI' | 'ui'>;
|
|
20
|
+
|
|
21
|
+
export interface StartupCacheState {
|
|
22
|
+
info?: {
|
|
23
|
+
count: number;
|
|
24
|
+
age: string;
|
|
25
|
+
};
|
|
26
|
+
warning?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function initializeSessionState(): void {
|
|
30
|
+
sessionState = createSessionState();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the current OpenRouter session ID.
|
|
35
|
+
* Returns a stable formatted session ID for the active Pi session.
|
|
36
|
+
* @internal Exposed for testing
|
|
37
|
+
*/
|
|
38
|
+
export function getCurrentSessionId(ctx: { sessionManager: { getSessionId(): string } }): string {
|
|
39
|
+
if (!sessionState) {
|
|
40
|
+
sessionState = createSessionState();
|
|
41
|
+
}
|
|
42
|
+
return sessionState.getCurrentSessionId(ctx);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Add session_id to OpenRouter requests before they are sent.
|
|
47
|
+
* Returns modified payload with session_id, or undefined if no modification needed.
|
|
48
|
+
*/
|
|
49
|
+
export function addSessionIdToOpenRouterRequest(
|
|
50
|
+
event: unknown,
|
|
51
|
+
ctx: { sessionManager: { getSessionId(): string } },
|
|
52
|
+
): Record<string, unknown> | undefined {
|
|
53
|
+
try {
|
|
54
|
+
const ev = event as Record<string, unknown>;
|
|
55
|
+
const payload = ev['payload'] as Record<string, unknown> | undefined;
|
|
56
|
+
if (!payload) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isOpenRouter = isOpenRouterRequest(event as BeforeProviderRequestEvent, ctx);
|
|
61
|
+
if (!isOpenRouter) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if ('session_id' in payload && payload['session_id'] !== undefined) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
...payload,
|
|
71
|
+
session_id: getCurrentSessionId(ctx),
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function loadStartupCacheState(
|
|
79
|
+
pi: Pick<ExtensionAPI, 'registerProvider'>,
|
|
80
|
+
): Promise<StartupCacheState> {
|
|
81
|
+
const startupState: StartupCacheState = {};
|
|
82
|
+
|
|
83
|
+
if (!isSyncEnabled()) {
|
|
84
|
+
return startupState;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const cache = await loadCache().catch(() => null);
|
|
88
|
+
if (!cache?.models.length) {
|
|
89
|
+
return startupState;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const { configs } = await mapOpenRouterModels(cache.models);
|
|
94
|
+
const configsWithRouters = includeBuiltinRouterModels(configs);
|
|
95
|
+
|
|
96
|
+
pi.registerProvider('openrouter', {
|
|
97
|
+
baseUrl: 'https://openrouter.ai/api/v1',
|
|
98
|
+
apiKey: 'OPENROUTER_API_KEY',
|
|
99
|
+
api: 'openai-completions',
|
|
100
|
+
models: configsWithRouters,
|
|
101
|
+
authHeader: true,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
startupState.info = {
|
|
105
|
+
count: configsWithRouters.length,
|
|
106
|
+
age: formatDuration(getCacheAgeMs(cache)),
|
|
107
|
+
};
|
|
108
|
+
} catch (error) {
|
|
109
|
+
startupState.warning = `OpenRouter: cached models found but failed to register: ${error instanceof Error ? error.message : String(error)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return startupState;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function installOpenRouterHooks(
|
|
116
|
+
pi: Pick<ExtensionAPI, 'on'>,
|
|
117
|
+
startupState: StartupCacheState,
|
|
118
|
+
): void {
|
|
119
|
+
installSessionTaggingHook(pi);
|
|
120
|
+
installLocalUsageHook(pi);
|
|
121
|
+
installLifecycleHooks(pi, startupState);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function installSessionTaggingHook(pi: Pick<ExtensionAPI, 'on'>): void {
|
|
125
|
+
if (sessionTrackingInstalled) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
sessionTrackingInstalled = true;
|
|
130
|
+
pi.on('before_provider_request', (event, ctx) => {
|
|
131
|
+
return addSessionIdToOpenRouterRequest(event as unknown, ctx);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function installLocalUsageHook(pi: Pick<ExtensionAPI, 'on'>): void {
|
|
136
|
+
pi.on('turn_end', (event, ctx) => {
|
|
137
|
+
captureLocalUsage(event as unknown, ctx);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function captureLocalUsage(event: unknown, ctx: ExtensionContext): void {
|
|
142
|
+
try {
|
|
143
|
+
const turnEvent = event as Record<string, unknown>;
|
|
144
|
+
|
|
145
|
+
const message = turnEvent['message'] as Record<string, unknown> | undefined;
|
|
146
|
+
if (!message) return;
|
|
147
|
+
|
|
148
|
+
const openRouterEvent = {
|
|
149
|
+
type: 'before_provider_request',
|
|
150
|
+
payload: message,
|
|
151
|
+
url: turnEvent['url'],
|
|
152
|
+
endpoint: turnEvent['endpoint'],
|
|
153
|
+
} as unknown as Parameters<typeof isOpenRouterRequest>[0];
|
|
154
|
+
|
|
155
|
+
if (!isOpenRouterRequest(openRouterEvent, ctx)) return;
|
|
156
|
+
|
|
157
|
+
const usage = (message as { usage?: unknown })['usage'] as
|
|
158
|
+
| {
|
|
159
|
+
input?: number;
|
|
160
|
+
output?: number;
|
|
161
|
+
cacheRead?: number;
|
|
162
|
+
cacheWrite?: number;
|
|
163
|
+
cost?: {
|
|
164
|
+
total?: number;
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
| undefined;
|
|
168
|
+
if (!usage) return;
|
|
169
|
+
|
|
170
|
+
const model = message['model'] as string | undefined;
|
|
171
|
+
const responseModel = message['responseModel'] as string | undefined;
|
|
172
|
+
|
|
173
|
+
const localEvent: LocalUsageEvent = {
|
|
174
|
+
id: crypto.randomUUID(),
|
|
175
|
+
generationId: String(message['responseId'] ?? ''),
|
|
176
|
+
sessionId: getCurrentSessionId(ctx),
|
|
177
|
+
completedAt: new Date().toISOString(),
|
|
178
|
+
model: model || responseModel || 'unknown',
|
|
179
|
+
requests: 1,
|
|
180
|
+
promptTokens: usage.input ?? 0,
|
|
181
|
+
completionTokens: usage.output ?? 0,
|
|
182
|
+
reasoningTokens: 0,
|
|
183
|
+
cacheReadTokens: usage.cacheRead ?? 0,
|
|
184
|
+
cacheWriteTokens: usage.cacheWrite ?? 0,
|
|
185
|
+
cost: usage.cost?.total ?? 0,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
void writeLocalUsage(localEvent)
|
|
189
|
+
.then(() => refreshOpenRouterUsageStatus(ctx))
|
|
190
|
+
.catch(() => {});
|
|
191
|
+
} catch {
|
|
192
|
+
// Fail open - silently ignore errors
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function refreshOpenRouterUsageStatus(ctx: StatusContext): Promise<void> {
|
|
197
|
+
if (!ctx.hasUI) return;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const statusResult = await loadOpenRouterStatusBar();
|
|
201
|
+
|
|
202
|
+
switch (statusResult.kind) {
|
|
203
|
+
case 'ready':
|
|
204
|
+
ctx.ui.setStatus('openrouter', ctx.ui.theme.fg('dim', statusResult.text));
|
|
205
|
+
return;
|
|
206
|
+
case 'empty':
|
|
207
|
+
ctx.ui.setStatus('openrouter', undefined);
|
|
208
|
+
return;
|
|
209
|
+
case 'failed':
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Fail open - preserve the existing status on unexpected refresh errors.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function clearOpenRouterStatusRolloverTimer(): void {
|
|
218
|
+
if (openRouterStatusRolloverTimer !== null) {
|
|
219
|
+
clearTimeout(openRouterStatusRolloverTimer);
|
|
220
|
+
openRouterStatusRolloverTimer = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getMillisecondsUntilNextUtcMidnight(now: Date = new Date()): number {
|
|
225
|
+
const nextUtcMidnight = Date.UTC(
|
|
226
|
+
now.getUTCFullYear(),
|
|
227
|
+
now.getUTCMonth(),
|
|
228
|
+
now.getUTCDate() + 1,
|
|
229
|
+
0,
|
|
230
|
+
0,
|
|
231
|
+
0,
|
|
232
|
+
0,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return Math.max(0, nextUtcMidnight - now.getTime());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function scheduleOpenRouterStatusRollover(ctx: StatusContext): void {
|
|
239
|
+
clearOpenRouterStatusRolloverTimer();
|
|
240
|
+
|
|
241
|
+
if (!ctx.hasUI) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
openRouterStatusRolloverTimer = setTimeout(() => {
|
|
246
|
+
openRouterStatusRolloverTimer = null;
|
|
247
|
+
void refreshOpenRouterUsageStatus(ctx).catch(() => {});
|
|
248
|
+
scheduleOpenRouterStatusRollover(ctx);
|
|
249
|
+
}, getMillisecondsUntilNextUtcMidnight());
|
|
250
|
+
openRouterStatusRolloverTimer.unref?.();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function installLifecycleHooks(
|
|
254
|
+
pi: Pick<ExtensionAPI, 'on'>,
|
|
255
|
+
startupState: StartupCacheState,
|
|
256
|
+
): void {
|
|
257
|
+
pi.on('session_shutdown', () => {
|
|
258
|
+
clearOpenRouterStatusRolloverTimer();
|
|
259
|
+
stopBackgroundRefresh();
|
|
260
|
+
sessionState?.reset();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
pi.on('session_start', (event, ctx) => {
|
|
264
|
+
handleSessionStart(event as { reason: string }, ctx, startupState);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function handleSessionStart(
|
|
269
|
+
event: { reason: string },
|
|
270
|
+
ctx: ExtensionContext,
|
|
271
|
+
startupState: StartupCacheState,
|
|
272
|
+
): void {
|
|
273
|
+
sessionState?.startSession(ctx);
|
|
274
|
+
|
|
275
|
+
if (!ctx.hasUI) return;
|
|
276
|
+
|
|
277
|
+
void refreshOpenRouterUsageStatus(ctx).catch(() => {});
|
|
278
|
+
scheduleOpenRouterStatusRollover(ctx);
|
|
279
|
+
|
|
280
|
+
if (event.reason === 'startup' && startupState.info) {
|
|
281
|
+
const notice = `OpenRouter: ${startupState.info.count} models loaded from cache (${startupState.info.age} old). Run /openrouter models-sync to refresh.`;
|
|
282
|
+
ctx.ui.notify(notice, 'info');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (event.reason === 'startup' && startupState.warning) {
|
|
286
|
+
ctx.ui.notify(startupState.warning, 'warning');
|
|
287
|
+
}
|
|
288
|
+
}
|