@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
|
|
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
|
-
##
|
|
38
|
+
## Session tracking
|
|
39
39
|
|
|
40
|
-
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
|
97
|
+
} catch {
|
|
98
98
|
consecutiveFailures++;
|
|
99
|
-
console.log(`Background refresh failed (${consecutiveFailures}/${MAX_RETRY_COUNT})
|
|
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
|
-
|
|
14
|
-
|
|
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();
|
|
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
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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/
|
|
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",
|