@robhowley/pi-openrouter 0.3.0 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-openrouter
2
2
 
3
- A [Pi](https://pi.dev/) extension that adds an `/openrouter-usage` command for viewing OpenRouter spend, caps, burn rate, and model breakdowns in a terminal overlay.
3
+ A [Pi](https://pi.dev/) extension for OpenRouter usage and session visibility, with an `/openrouter-usage` terminal overlay for spend, caps, burn rate, and model breakdowns, plus automatic `session_id` tagging for dashboard grouping.
4
4
 
5
5
  ## Installation
6
6
 
@@ -35,11 +35,26 @@ The extension refreshes data in the background every 30 seconds (with exponentia
35
35
 
36
36
  Press `q`, `Esc`, or `Ctrl+C` to close the overlay.
37
37
 
38
- ## Features
38
+ ## Session tracking
39
39
 
40
- - Ephemeral TUI overlay doesn't clutter chat history
41
- - Auto-refreshing cache — data stays fresh without repeated API calls
42
- - Graceful degradation works with API key only (no model breakdowns)
40
+ `pi-openrouter` automatically tags OpenRouter requests with `session_id` field set to the Pi session's ID.
41
+
42
+ Can view the Pi session ID with
43
+
44
+ ```bash
45
+ /session # [uuid]
46
+ ```
47
+
48
+ The session can be tracked in OpenRouter's logs under the following ID:
49
+
50
+ ```bash
51
+ /openrouter-session
52
+
53
+ # OpenRouter session_id
54
+ pi:[uuid]
55
+ ```
56
+
57
+ This feature allows for session level tracking the OpenRouter → Logs → Sessions page. It does not use Pi session names, local paths, repo names, branches, or other local identifiers.
43
58
 
44
59
  ## License
45
60
 
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isOpenRouterRequest, formatSessionId } from '../session.js';
3
+
4
+ // =============================================================================
5
+ // Session ID Formatting Tests
6
+ // =============================================================================
7
+
8
+ describe('formatSessionId', () => {
9
+ it('adds pi: prefix if missing', () => {
10
+ expect(formatSessionId('abc123')).toBe('pi:abc123');
11
+ });
12
+
13
+ it('does not add duplicate pi: prefix', () => {
14
+ expect(formatSessionId('pi:abc123')).toBe('pi:abc123');
15
+ });
16
+ });
17
+
18
+ // =============================================================================
19
+ // Request Detection Tests
20
+ // =============================================================================
21
+
22
+ // Helper to create mock event
23
+ function createEvent(
24
+ payload: Record<string, unknown>,
25
+ url?: string,
26
+ provider?: Record<string, unknown>,
27
+ ) {
28
+ const event: any = { payload };
29
+ if (url) event.url = url;
30
+ if (provider) event.provider = provider;
31
+ return event;
32
+ }
33
+
34
+ // Helper to create mock context
35
+ function createContext(model: string | Record<string, unknown>) {
36
+ return { model } as any;
37
+ }
38
+
39
+ describe('isOpenRouterRequest', () => {
40
+ // Method 1: Check model string (e.g., "openrouter/anthropic/claude-3.5-sonnet")
41
+ it('detects OpenRouter by model prefix', () => {
42
+ const event = createEvent({ model: 'openrouter/anthropic/claude-sonnet-4' });
43
+ expect(isOpenRouterRequest(event, {})).toBe(true);
44
+ });
45
+
46
+ it('does not detect non-OpenRouter by model prefix', () => {
47
+ const event = createEvent({ model: 'anthropic/claude-sonnet-4' });
48
+ expect(isOpenRouterRequest(event, {})).toBe(false);
49
+ });
50
+
51
+ // Method 2: Check baseUrl from context.model
52
+ it('detects OpenRouter by baseUrl', () => {
53
+ const event = createEvent({ model: 'qwen/qwen3-coder-next' });
54
+ const ctx = createContext({ baseUrl: 'https://openrouter.ai/api/v1' });
55
+ expect(isOpenRouterRequest(event, ctx)).toBe(true);
56
+ });
57
+
58
+ it('does not detect non-OpenRouter by baseUrl', () => {
59
+ const event = createEvent({ model: 'qwen/qwen3-coder-next' });
60
+ const ctx = createContext({ baseUrl: 'https://api.anthropic.com' });
61
+ expect(isOpenRouterRequest(event, ctx)).toBe(false);
62
+ });
63
+
64
+ // Method 3: Check for ZDR provider (Shopify routes to OpenRouter via ZDR)
65
+ it('detects OpenRouter by ZDR provider', () => {
66
+ const event = createEvent({ model: 'qwen/qwen3-coder-next' }, undefined, { zdr: true });
67
+ expect(isOpenRouterRequest(event, {})).toBe(true);
68
+ });
69
+
70
+ it('does not detect non-ZDR provider', () => {
71
+ const event = createEvent({ model: 'qwen/qwen3-coder-next', provider: 'openrouter' });
72
+ expect(isOpenRouterRequest(event, {})).toBe(false);
73
+ });
74
+
75
+ // Method 4: Check URL
76
+ it('detects OpenRouter by URL', () => {
77
+ const event = createEvent(
78
+ { model: 'anthropic/claude-sonnet_4', messages: [] },
79
+ 'https://openrouter.ai/api/v1/chat/completions',
80
+ );
81
+ expect(isOpenRouterRequest(event, {})).toBe(true);
82
+ });
83
+
84
+ it('does not detect non-OpenRouter by URL', () => {
85
+ const event = createEvent(
86
+ { model: 'anthropic/claude-sonnet_4', messages: [] },
87
+ 'https://api.anthropic.com/v1/messages',
88
+ );
89
+ expect(isOpenRouterRequest(event, {})).toBe(false);
90
+ });
91
+
92
+ // Combined methods
93
+ it('detects by multiple methods simultaneously', () => {
94
+ const event = createEvent(
95
+ { model: 'openrouter/anthropic/claude-sonnet-4' },
96
+ 'https://openrouter.ai/api/v1/chat/completions',
97
+ );
98
+ const ctx = createContext({ baseUrl: 'https://openrouter.ai/api/v1' });
99
+ expect(isOpenRouterRequest(event, ctx)).toBe(true);
100
+ });
101
+ });
@@ -68,7 +68,7 @@ export async function fetchAndAggregate(): Promise<UsageSummary | null> {
68
68
  } catch (err) {
69
69
  // getActivity() requires a management key; suppress this expected error
70
70
  if (!(err instanceof Error) || !err.message.includes('management key')) {
71
- console.log('Activity fetch failed:', err);
71
+ console.log('Activity fetch failed');
72
72
  }
73
73
  hasActivityData = false;
74
74
  }
@@ -94,9 +94,9 @@ function scheduleRefresh(): void {
94
94
  scheduleRefresh();
95
95
  }
96
96
  }
97
- } catch (err) {
97
+ } catch {
98
98
  consecutiveFailures++;
99
- console.log(`Background refresh failed (${consecutiveFailures}/${MAX_RETRY_COUNT}):`, err);
99
+ console.log(`Background refresh failed (${consecutiveFailures}/${MAX_RETRY_COUNT})`);
100
100
 
101
101
  // Stop after max retries reached
102
102
  if (consecutiveFailures >= MAX_RETRY_COUNT) {
@@ -8,11 +8,76 @@ import {
8
8
  } from './cache.js';
9
9
  import { AuthError } from './client.js';
10
10
  import { UsageOverlayComponent } from './overlay.js';
11
+ import { formatSessionId, isOpenRouterRequest, type OpenRouterSessionState } from './session.js';
12
+ import crypto from 'node:crypto';
13
+
14
+ // Store the current session state for use in command handlers
15
+ let currentSessionState: OpenRouterSessionState | null = null;
16
+ let sessionTrackingInstalled = false;
17
+
18
+ // =============================================================================
19
+ // Session State Management
20
+ // =============================================================================
21
+
22
+ function getCurrentSessionId(ctx: { sessionManager: { getSessionId(): string } }): string {
23
+ if (currentSessionState) {
24
+ return currentSessionState.sessionId;
25
+ }
26
+
27
+ try {
28
+ const sessionId = ctx.sessionManager.getSessionId();
29
+ let formattedSessionId: string;
30
+ if (sessionId && sessionId !== '') {
31
+ formattedSessionId = formatSessionId(sessionId);
32
+ } else {
33
+ formattedSessionId = formatSessionId(crypto.randomUUID());
34
+ }
35
+
36
+ currentSessionState = { sessionId: formattedSessionId };
37
+ return formattedSessionId;
38
+ } catch {
39
+ // Generate fallback on any error
40
+ const fallbackId = formatSessionId(crypto.randomUUID());
41
+ currentSessionState = { sessionId: fallbackId };
42
+ return fallbackId;
43
+ }
44
+ }
11
45
 
12
46
  export default function (pi: ExtensionAPI) {
13
- pi.on('session_start', async (_event, ctx) => {
14
- ctx.ui.notify('OpenRouter extension loaded', 'info');
15
- });
47
+ // Install before_provider_request hook once
48
+ if (!sessionTrackingInstalled) {
49
+ sessionTrackingInstalled = true;
50
+ pi.on('before_provider_request', (event, ctx) => {
51
+ try {
52
+ // Validate the payload exists
53
+ const ev = event as unknown as Record<string, unknown>;
54
+ const payload = ev['payload'] as Record<string, unknown> | undefined;
55
+ if (!payload) {
56
+ return;
57
+ }
58
+
59
+ // Check if this is an OpenRouter request
60
+ const isOpenRouter = isOpenRouterRequest(event, ctx);
61
+ if (!isOpenRouter) {
62
+ return;
63
+ }
64
+
65
+ // Do not overwrite existing session_id
66
+ if ('session_id' in payload && payload['session_id'] !== undefined) {
67
+ return;
68
+ }
69
+
70
+ // Add session_id to the payload (OpenRouter-specific field)
71
+ return {
72
+ ...payload,
73
+ session_id: getCurrentSessionId(ctx),
74
+ };
75
+ } catch {
76
+ // Fail open - silently ignore errors
77
+ return;
78
+ }
79
+ });
80
+ }
16
81
 
17
82
  pi.on('session_shutdown', () => {
18
83
  stopBackgroundRefresh();
@@ -22,11 +87,20 @@ export default function (pi: ExtensionAPI) {
22
87
  description: 'Show OpenRouter usage: caps, spend, burn rate, and model breakdowns',
23
88
  getArgumentCompletions: () => null,
24
89
  handler: async (args, ctx) => {
25
- startBackgroundRefresh(); // Start cache refresh on first use
90
+ startBackgroundRefresh();
26
91
  const subcommand = args.trim() || undefined;
27
92
  await showUsageOverlay(ctx, subcommand);
28
93
  },
29
94
  });
95
+
96
+ pi.registerCommand('openrouter-session', {
97
+ description: 'Show the current OpenRouter session ID for request grouping',
98
+ getArgumentCompletions: () => null,
99
+ handler: async (_args, ctx) => {
100
+ const idToShow = getCurrentSessionId(ctx);
101
+ ctx.ui.notify(`OpenRouter session_id\n${idToShow}`, 'info');
102
+ },
103
+ });
30
104
  }
31
105
 
32
106
  async function showUsageOverlay(ctx: ExtensionContext, _subcommand?: string) {
@@ -0,0 +1,60 @@
1
+ import type { BeforeProviderRequestEvent } from '@mariozechner/pi-coding-agent';
2
+
3
+ // =============================================================================
4
+ // Types & State
5
+ // =============================================================================
6
+
7
+ export type OpenRouterSessionState = {
8
+ sessionId: string;
9
+ };
10
+
11
+ // =============================================================================
12
+ // State Factory
13
+ // =============================================================================
14
+
15
+ export function formatSessionId(sessionId: string): string {
16
+ if (sessionId.startsWith('pi:')) {
17
+ return sessionId;
18
+ }
19
+ return `pi:${sessionId}`;
20
+ }
21
+
22
+ // =============================================================================
23
+ // Detection Logic
24
+ // =============================================================================
25
+
26
+ export function isOpenRouterRequest(event: BeforeProviderRequestEvent, _ctx: unknown): boolean {
27
+ const ev = event as unknown as Record<string, unknown>;
28
+
29
+ // Method 1: Check model string (e.g., "openrouter/anthropic/claude-3.5-sonnet")
30
+ const payload = ev['payload'] as Record<string, unknown> | undefined;
31
+ const model = String(payload?.['model'] ?? '');
32
+ if (model.includes('openrouter/')) {
33
+ return true;
34
+ }
35
+
36
+ // Method 2: Check baseUrl from context.model
37
+ // OpenRouter models have baseUrl starting with https://openrouter.ai/api/v1
38
+ const context = _ctx as Record<string, unknown>;
39
+ const ctxModel = context['model'] as Record<string, unknown> | undefined;
40
+ const baseUrl = ctxModel?.['baseUrl'] as string | undefined;
41
+ if (baseUrl?.includes('openrouter.ai')) {
42
+ return true;
43
+ }
44
+
45
+ // Method 3: Check for ZDR provider (Shopify routes to OpenRouter via ZDR)
46
+ const provider = ev['provider'] as Record<string, unknown> | undefined;
47
+ if (provider?.['zdr'] === true) {
48
+ return true;
49
+ }
50
+
51
+ // Method 4: Check URL
52
+ const url = String(
53
+ (ev['url'] as string | undefined) ?? (ev['endpoint'] as string | undefined) ?? '',
54
+ );
55
+ if (url.includes('openrouter.ai')) {
56
+ return true;
57
+ }
58
+
59
+ return false;
60
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@robhowley/pi-openrouter",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
- "description": "Terminal overlay for OpenRouter usage: caps, spend, burn rate, and model breakdowns.",
5
+ "description": "OpenRouter usage TUI overlay with caps, spend, burn rate, models, and session_id tracking.",
6
6
  "files": [
7
7
  "extensions",
8
8
  "README.md",
@@ -19,7 +19,7 @@
19
19
  "extensions": [
20
20
  "./extensions/openrouter"
21
21
  ],
22
- "image": "https://raw.githubusercontent.com/roberthowley/pi-userland/main/packages/pi-openrouter/img/openrouter-usage-tui.png"
22
+ "image": "https://raw.githubusercontent.com/robhowley/pi-userland/main/packages/pi-openrouter/img/openrouter-usage-tui.png"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",