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