@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.
- package/README.md +87 -4
- package/extensions/openrouter/__tests__/cache.test.ts +769 -0
- package/extensions/openrouter/__tests__/client.test.ts +333 -15
- package/extensions/openrouter/__tests__/commands.test.ts +816 -0
- package/extensions/openrouter/__tests__/fixtures.ts +140 -1
- package/extensions/openrouter/__tests__/format.test.ts +19 -0
- package/extensions/openrouter/__tests__/hooks.test.ts +276 -0
- package/extensions/openrouter/__tests__/index.test.ts +163 -0
- package/extensions/openrouter/__tests__/local-usage.test.ts +777 -0
- package/extensions/openrouter/__tests__/normalizers.test.ts +288 -0
- package/extensions/openrouter/__tests__/overlay.test.ts +225 -0
- package/extensions/openrouter/__tests__/session-state.test.ts +233 -0
- package/extensions/openrouter/__tests__/session.test.ts +44 -43
- package/extensions/openrouter/account-client.ts +11 -61
- package/extensions/openrouter/cache.ts +203 -91
- package/extensions/openrouter/client.ts +49 -3
- package/extensions/openrouter/commands.ts +555 -0
- package/extensions/openrouter/format.ts +7 -4
- package/extensions/openrouter/hooks.ts +229 -0
- package/extensions/openrouter/index.ts +13 -589
- package/extensions/openrouter/local-usage.ts +145 -22
- package/extensions/openrouter/models/__tests__/cache.test.ts +63 -2
- package/extensions/openrouter/models/__tests__/mapper-overrides.test.ts +102 -0
- package/extensions/openrouter/models/__tests__/mapper.test.ts +29 -0
- package/extensions/openrouter/models/__tests__/override-commands.test.ts +668 -0
- package/extensions/openrouter/models/__tests__/overrides.test.ts +237 -0
- package/extensions/openrouter/models/__tests__/sync.test.ts +156 -4
- package/extensions/openrouter/models/cache.ts +27 -2
- package/extensions/openrouter/models/mapper.ts +60 -77
- package/extensions/openrouter/models/override-commands.ts +434 -0
- package/extensions/openrouter/models/overrides.ts +174 -0
- package/extensions/openrouter/models/skip-hints.ts +19 -0
- package/extensions/openrouter/models/sync.ts +22 -10
- package/extensions/openrouter/models/types.ts +31 -1
- package/extensions/openrouter/normalizers.ts +128 -0
- package/extensions/openrouter/overlay.ts +19 -8
- package/extensions/openrouter/session-state.ts +110 -0
- package/extensions/openrouter/session.ts +16 -0
- package/extensions/openrouter/types.ts +28 -9
- 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
|
|
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
|
|
67
|
-
*
|
|
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 =
|
|
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,
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
194
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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;
|