pi-openrouter-realtime 0.1.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/LICENSE +21 -0
- package/README.md +104 -0
- package/assets/preview.png +0 -0
- package/extensions/openrouter-routing/index.ts +451 -0
- package/package.json +48 -0
- package/tsconfig.json +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 olixis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# pi-openrouter-plus
|
|
4
|
+
|
|
5
|
+
Pi extension for OpenRouter that loads the latest models from OpenRouter in real time, keeps the default model list simple, and lets you enrich a specific model with provider and quantization variants.
|
|
6
|
+
|
|
7
|
+
Npm package:
|
|
8
|
+
|
|
9
|
+
- `@corvo_prophet/pi-openrouter-plus`
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Loads the latest OpenRouter model list into pi in real time
|
|
14
|
+
- Keeps startup behavior fast by default
|
|
15
|
+
- Adds provider-specific variants on demand
|
|
16
|
+
- Adds quantization-specific variants for a chosen model
|
|
17
|
+
- Routes enriched selections through OpenRouter provider routing
|
|
18
|
+
- Enriches one model at a time to avoid slow full-catalog endpoint scans
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
### From npm
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pi install npm:@corvo_prophet/pi-openrouter-plus
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### From GitHub
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi install git:github.com/olixis/pi-openrouter-plus
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Try without installing
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pi -e npm:@corvo_prophet/pi-openrouter-plus
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
or:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pi -e git:github.com/olixis/pi-openrouter-plus
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
- `/openrouter-sync` — fetch the latest OpenRouter model list in real time
|
|
49
|
+
- `/openrouter-enrich <model-id>` — add provider and quantization variants for one specific OpenRouter model
|
|
50
|
+
|
|
51
|
+
## Example
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
/openrouter-enrich kwaipilot/kat-coder-pro-v2
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This keeps the normal OpenRouter catalog and adds variants like:
|
|
58
|
+
|
|
59
|
+
- `Kwaipilot: KAT-Coder-Pro V2 (StreamLake)`
|
|
60
|
+
- `Kwaipilot: KAT-Coder-Pro V2 (AtlasCloud · fp8)`
|
|
61
|
+
|
|
62
|
+
## Behavior
|
|
63
|
+
|
|
64
|
+
- On startup, the extension syncs the latest plain OpenRouter model list from OpenRouter
|
|
65
|
+
- Enrichment is manual and targeted to one model at a time
|
|
66
|
+
- Quantization variants are exposed as separate model choices when available
|
|
67
|
+
- Enriched variants are translated into OpenRouter provider routing fields at request time
|
|
68
|
+
- If you want to go back to the default list, run `/openrouter-sync`
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
Type-check locally:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
bunx tsc --noEmit
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
or:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx tsc --noEmit
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Test the package locally with pi:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pi -e .
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Or load the extension entry file directly:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pi -e ./extensions/openrouter-routing/index.ts
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
> ⁶ Jesus said unto him, I am the way, the truth, and the life: no man comes unto the Father, but by me.
|
|
103
|
+
>
|
|
104
|
+
> — *John 14:6*
|
|
Binary file
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import {
|
|
2
|
+
streamSimpleOpenAICompletions,
|
|
3
|
+
type AssistantMessageEventStream,
|
|
4
|
+
type Context,
|
|
5
|
+
type Model,
|
|
6
|
+
type SimpleStreamOptions,
|
|
7
|
+
} from "@mariozechner/pi-ai";
|
|
8
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
11
|
+
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
|
12
|
+
const PROVIDER_NAME = "openrouter";
|
|
13
|
+
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
14
|
+
const ENRICHED_MODEL_PREFIX = "openrouter-route:";
|
|
15
|
+
|
|
16
|
+
type InputType = "text" | "image";
|
|
17
|
+
type SyncMode = "plain" | "enriched";
|
|
18
|
+
|
|
19
|
+
interface OpenRouterPricing {
|
|
20
|
+
prompt?: string;
|
|
21
|
+
completion?: string;
|
|
22
|
+
input_cache_read?: string;
|
|
23
|
+
input_cache_write?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface OpenRouterArchitecture {
|
|
27
|
+
modality?: string;
|
|
28
|
+
input_modalities?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface OpenRouterModel {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
context_length?: number;
|
|
35
|
+
top_provider?: { max_completion_tokens?: number };
|
|
36
|
+
pricing?: OpenRouterPricing;
|
|
37
|
+
architecture?: OpenRouterArchitecture;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface OpenRouterEndpoint {
|
|
41
|
+
provider_name?: string;
|
|
42
|
+
tag?: string;
|
|
43
|
+
quantization?: string;
|
|
44
|
+
context_length?: number;
|
|
45
|
+
max_completion_tokens?: number;
|
|
46
|
+
pricing?: OpenRouterPricing;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface OpenRouterEndpointsResponse {
|
|
50
|
+
data?: {
|
|
51
|
+
endpoints?: OpenRouterEndpoint[];
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ProviderModelConfig {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
reasoning: boolean;
|
|
59
|
+
input: InputType[];
|
|
60
|
+
cost: {
|
|
61
|
+
input: number;
|
|
62
|
+
output: number;
|
|
63
|
+
cacheRead: number;
|
|
64
|
+
cacheWrite: number;
|
|
65
|
+
};
|
|
66
|
+
contextWindow: number;
|
|
67
|
+
maxTokens: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface RouteVariant {
|
|
71
|
+
syntheticId: string;
|
|
72
|
+
baseModelId: string;
|
|
73
|
+
providerSlug: string;
|
|
74
|
+
providerName: string;
|
|
75
|
+
quantization?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface EnrichedCatalog {
|
|
79
|
+
models: ProviderModelConfig[];
|
|
80
|
+
routes: Map<string, RouteVariant>;
|
|
81
|
+
variantCount: number;
|
|
82
|
+
endpointFailures: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let cachedModels: OpenRouterModel[] | null = null;
|
|
86
|
+
let cacheTimestamp = 0;
|
|
87
|
+
let endpointCache = new Map<string, { timestamp: number; endpoints: OpenRouterEndpoint[] }>();
|
|
88
|
+
let enrichedRoutes = new Map<string, RouteVariant>();
|
|
89
|
+
|
|
90
|
+
function isReasoningModel(m: OpenRouterModel): boolean {
|
|
91
|
+
const id = m.id.toLowerCase();
|
|
92
|
+
const name = (m.name || "").toLowerCase();
|
|
93
|
+
return (
|
|
94
|
+
id.includes(":thinking") ||
|
|
95
|
+
id.includes("-r1") ||
|
|
96
|
+
id.includes("/r1") ||
|
|
97
|
+
id.includes("o1-") ||
|
|
98
|
+
id.includes("o3-") ||
|
|
99
|
+
id.includes("o4-") ||
|
|
100
|
+
id.includes("reasoner") ||
|
|
101
|
+
name.includes("thinking") ||
|
|
102
|
+
name.includes("reasoner")
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function supportsImages(architecture?: OpenRouterArchitecture): boolean {
|
|
107
|
+
if (architecture?.input_modalities) {
|
|
108
|
+
return architecture.input_modalities.includes("image");
|
|
109
|
+
}
|
|
110
|
+
return architecture?.modality?.includes("multimodal") ?? false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseCost(value?: string): number {
|
|
114
|
+
const n = parseFloat(value || "0");
|
|
115
|
+
// OpenRouter: price per token → pi expects per million tokens
|
|
116
|
+
return isNaN(n) ? 0 : n * 1_000_000;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeQuantization(value?: string): string | undefined {
|
|
120
|
+
const normalized = (value || "").trim().toLowerCase();
|
|
121
|
+
if (!normalized || normalized === "unknown") return undefined;
|
|
122
|
+
return normalized;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function slugifyProvider(value?: string): string {
|
|
126
|
+
return (value || "unknown-provider")
|
|
127
|
+
.trim()
|
|
128
|
+
.toLowerCase()
|
|
129
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
130
|
+
.replace(/^-+|-+$/g, "") || "unknown-provider";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getProviderSlug(endpoint: OpenRouterEndpoint): string {
|
|
134
|
+
const fromTag = endpoint.tag?.split("/")[0]?.trim().toLowerCase();
|
|
135
|
+
return fromTag || slugifyProvider(endpoint.provider_name);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function minPositive(values: Array<number | undefined>, fallback: number): number {
|
|
139
|
+
const filtered = values.filter((value): value is number => typeof value === "number" && value > 0);
|
|
140
|
+
return filtered.length > 0 ? Math.min(...filtered) : fallback;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function maxValue(values: Array<number | undefined>, fallback: number): number {
|
|
144
|
+
const filtered = values.filter((value): value is number => typeof value === "number" && value >= 0);
|
|
145
|
+
return filtered.length > 0 ? Math.max(...filtered) : fallback;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function toProviderModel(m: OpenRouterModel): ProviderModelConfig {
|
|
149
|
+
return {
|
|
150
|
+
id: m.id,
|
|
151
|
+
name: m.name || m.id,
|
|
152
|
+
reasoning: isReasoningModel(m),
|
|
153
|
+
input: supportsImages(m.architecture) ? ["text", "image"] : ["text"],
|
|
154
|
+
cost: {
|
|
155
|
+
input: parseCost(m.pricing?.prompt),
|
|
156
|
+
output: parseCost(m.pricing?.completion),
|
|
157
|
+
cacheRead: parseCost(m.pricing?.input_cache_read),
|
|
158
|
+
cacheWrite: parseCost(m.pricing?.input_cache_write),
|
|
159
|
+
},
|
|
160
|
+
contextWindow: m.context_length || 128000,
|
|
161
|
+
maxTokens: m.top_provider?.max_completion_tokens || 16384,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function createVariantId(baseModelId: string, providerSlug: string, quantization?: string): string {
|
|
166
|
+
const q = quantization || "default";
|
|
167
|
+
return `${ENRICHED_MODEL_PREFIX}${baseModelId}::${providerSlug}::${q}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function createVariantName(baseName: string, providerName: string, quantization?: string): string {
|
|
171
|
+
return quantization
|
|
172
|
+
? `${baseName} (${providerName} · ${quantization})`
|
|
173
|
+
: `${baseName} (${providerName})`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resetCaches() {
|
|
177
|
+
cachedModels = null;
|
|
178
|
+
cacheTimestamp = 0;
|
|
179
|
+
endpointCache = new Map();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function fetchModels(apiKey?: string): Promise<OpenRouterModel[]> {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
if (cachedModels && now - cacheTimestamp < CACHE_TTL_MS) {
|
|
185
|
+
return cachedModels;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const headers: Record<string, string> = {};
|
|
189
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
190
|
+
|
|
191
|
+
const res = await fetch(OPENROUTER_MODELS_URL, { headers });
|
|
192
|
+
if (!res.ok) {
|
|
193
|
+
throw new Error(`OpenRouter API: ${res.status} ${res.statusText}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const json = (await res.json()) as { data?: OpenRouterModel[] };
|
|
197
|
+
cachedModels = json.data || [];
|
|
198
|
+
cacheTimestamp = now;
|
|
199
|
+
return cachedModels;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildEndpointsUrl(modelId: string): string {
|
|
203
|
+
const path = modelId
|
|
204
|
+
.split("/")
|
|
205
|
+
.map((part) => encodeURIComponent(part))
|
|
206
|
+
.join("/");
|
|
207
|
+
return `${OPENROUTER_BASE_URL}/models/${path}/endpoints`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function fetchModelEndpoints(modelId: string, apiKey?: string): Promise<OpenRouterEndpoint[]> {
|
|
211
|
+
const cached = endpointCache.get(modelId);
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
if (cached && now - cached.timestamp < CACHE_TTL_MS) {
|
|
214
|
+
return cached.endpoints;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const headers: Record<string, string> = {};
|
|
218
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
219
|
+
|
|
220
|
+
const res = await fetch(buildEndpointsUrl(modelId), { headers });
|
|
221
|
+
if (res.status === 404) {
|
|
222
|
+
endpointCache.set(modelId, { timestamp: now, endpoints: [] });
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
if (!res.ok) {
|
|
226
|
+
throw new Error(`OpenRouter endpoints API: ${res.status} ${res.statusText}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const json = (await res.json()) as OpenRouterEndpointsResponse;
|
|
230
|
+
const endpoints = json.data?.endpoints || [];
|
|
231
|
+
endpointCache.set(modelId, { timestamp: now, endpoints });
|
|
232
|
+
return endpoints;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function buildVariantModel(base: OpenRouterModel, route: RouteVariant, endpoints: OpenRouterEndpoint[]): ProviderModelConfig {
|
|
236
|
+
const fallback = toProviderModel(base);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
id: route.syntheticId,
|
|
240
|
+
name: createVariantName(fallback.name, route.providerName, route.quantization),
|
|
241
|
+
reasoning: fallback.reasoning,
|
|
242
|
+
input: fallback.input,
|
|
243
|
+
cost: {
|
|
244
|
+
input: maxValue(endpoints.map((endpoint) => parseCost(endpoint.pricing?.prompt)), fallback.cost.input),
|
|
245
|
+
output: maxValue(endpoints.map((endpoint) => parseCost(endpoint.pricing?.completion)), fallback.cost.output),
|
|
246
|
+
cacheRead: maxValue(
|
|
247
|
+
endpoints.map((endpoint) => parseCost(endpoint.pricing?.input_cache_read)),
|
|
248
|
+
fallback.cost.cacheRead,
|
|
249
|
+
),
|
|
250
|
+
cacheWrite: maxValue(
|
|
251
|
+
endpoints.map((endpoint) => parseCost(endpoint.pricing?.input_cache_write)),
|
|
252
|
+
fallback.cost.cacheWrite,
|
|
253
|
+
),
|
|
254
|
+
},
|
|
255
|
+
contextWindow: minPositive(endpoints.map((endpoint) => endpoint.context_length), fallback.contextWindow),
|
|
256
|
+
maxTokens: minPositive(endpoints.map((endpoint) => endpoint.max_completion_tokens), fallback.maxTokens),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function groupEndpoints(base: OpenRouterModel, endpoints: OpenRouterEndpoint[]): Array<{ route: RouteVariant; endpoints: OpenRouterEndpoint[] }> {
|
|
261
|
+
const groups = new Map<string, { route: RouteVariant; endpoints: OpenRouterEndpoint[] }>();
|
|
262
|
+
|
|
263
|
+
for (const endpoint of endpoints) {
|
|
264
|
+
const providerSlug = getProviderSlug(endpoint);
|
|
265
|
+
const providerName = endpoint.provider_name || providerSlug;
|
|
266
|
+
const quantization = normalizeQuantization(endpoint.quantization);
|
|
267
|
+
const syntheticId = createVariantId(base.id, providerSlug, quantization);
|
|
268
|
+
const key = `${providerSlug}::${quantization || "default"}`;
|
|
269
|
+
|
|
270
|
+
const group = groups.get(key);
|
|
271
|
+
if (group) {
|
|
272
|
+
group.endpoints.push(endpoint);
|
|
273
|
+
} else {
|
|
274
|
+
groups.set(key, {
|
|
275
|
+
route: {
|
|
276
|
+
syntheticId,
|
|
277
|
+
baseModelId: base.id,
|
|
278
|
+
providerSlug,
|
|
279
|
+
providerName,
|
|
280
|
+
quantization,
|
|
281
|
+
},
|
|
282
|
+
endpoints: [endpoint],
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return Array.from(groups.values()).sort((a, b) => {
|
|
288
|
+
const providerCompare = a.route.providerName.localeCompare(b.route.providerName);
|
|
289
|
+
if (providerCompare !== 0) return providerCompare;
|
|
290
|
+
return (a.route.quantization || "").localeCompare(b.route.quantization || "");
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function buildEnrichedCatalog(
|
|
295
|
+
models: OpenRouterModel[],
|
|
296
|
+
targetModelId: string,
|
|
297
|
+
apiKey?: string,
|
|
298
|
+
): Promise<EnrichedCatalog> {
|
|
299
|
+
const targetModel = models.find((model) => model.id === targetModelId);
|
|
300
|
+
if (!targetModel) {
|
|
301
|
+
throw new Error(`OpenRouter model not found: ${targetModelId}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const providerModels = models.map(toProviderModel);
|
|
305
|
+
const routes = new Map<string, RouteVariant>();
|
|
306
|
+
let variantCount = 0;
|
|
307
|
+
let endpointFailures = 0;
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const endpoints = await fetchModelEndpoints(targetModel.id, apiKey);
|
|
311
|
+
for (const group of groupEndpoints(targetModel, endpoints)) {
|
|
312
|
+
providerModels.push(buildVariantModel(targetModel, group.route, group.endpoints));
|
|
313
|
+
routes.set(group.route.syntheticId, group.route);
|
|
314
|
+
variantCount++;
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
endpointFailures = 1;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { models: providerModels, routes, variantCount, endpointFailures };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function streamOpenRouter(
|
|
324
|
+
model: Model<"openai-completions">,
|
|
325
|
+
context: Context,
|
|
326
|
+
options?: SimpleStreamOptions,
|
|
327
|
+
): AssistantMessageEventStream {
|
|
328
|
+
const route = enrichedRoutes.get(model.id);
|
|
329
|
+
if (!route) {
|
|
330
|
+
return streamSimpleOpenAICompletions(model, context, options);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return streamSimpleOpenAICompletions(model, context, {
|
|
334
|
+
...options,
|
|
335
|
+
onPayload: async (payload, payloadModel) => {
|
|
336
|
+
let nextPayload = payload;
|
|
337
|
+
if (options?.onPayload) {
|
|
338
|
+
const userPayload = await options.onPayload(payload, payloadModel);
|
|
339
|
+
if (userPayload !== undefined) nextPayload = userPayload;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!nextPayload || typeof nextPayload !== "object" || Array.isArray(nextPayload)) {
|
|
343
|
+
return nextPayload;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const currentProvider: Record<string, unknown> =
|
|
347
|
+
nextPayload &&
|
|
348
|
+
typeof (nextPayload as { provider?: unknown }).provider === "object" &&
|
|
349
|
+
!Array.isArray((nextPayload as { provider?: unknown }).provider)
|
|
350
|
+
? { ...((nextPayload as { provider?: Record<string, unknown> }).provider || {}) }
|
|
351
|
+
: {};
|
|
352
|
+
|
|
353
|
+
currentProvider.only = [route.providerSlug];
|
|
354
|
+
currentProvider.allow_fallbacks = false;
|
|
355
|
+
if (route.quantization) {
|
|
356
|
+
currentProvider.quantizations = [route.quantization];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
...(nextPayload as Record<string, unknown>),
|
|
361
|
+
model: route.baseModelId,
|
|
362
|
+
provider: currentProvider,
|
|
363
|
+
};
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export default function openrouterModelsExtension(pi: ExtensionAPI) {
|
|
369
|
+
async function syncModels(ctx: any, mode: SyncMode = "plain", silent = false, enrichModelId?: string) {
|
|
370
|
+
try {
|
|
371
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER_NAME);
|
|
372
|
+
if (!silent) {
|
|
373
|
+
ctx.ui.notify(
|
|
374
|
+
mode === "plain"
|
|
375
|
+
? "Fetching OpenRouter models..."
|
|
376
|
+
: `Fetching OpenRouter models and endpoint variants for ${enrichModelId}...`,
|
|
377
|
+
"info",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const models = await fetchModels(apiKey);
|
|
382
|
+
let providerModels: ProviderModelConfig[];
|
|
383
|
+
let variantCount = 0;
|
|
384
|
+
let endpointFailures = 0;
|
|
385
|
+
|
|
386
|
+
if (mode === "enriched") {
|
|
387
|
+
if (!enrichModelId) {
|
|
388
|
+
throw new Error("Missing model id for enrichment");
|
|
389
|
+
}
|
|
390
|
+
const enriched = await buildEnrichedCatalog(models, enrichModelId, apiKey);
|
|
391
|
+
providerModels = enriched.models;
|
|
392
|
+
enrichedRoutes = enriched.routes;
|
|
393
|
+
variantCount = enriched.variantCount;
|
|
394
|
+
endpointFailures = enriched.endpointFailures;
|
|
395
|
+
} else {
|
|
396
|
+
providerModels = models.map(toProviderModel);
|
|
397
|
+
enrichedRoutes = new Map();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
pi.registerProvider(PROVIDER_NAME, {
|
|
401
|
+
baseUrl: OPENROUTER_BASE_URL,
|
|
402
|
+
apiKey: "OPENROUTER_API_KEY",
|
|
403
|
+
api: "openai-completions",
|
|
404
|
+
models: providerModels,
|
|
405
|
+
streamSimple: streamOpenRouter,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
if (!silent) {
|
|
409
|
+
if (mode === "plain") {
|
|
410
|
+
ctx.ui.notify(`OpenRouter: ${providerModels.length} models synced`, "info");
|
|
411
|
+
} else {
|
|
412
|
+
const failuresText = endpointFailures > 0 ? `, ${endpointFailures} endpoint fetch failures` : "";
|
|
413
|
+
ctx.ui.notify(
|
|
414
|
+
`OpenRouter: ${providerModels.length} models synced (${variantCount} provider variants for ${enrichModelId}${failuresText})`,
|
|
415
|
+
"info",
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} catch (err: any) {
|
|
420
|
+
if (!silent) ctx.ui.notify(`OpenRouter sync failed: ${err?.message}`, "error");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
425
|
+
if (ctx.modelRegistry.authStorage.hasAuth(PROVIDER_NAME)) {
|
|
426
|
+
await syncModels(ctx, "plain", true);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
pi.registerCommand("openrouter-sync", {
|
|
431
|
+
description: "Fetch latest OpenRouter base models and restore the plain model list",
|
|
432
|
+
handler: async (_args, ctx) => {
|
|
433
|
+
resetCaches();
|
|
434
|
+
await syncModels(ctx, "plain");
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
pi.registerCommand("openrouter-enrich", {
|
|
439
|
+
description: "Fetch OpenRouter endpoint variants for one model and add provider/quantization choices to the model list",
|
|
440
|
+
handler: async (args, ctx) => {
|
|
441
|
+
const modelId = args.trim();
|
|
442
|
+
if (!modelId) {
|
|
443
|
+
ctx.ui.notify("Usage: /openrouter-enrich <openrouter-model-id>", "warning");
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
resetCaches();
|
|
447
|
+
await syncModels(ctx, "enriched", false, modelId);
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-openrouter-realtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenRouter extension for pi that loads the latest models from OpenRouter in real time and adds optional provider and quantization enrichment per model",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"openrouter",
|
|
11
|
+
"extension",
|
|
12
|
+
"llm"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/olixis/pi-openrouter-plus.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/olixis/pi-openrouter-plus",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/olixis/pi-openrouter-plus/issues"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"extensions",
|
|
24
|
+
"assets",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"package.json",
|
|
28
|
+
"tsconfig.json"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@mariozechner/pi-ai": "*",
|
|
35
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@mariozechner/pi-ai": "*",
|
|
39
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
40
|
+
"typescript": "^5.8.0"
|
|
41
|
+
},
|
|
42
|
+
"pi": {
|
|
43
|
+
"extensions": [
|
|
44
|
+
"./extensions/openrouter-routing/index.ts"
|
|
45
|
+
],
|
|
46
|
+
"image": "https://raw.githubusercontent.com/olixis/pi-openrouter-plus/main/assets/preview.png"
|
|
47
|
+
}
|
|
48
|
+
}
|