@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,174 @@
1
+ /**
2
+ * User model overrides persistence
3
+ *
4
+ * Manages ~/.pi/openrouter/model-overrides.json for user-defined
5
+ * PiModelConfig field overrides.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+ import type { UserModelOverride, ModelOverridesFile, ThinkingLevelMap } from './types.js';
13
+
14
+ export class ModelOverridesLoadError extends Error {
15
+ constructor(message: string) {
16
+ super(message);
17
+ this.name = 'ModelOverridesLoadError';
18
+ }
19
+ }
20
+
21
+ function getErrorMessage(error: unknown): string {
22
+ return error instanceof Error ? error.message : String(error);
23
+ }
24
+
25
+ /**
26
+ * Get the path to the overrides file.
27
+ * Computed lazily to allow for test mocking.
28
+ */
29
+ function getOverridesFile(): string {
30
+ return join(homedir(), '.pi', 'openrouter', 'model-overrides.json');
31
+ }
32
+
33
+ /**
34
+ * Load model overrides from disk.
35
+ * Returns empty structure only when the file doesn't exist.
36
+ * Throws when an existing file cannot be read, parsed, or validated.
37
+ */
38
+ export async function loadModelOverrides(): Promise<ModelOverridesFile> {
39
+ const overridesFile = getOverridesFile();
40
+ if (!existsSync(overridesFile)) {
41
+ return { version: 1, overrides: {} };
42
+ }
43
+
44
+ let content: string;
45
+ try {
46
+ content = await readFile(overridesFile, 'utf-8');
47
+ } catch (error) {
48
+ throw new ModelOverridesLoadError(
49
+ `Failed to read model overrides file at ${overridesFile}: ${getErrorMessage(error)}`,
50
+ );
51
+ }
52
+
53
+ let data: unknown;
54
+ try {
55
+ data = JSON.parse(content) as unknown;
56
+ } catch (error) {
57
+ throw new ModelOverridesLoadError(
58
+ `Invalid JSON in model overrides file at ${overridesFile}: ${getErrorMessage(error)}`,
59
+ );
60
+ }
61
+
62
+ // Basic validation
63
+ if (
64
+ typeof data === 'object' &&
65
+ data !== null &&
66
+ 'version' in data &&
67
+ typeof (data as { version: unknown }).version === 'number' &&
68
+ 'overrides' in data &&
69
+ typeof (data as { overrides: unknown }).overrides === 'object' &&
70
+ (data as { overrides: unknown }).overrides !== null
71
+ ) {
72
+ return data as ModelOverridesFile;
73
+ }
74
+
75
+ throw new ModelOverridesLoadError(`Invalid model overrides file structure at ${overridesFile}`);
76
+ }
77
+
78
+ /**
79
+ * Save model overrides to disk.
80
+ */
81
+ export async function saveModelOverrides(overrides: ModelOverridesFile): Promise<void> {
82
+ const overridesFile = getOverridesFile();
83
+ const overridesDir = join(homedir(), '.pi', 'openrouter');
84
+
85
+ // Ensure directory exists
86
+ if (!existsSync(overridesDir)) {
87
+ await mkdir(overridesDir, { recursive: true });
88
+ }
89
+
90
+ const content = JSON.stringify(overrides, null, 2);
91
+ await writeFile(overridesFile, content, 'utf-8');
92
+ }
93
+
94
+ /**
95
+ * Get existing override for a specific model.
96
+ */
97
+ export function getModelOverride(
98
+ overrides: ModelOverridesFile,
99
+ modelId: string,
100
+ ): UserModelOverride | undefined {
101
+ return overrides.overrides[modelId];
102
+ }
103
+
104
+ /**
105
+ * Set override for a specific model.
106
+ * Merges with existing override.
107
+ */
108
+ export function setModelOverride(
109
+ overrides: ModelOverridesFile,
110
+ modelId: string,
111
+ override: UserModelOverride,
112
+ ): ModelOverridesFile {
113
+ const existing = overrides.overrides[modelId] ?? {};
114
+ const mergedThinkingLevelMap =
115
+ existing.thinkingLevelMap !== undefined || override.thinkingLevelMap !== undefined
116
+ ? {
117
+ ...existing.thinkingLevelMap,
118
+ ...override.thinkingLevelMap,
119
+ }
120
+ : undefined;
121
+
122
+ const merged: UserModelOverride = {
123
+ ...existing,
124
+ ...override,
125
+ };
126
+
127
+ if (mergedThinkingLevelMap !== undefined) {
128
+ const cleaned = Object.fromEntries(
129
+ Object.entries(mergedThinkingLevelMap).filter(([, v]) => v !== undefined),
130
+ );
131
+ if (Object.keys(cleaned).length > 0) {
132
+ merged.thinkingLevelMap = cleaned as Partial<ThinkingLevelMap>;
133
+ } else {
134
+ delete merged.thinkingLevelMap;
135
+ }
136
+ }
137
+
138
+ return {
139
+ ...overrides,
140
+ overrides: {
141
+ ...overrides.overrides,
142
+ [modelId]: merged,
143
+ },
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Remove override for a specific model.
149
+ */
150
+ export function removeModelOverride(
151
+ overrides: ModelOverridesFile,
152
+ modelId: string,
153
+ ): ModelOverridesFile {
154
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
155
+ const { [modelId]: _, ...rest } = overrides.overrides;
156
+ return {
157
+ ...overrides,
158
+ overrides: rest,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Get all model IDs that have overrides.
164
+ */
165
+ export function getOverrideModelIds(overrides: ModelOverridesFile): string[] {
166
+ return Object.keys(overrides.overrides);
167
+ }
168
+
169
+ /**
170
+ * Check if any overrides exist.
171
+ */
172
+ export function hasOverrides(overrides: ModelOverridesFile): boolean {
173
+ return Object.keys(overrides.overrides).length > 0;
174
+ }
@@ -0,0 +1,19 @@
1
+ const SKIP_REASON_HINTS: Record<string, string> = {
2
+ 'missing context window':
3
+ "Add a local contextWindow override with '/openrouter model-override-set <model-id> contextWindow=<tokens>' if the model's limit is known.",
4
+ 'missing max tokens':
5
+ "Add a local maxTokens override with '/openrouter model-override-set <model-id> maxTokens=<tokens>' if the model's completion limit is known.",
6
+ 'missing prompt pricing':
7
+ 'OpenRouter did not provide complete pricing metadata, so Pi cannot map model cost safely.',
8
+ 'missing completion pricing':
9
+ 'OpenRouter did not provide complete pricing metadata, so Pi cannot map model cost safely.',
10
+ 'non-text output modalities':
11
+ 'This sync only registers models that advertise text/chat output, so non-text-only models are skipped.',
12
+ };
13
+
14
+ /**
15
+ * Return an optional human-readable hint for a stable machine-readable skip reason.
16
+ */
17
+ export function getSkipReasonHint(reason: string): string | undefined {
18
+ return SKIP_REASON_HINTS[reason];
19
+ }
@@ -4,7 +4,8 @@
4
4
  */
5
5
 
6
6
  import { fetchUserModels } from '../client.js';
7
- import { mapOpenRouterModels, sdkModelToOpenRouterModel } from './mapper.js';
7
+ import { mapOpenRouterModels } from './mapper.js';
8
+ import { sdkModelToOpenRouterModel } from '../normalizers.js';
8
9
  import { loadCache, saveCache } from './cache.js';
9
10
  import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
10
11
  import type {
@@ -63,15 +64,13 @@ export function getSyncState(): SyncResult | null {
63
64
  /**
64
65
  * Register mapped models with Pi's OpenRouter provider.
65
66
  *
66
- * Uses modelRegistry.registerProvider() to add models to the built-in openrouter provider.
67
- * The models array replaces all existing models for the provider.
67
+ * Uses modelRegistry.registerProvider() to replace the provider's model list with the synced
68
+ * user-scoped catalog plus the built-in router aliases that do not appear in /models/user.
68
69
  */
69
70
  export async function registerModelsWithProvider(
70
71
  ctx: ExtensionContext,
71
72
  configs: PiModelConfig[],
72
73
  ): Promise<void> {
73
- // Register models with Pi's OpenRouter provider
74
- // This replaces all existing models for the provider with our synced ones
75
74
  ctx.modelRegistry.registerProvider('openrouter', {
76
75
  baseUrl: 'https://openrouter.ai/api/v1',
77
76
  apiKey: 'OPENROUTER_API_KEY',
@@ -96,6 +95,18 @@ const BUILTIN_ROUTER_MODELS: PiModelConfig[] = ROUTER_DEFINITIONS.map((r) => ({
96
95
  maxTokens: r.maxTokens,
97
96
  }));
98
97
 
98
+ /**
99
+ * Add built-in router aliases exactly once to a synced user catalog.
100
+ *
101
+ * The OpenRouter provider registration replaces the built-in list, so router aliases are the
102
+ * only built-ins we intentionally preserve in this cleanup pass.
103
+ */
104
+ export function includeBuiltinRouterModels(configs: PiModelConfig[]): PiModelConfig[] {
105
+ const seen = new Set(configs.map((config) => config.id));
106
+ const routersToAdd = BUILTIN_ROUTER_MODELS.filter((router) => !seen.has(router.id));
107
+ return [...configs, ...routersToAdd];
108
+ }
109
+
99
110
  /**
100
111
  * Convert router definitions to OpenRouterModel format for cache storage.
101
112
  */
@@ -134,8 +145,8 @@ export async function syncModels(_ctx: ExtensionContext): Promise<SyncResult> {
134
145
  const response = await fetchUserModels();
135
146
  const { configs, skipped, skippedDetails } = await mapOpenRouterModels(response.data);
136
147
 
137
- // Add built-in router aliases that don't appear in /models/user endpoint
138
- const configsWithRouters = [...configs, ...BUILTIN_ROUTER_MODELS];
148
+ // Add built-in router aliases that don't appear in /models/user endpoint.
149
+ const configsWithRouters = includeBuiltinRouterModels(configs);
139
150
 
140
151
  // Register with Pi's OpenRouter provider
141
152
  await registerModelsWithProvider(_ctx, configsWithRouters);
@@ -173,15 +184,16 @@ export async function syncModels(_ctx: ExtensionContext): Promise<SyncResult> {
173
184
  if (cache) {
174
185
  // Attempt 2: Use cached models
175
186
  const { configs, skipped } = await mapOpenRouterModels(cache.models);
187
+ const configsWithRouters = includeBuiltinRouterModels(configs);
176
188
 
177
- await registerModelsWithProvider(_ctx, configs);
189
+ await registerModelsWithProvider(_ctx, configsWithRouters);
178
190
 
179
191
  // Use cached skip details if available
180
192
  const cachedSkipDetails = cache.skippedDetails || [];
181
193
 
182
194
  const result: SyncResult = {
183
195
  success: false,
184
- registeredCount: configs.length,
196
+ registeredCount: configsWithRouters.length,
185
197
  skippedCount: skipped,
186
198
  source: 'cache',
187
199
  cacheUpdated: false,
@@ -245,7 +257,7 @@ export async function areModelsAvailable(): Promise<boolean> {
245
257
 
246
258
  // Check cache file on disk
247
259
  const cache = await loadCache();
248
- return cache !== null && cache.models.length > 0;
260
+ return !!cache && cache.models.length > 0;
249
261
  }
250
262
 
251
263
  /**
@@ -9,7 +9,7 @@ export interface OpenRouterModel {
9
9
  output_modalities?: string[];
10
10
  };
11
11
  context_length: number;
12
- pricing: {
12
+ pricing?: {
13
13
  prompt: string; // per-token price as string
14
14
  completion: string;
15
15
  input_cache_read?: string;
@@ -94,6 +94,7 @@ export interface ModelsCache {
94
94
  export interface SkipReason {
95
95
  id: string;
96
96
  reason: string;
97
+ hint?: string;
97
98
  }
98
99
 
99
100
  /**
@@ -105,6 +106,35 @@ export interface MapResult {
105
106
  skippedDetails: SkipReason[];
106
107
  }
107
108
 
109
+ // =============================================================================
110
+ // User Override Types
111
+ // =============================================================================
112
+
113
+ /**
114
+ * User-defined override for any PiModelConfig field.
115
+ * Stored in ~/.pi/openrouter/model-overrides.json
116
+ *
117
+ * For type safety, fields must be validated before storage.
118
+ * Unknown fields are ignored during merge.
119
+ */
120
+ export interface UserModelOverride {
121
+ // Nested thinking level map - mapped from 'thinking.X' scoped syntax
122
+ thinkingLevelMap?: Partial<ThinkingLevelMap>;
123
+
124
+ // Top-level PiModelConfig overrides
125
+ contextWindow?: number;
126
+ maxTokens?: number;
127
+ reasoning?: boolean;
128
+ }
129
+
130
+ /**
131
+ * The overrides file structure.
132
+ */
133
+ export interface ModelOverridesFile {
134
+ version: number;
135
+ overrides: Record<string, UserModelOverride>;
136
+ }
137
+
108
138
  // =============================================================================
109
139
  // Built-in Router Definitions (Single Source of Truth)
110
140
  // =============================================================================
@@ -0,0 +1,128 @@
1
+ import type { Model as SDKModel } from '@openrouter/sdk/models/index.js';
2
+ import type { GetCurrentKeyData, ListData } from '@openrouter/sdk/models/operations/index.js';
3
+ import type { BYOKStatus, ResetCadence } from './account-types.js';
4
+ import type { OpenRouterModel } from './models/types.js';
5
+
6
+ export interface NormalizedKeyMetadata {
7
+ name: string;
8
+ label: string;
9
+ used: number;
10
+ resetCadence: ResetCadence;
11
+ byok: BYOKStatus;
12
+ hash: string;
13
+ disabled: boolean;
14
+ limit?: number;
15
+ remaining?: number;
16
+ }
17
+
18
+ /**
19
+ * Convert SDK Model to our canonical OpenRouterModel shape.
20
+ * This isolates SDK camelCase/null handling at the package boundary.
21
+ */
22
+ export function sdkModelToOpenRouterModel(model: SDKModel): OpenRouterModel {
23
+ const topProvider = model.topProvider
24
+ ? {
25
+ context_length: model.topProvider.contextLength ?? 0,
26
+ max_completion_tokens: model.topProvider.maxCompletionTokens ?? 0,
27
+ }
28
+ : undefined;
29
+
30
+ const perRequestLimits = model.perRequestLimits
31
+ ? {
32
+ completion_tokens: model.perRequestLimits.completionTokens ?? 0,
33
+ }
34
+ : undefined;
35
+
36
+ const architecture = model.architecture
37
+ ? {
38
+ input_modalities: model.architecture.inputModalities ?? [],
39
+ output_modalities: model.architecture.outputModalities ?? [],
40
+ }
41
+ : undefined;
42
+
43
+ const pricing = model.pricing
44
+ ? {
45
+ prompt: String(model.pricing.prompt ?? 0),
46
+ completion: String(model.pricing.completion ?? 0),
47
+ input_cache_read: String(model.pricing.inputCacheRead ?? 0),
48
+ input_cache_write: String(model.pricing.inputCacheWrite ?? 0),
49
+ }
50
+ : undefined;
51
+
52
+ const result: OpenRouterModel = {
53
+ id: model.id,
54
+ name: model.name,
55
+ context_length: model.contextLength ?? 0,
56
+ supported_parameters: model.supportedParameters,
57
+ };
58
+
59
+ if (architecture) {
60
+ result.architecture = architecture;
61
+ }
62
+ if (pricing) {
63
+ result.pricing = pricing;
64
+ }
65
+ if (topProvider) {
66
+ result.top_provider = topProvider;
67
+ }
68
+ if (perRequestLimits) {
69
+ result.per_request_limits = perRequestLimits;
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ /**
76
+ * Normalize mixed SDK/canonical model inputs into the package's canonical shape.
77
+ */
78
+ export function normalizeOpenRouterModel(model: OpenRouterModel | SDKModel): OpenRouterModel {
79
+ return 'contextLength' in model ? sdkModelToOpenRouterModel(model as SDKModel) : model;
80
+ }
81
+
82
+ /**
83
+ * Normalize SDK key metadata into the package's canonical internal shape.
84
+ * Converts SDK null/variant fields once so account code can stay domain-focused.
85
+ */
86
+ export function normalizeSdkKeyMetadata(raw: GetCurrentKeyData | ListData): NormalizedKeyMetadata {
87
+ const used = raw.usage ?? raw.usageMonthly ?? 0;
88
+ const limit = raw.limit ?? undefined;
89
+ const remaining = raw.limitRemaining ?? undefined;
90
+
91
+ let byok: BYOKStatus = '?';
92
+ if (raw.includeByokInLimit === true) {
93
+ byok = 'incl';
94
+ } else if (raw.includeByokInLimit === false) {
95
+ byok = 'excl';
96
+ }
97
+
98
+ let resetCadence: ResetCadence = 'partial';
99
+ if (raw.limitReset) {
100
+ const reset = raw.limitReset.toLowerCase();
101
+ if (reset === 'monthly') {
102
+ resetCadence = 'monthly';
103
+ } else if (reset === 'daily') {
104
+ resetCadence = 'daily';
105
+ } else if (reset === 'never') {
106
+ resetCadence = 'never';
107
+ }
108
+ }
109
+
110
+ const normalized: NormalizedKeyMetadata = {
111
+ name: 'name' in raw ? (raw as ListData).name : raw.label,
112
+ label: raw.label,
113
+ used,
114
+ resetCadence,
115
+ byok,
116
+ hash: 'hash' in raw ? (raw as ListData).hash : 'unknown',
117
+ disabled: 'disabled' in raw ? (raw as ListData).disabled : false,
118
+ };
119
+
120
+ if (limit !== undefined) {
121
+ normalized.limit = limit;
122
+ }
123
+ if (remaining !== undefined) {
124
+ normalized.remaining = remaining;
125
+ }
126
+
127
+ return normalized;
128
+ }
@@ -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;