@robhowley/pi-openrouter 0.9.0 → 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 (37) hide show
  1. package/README.md +39 -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 +112 -363
  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 -990
  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.test.ts +29 -0
  24. package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
  25. package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
  26. package/extensions/openrouter/models/cache.ts +27 -2
  27. package/extensions/openrouter/models/mapper.ts +35 -69
  28. package/extensions/openrouter/models/override-commands.ts +434 -0
  29. package/extensions/openrouter/models/skip-hints.ts +19 -0
  30. package/extensions/openrouter/models/sync.ts +22 -10
  31. package/extensions/openrouter/models/types.ts +2 -1
  32. package/extensions/openrouter/normalizers.ts +128 -0
  33. package/extensions/openrouter/overlay.ts +19 -8
  34. package/extensions/openrouter/session-state.ts +110 -0
  35. package/extensions/openrouter/session.ts +16 -0
  36. package/extensions/openrouter/types.ts +28 -9
  37. package/package.json +1 -1
@@ -134,11 +134,13 @@ export class UsageOverlayComponent {
134
134
  const th = this.theme;
135
135
  const lines: string[] = [];
136
136
 
137
- if (error) {
137
+ if (error && !summary) {
138
138
  lines.push(boxTop(this.width));
139
139
  lines.push(this.getUsageHeaderRow());
140
140
  lines.push(emptyRow(this.width));
141
- lines.push(row(th.fg('error', error), this.width));
141
+ for (const errorLine of error.split('\n')) {
142
+ lines.push(row(th.fg('error', errorLine), this.width));
143
+ }
142
144
  if (cachedMinutesAgo !== null) {
143
145
  lines.push(
144
146
  row(th.fg('dim', `(last successful fetch: ${cachedMinutesAgo}m ago)`), this.width),
@@ -164,6 +166,19 @@ export class UsageOverlayComponent {
164
166
  lines.push(this.getUsageHeaderRow());
165
167
  lines.push(emptyRow(this.width));
166
168
 
169
+ if (error) {
170
+ lines.push(row(th.fg('warning', ' Stale usage data - refresh failed'), this.width));
171
+ for (const errorLine of error.split('\n')) {
172
+ lines.push(row(th.fg('dim', ` ${errorLine}`), this.width));
173
+ }
174
+ if (cachedMinutesAgo !== null) {
175
+ lines.push(
176
+ row(th.fg('dim', ` Last successful fetch: ${cachedMinutesAgo}m ago`), this.width),
177
+ );
178
+ }
179
+ lines.push(emptyRow(this.width));
180
+ }
181
+
167
182
  // Month row: amount stays with label, cap percentage right-aligned
168
183
  const monthLeftBase = ` Month $${fmt(summary.month)} / $${fmt(summary.cap)}`;
169
184
  const monthPercent = summary.cap > 0 ? Math.round((summary.month / summary.cap) * 100) : 0;
@@ -190,12 +205,8 @@ export class UsageOverlayComponent {
190
205
  }
191
206
  lines.push(rowRightAligned(weekLeftBase, weekRight + ' ', this.width));
192
207
 
193
- // Today row on its own line - shows tilde since from local logs
194
- const todayReqStr =
195
- summary.local.requests > 0 ? ` · ${fmtCount(summary.local.requests)} reqs` : '';
196
- lines.push(
197
- rowRightAligned(` Today ~$${fmt(summary.local.cost)}${todayReqStr}`, ' ', this.width),
198
- );
208
+ // Today row on its own line - shows tilde since it may include local tracked usage
209
+ lines.push(rowRightAligned(` Today ~$${fmt(summary.today)}`, ' ', this.width));
199
210
  lines.push(emptyRow(this.width));
200
211
 
201
212
  // Top models (7d table)
@@ -0,0 +1,110 @@
1
+ import crypto from 'node:crypto';
2
+ import { formatSessionId } from './session.js';
3
+
4
+ /**
5
+ * Session state manager for OpenRouter session IDs.
6
+ * Ensures stable session IDs within a Pi session and proper reset across sessions.
7
+ */
8
+ export interface SessionState {
9
+ /**
10
+ * Get the current session ID, creating one if needed.
11
+ * The ID is cached and stable for the lifetime of this SessionState instance.
12
+ */
13
+ getCurrentSessionId(ctx: { sessionManager: { getSessionId(): string } }): string;
14
+
15
+ /**
16
+ * Handle session_start lifecycle event.
17
+ * Checks if the raw session ID has changed and updates state accordingly.
18
+ * - If raw ID differs from cached: reset and format new ID
19
+ * - If raw ID is same: preserve cached formatted ID
20
+ * - If raw ID is empty/throws: clear state for fresh fallback
21
+ */
22
+ startSession(ctx: { sessionManager: { getSessionId(): string } }): void;
23
+
24
+ /**
25
+ * Reset the cached session ID. Call this on session_shutdown
26
+ * to ensure fresh IDs for new sessions.
27
+ */
28
+ reset(): void;
29
+
30
+ /**
31
+ * Peek at the current cached session ID without initializing one.
32
+ * Returns null if no session ID has been cached yet.
33
+ */
34
+ peek(): string | null;
35
+ }
36
+
37
+ class SessionStateImpl implements SessionState {
38
+ private cachedSessionId: string | null = null;
39
+ private cachedRawSessionId: string | null = null;
40
+
41
+ getCurrentSessionId(ctx: { sessionManager: { getSessionId(): string } }): string {
42
+ if (this.cachedSessionId) {
43
+ return this.cachedSessionId;
44
+ }
45
+
46
+ try {
47
+ const sessionId = ctx.sessionManager.getSessionId();
48
+ let formattedSessionId: string;
49
+
50
+ if (sessionId && sessionId !== '') {
51
+ formattedSessionId = formatSessionId(sessionId);
52
+ this.cachedRawSessionId = sessionId;
53
+ } else {
54
+ // Empty session ID: generate fallback UUID
55
+ formattedSessionId = formatSessionId(crypto.randomUUID());
56
+ this.cachedRawSessionId = null;
57
+ }
58
+
59
+ this.cachedSessionId = formattedSessionId;
60
+ return formattedSessionId;
61
+ } catch {
62
+ // Session manager threw error: generate fallback UUID
63
+ const fallbackId = formatSessionId(crypto.randomUUID());
64
+ this.cachedSessionId = fallbackId;
65
+ this.cachedRawSessionId = null;
66
+ return fallbackId;
67
+ }
68
+ }
69
+
70
+ startSession(ctx: { sessionManager: { getSessionId(): string } }): void {
71
+ try {
72
+ const rawSessionId = ctx.sessionManager.getSessionId();
73
+
74
+ if (rawSessionId && rawSessionId !== '') {
75
+ // Non-empty session ID from manager
76
+ if (this.cachedRawSessionId !== rawSessionId) {
77
+ // Raw session ID changed - reset and format new ID
78
+ this.cachedRawSessionId = rawSessionId;
79
+ this.cachedSessionId = formatSessionId(rawSessionId);
80
+ }
81
+ // else: same raw ID, preserve cached formatted ID
82
+ } else {
83
+ // Empty session ID - clear state for fresh fallback
84
+ this.cachedSessionId = null;
85
+ this.cachedRawSessionId = null;
86
+ }
87
+ } catch {
88
+ // Session manager threw - clear state for fresh fallback
89
+ this.cachedSessionId = null;
90
+ this.cachedRawSessionId = null;
91
+ }
92
+ }
93
+
94
+ reset(): void {
95
+ this.cachedSessionId = null;
96
+ this.cachedRawSessionId = null;
97
+ }
98
+
99
+ peek(): string | null {
100
+ return this.cachedSessionId;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Create a new SessionState instance.
106
+ * Each Pi session should get its own instance.
107
+ */
108
+ export function createSessionState(): SessionState {
109
+ return new SessionStateImpl();
110
+ }
@@ -23,6 +23,22 @@ export function formatSessionId(sessionId: string): string {
23
23
  // Detection Logic
24
24
  // =============================================================================
25
25
 
26
+ /**
27
+ * Detect whether a provider request should be treated as an OpenRouter request.
28
+ *
29
+ * We intentionally use several overlapping signals because Pi events do not always
30
+ * expose provider metadata in the same place or at the same lifecycle stage.
31
+ * Different integrations can identify OpenRouter by:
32
+ * - provider name (`event.provider` or `event.payload.provider`) when Pi resolves it directly
33
+ * - model prefix (`openrouter/...`) when the request payload keeps the routed model id
34
+ * - `context.model.baseUrl` when the active model config points at OpenRouter
35
+ * - `provider.zdr === true` for Shopify's ZDR path that still routes through OpenRouter
36
+ * - request URL / endpoint as a last fallback for events that only expose transport details
37
+ *
38
+ * The checks stay intentionally redundant so session tagging and usage tracking keep
39
+ * working across request-time and `turn_end`-style event shapes without depending on
40
+ * a single field being present.
41
+ */
26
42
  export function isOpenRouterRequest(event: BeforeProviderRequestEvent, _ctx: unknown): boolean {
27
43
  const ev = event as unknown as Record<string, unknown>;
28
44
  const payload = ev['payload'] as Record<string, unknown> | undefined;
@@ -89,15 +89,34 @@ export interface UsageAggregate {
89
89
  cost: number;
90
90
  }
91
91
 
92
- export const ZERO_AGGREGATE: UsageAggregate = {
93
- requests: 0,
94
- promptTokens: 0,
95
- completionTokens: 0,
96
- reasoningTokens: 0,
97
- cacheReadTokens: 0,
98
- cacheWriteTokens: 0,
99
- cost: 0,
100
- };
92
+ export function createZeroAggregate(): UsageAggregate {
93
+ return {
94
+ requests: 0,
95
+ promptTokens: 0,
96
+ completionTokens: 0,
97
+ reasoningTokens: 0,
98
+ cacheReadTokens: 0,
99
+ cacheWriteTokens: 0,
100
+ cost: 0,
101
+ };
102
+ }
103
+
104
+ export function combineUsageAggregates(
105
+ official: UsageAggregate,
106
+ local: UsageAggregate,
107
+ ): UsageAggregate {
108
+ return {
109
+ requests: official.requests + local.requests,
110
+ promptTokens: official.promptTokens + local.promptTokens,
111
+ completionTokens: official.completionTokens + local.completionTokens,
112
+ reasoningTokens: official.reasoningTokens + local.reasoningTokens,
113
+ cacheReadTokens: official.cacheReadTokens + local.cacheReadTokens,
114
+ cacheWriteTokens: official.cacheWriteTokens + local.cacheWriteTokens,
115
+ cost: official.cost + local.cost,
116
+ };
117
+ }
118
+
119
+ export const ZERO_AGGREGATE: Readonly<UsageAggregate> = Object.freeze(createZeroAggregate());
101
120
 
102
121
  export interface CacheEntry<T> {
103
122
  data: T;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robhowley/pi-openrouter",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "Live OpenRouter spend/account TUI overlays, user-scoped model sync, and session tagging for Pi.",
6
6
  "license": "MIT",