@mumboo/pi-openrouter-routing 0.1.2 → 0.1.3
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 +1 -0
- package/package.json +1 -1
- package/src/index.ts +104 -31
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ Switch to an OpenRouter model, then run `/routing` to pick its providers:
|
|
|
23
23
|
- **↑/↓ + enter** — toggle providers
|
|
24
24
|
- **alt+↑/alt+↓** — set priority order
|
|
25
25
|
- **ctrl+a / ctrl+x** — enable all / none
|
|
26
|
+
- **ctrl+f** — toggle fallbacks (fall back to other providers when pinned ones are unavailable)
|
|
26
27
|
- **ctrl+s** — save · **esc** — close
|
|
27
28
|
|
|
28
29
|
Saves are written to `~/.pi/agent/models.json` and applied to the running session immediately — no restart needed. Hand-set routing fields (e.g. `sort`, `max_price`) are preserved.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -57,7 +57,28 @@ export async function writeRouting(modelId: string, routing: OpenRouterRouting |
|
|
|
57
57
|
await writeFile(modelsJsonPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export
|
|
60
|
+
export interface ProviderInfo {
|
|
61
|
+
inputPrice?: number;
|
|
62
|
+
outputPrice?: number;
|
|
63
|
+
cacheReadPrice?: number;
|
|
64
|
+
quantizations: string[];
|
|
65
|
+
uptime1d?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parsePrice(value: string | number | undefined): number | undefined {
|
|
69
|
+
if (value == null) return undefined;
|
|
70
|
+
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
|
|
71
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatUsdPerMillion(rate: number): string {
|
|
75
|
+
const perMillion = rate * 1_000_000;
|
|
76
|
+
if (perMillion >= 100) return perMillion.toFixed(0);
|
|
77
|
+
if (perMillion >= 10) return perMillion.toFixed(1);
|
|
78
|
+
return perMillion.toFixed(2);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function fetchProviders(modelId: string): Promise<{ slugs: string[]; info: Record<string, ProviderInfo> } | undefined> {
|
|
61
82
|
try {
|
|
62
83
|
const response = await fetch(`https://openrouter.ai/api/v1/models/${modelId}/endpoints`, {
|
|
63
84
|
signal: AbortSignal.timeout(10_000),
|
|
@@ -66,20 +87,46 @@ export async function fetchEndpoints(modelId: string): Promise<string[] | undefi
|
|
|
66
87
|
if (!response.ok) return undefined;
|
|
67
88
|
const payload = (await response.json()) as {
|
|
68
89
|
data?: {
|
|
69
|
-
endpoints?: Array<{
|
|
90
|
+
endpoints?: Array<{
|
|
91
|
+
provider_name?: string;
|
|
92
|
+
tag?: string;
|
|
93
|
+
pricing?: { prompt?: string | number; completion?: string | number; input_cache_read?: string | number };
|
|
94
|
+
quantization?: string;
|
|
95
|
+
uptime_last_1d?: number | null;
|
|
96
|
+
}>;
|
|
70
97
|
};
|
|
71
98
|
};
|
|
72
99
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
100
|
+
const info: Record<string, ProviderInfo> = {};
|
|
101
|
+
for (const endpoint of payload.data?.endpoints ?? []) {
|
|
102
|
+
const slug = endpoint.tag ?? endpoint.provider_name?.toLowerCase();
|
|
103
|
+
if (!slug) continue;
|
|
104
|
+
const meta = (info[slug] ??= { quantizations: [] });
|
|
105
|
+
|
|
106
|
+
const inputPrice = parsePrice(endpoint.pricing?.prompt);
|
|
107
|
+
const outputPrice = parsePrice(endpoint.pricing?.completion);
|
|
108
|
+
const cacheReadPrice = parsePrice(endpoint.pricing?.input_cache_read);
|
|
109
|
+
if (inputPrice != null) meta.inputPrice = meta.inputPrice == null ? inputPrice : Math.min(meta.inputPrice, inputPrice);
|
|
110
|
+
if (outputPrice != null) meta.outputPrice = meta.outputPrice == null ? outputPrice : Math.min(meta.outputPrice, outputPrice);
|
|
111
|
+
if (cacheReadPrice != null) meta.cacheReadPrice = meta.cacheReadPrice == null ? cacheReadPrice : Math.min(meta.cacheReadPrice, cacheReadPrice);
|
|
112
|
+
|
|
113
|
+
if (endpoint.quantization && endpoint.quantization !== "unknown" && !meta.quantizations.includes(endpoint.quantization)) {
|
|
114
|
+
meta.quantizations.push(endpoint.quantization);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (endpoint.uptime_last_1d != null) {
|
|
118
|
+
meta.uptime1d = meta.uptime1d == null ? endpoint.uptime_last_1d : Math.min(meta.uptime1d, endpoint.uptime_last_1d);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const meta of Object.values(info)) meta.quantizations.sort();
|
|
122
|
+
|
|
123
|
+
return { slugs: Object.keys(info).sort(), info };
|
|
77
124
|
} catch {
|
|
78
125
|
return undefined;
|
|
79
126
|
}
|
|
80
127
|
}
|
|
81
128
|
|
|
82
|
-
const FOOTER_HINT = "↑↓ navigate • enter toggle • alt+↑↓ reorder • ctrl+a all • ctrl+x none • ctrl+
|
|
129
|
+
const FOOTER_HINT = "↑↓ navigate • enter toggle • alt+↑↓ reorder • ctrl+a all • ctrl+x none • ctrl+f fallbacks • ctrl+s save • esc close";
|
|
83
130
|
|
|
84
131
|
class ProviderRoutingList implements Component {
|
|
85
132
|
private readonly allSlugs: string[];
|
|
@@ -87,30 +134,40 @@ class ProviderRoutingList implements Component {
|
|
|
87
134
|
private filteredIds: string[];
|
|
88
135
|
private cursor = 0;
|
|
89
136
|
private readonly searchInput = new Input();
|
|
137
|
+
private readonly labelWidth: number;
|
|
138
|
+
private allowFallbacks: boolean;
|
|
90
139
|
private baseline: string;
|
|
91
140
|
private pendingSave: string | null = null;
|
|
92
141
|
private readonly maxVisible: number;
|
|
93
142
|
private readonly footer: Text;
|
|
94
143
|
|
|
95
|
-
onSave?: (enabled: string[]) => void;
|
|
144
|
+
onSave?: (enabled: string[], allowFallbacks: boolean) => void;
|
|
96
145
|
onCancel?: () => void;
|
|
97
146
|
|
|
98
147
|
constructor(
|
|
99
148
|
private readonly theme: Theme,
|
|
100
149
|
slugs: string[],
|
|
101
150
|
enabled: string[],
|
|
151
|
+
allowFallbacks: boolean,
|
|
152
|
+
private readonly info: Record<string, ProviderInfo>,
|
|
102
153
|
) {
|
|
103
154
|
this.allSlugs = slugs;
|
|
104
155
|
this.enabledIds = enabled.filter((slug) => slugs.includes(slug));
|
|
156
|
+
this.labelWidth = Math.max(...slugs.map((slug) => slug.length), 0);
|
|
157
|
+
this.allowFallbacks = allowFallbacks;
|
|
105
158
|
this.filteredIds = slugs;
|
|
106
159
|
this.maxVisible = Math.min(slugs.length, 15);
|
|
107
160
|
this.footer = new Text("", 0, 0);
|
|
108
|
-
this.baseline =
|
|
161
|
+
this.baseline = this.state;
|
|
109
162
|
this.refresh();
|
|
110
163
|
}
|
|
111
164
|
|
|
165
|
+
private get state(): string {
|
|
166
|
+
return JSON.stringify([this.enabledIds, this.allowFallbacks]);
|
|
167
|
+
}
|
|
168
|
+
|
|
112
169
|
private get dirty(): boolean {
|
|
113
|
-
return
|
|
170
|
+
return this.state !== this.baseline;
|
|
114
171
|
}
|
|
115
172
|
|
|
116
173
|
getSearchInput(): Input {
|
|
@@ -128,6 +185,22 @@ class ProviderRoutingList implements Component {
|
|
|
128
185
|
return [...this.enabledIds, ...disabled];
|
|
129
186
|
}
|
|
130
187
|
|
|
188
|
+
private metaText(slug: string): string {
|
|
189
|
+
const meta = this.info[slug];
|
|
190
|
+
if (!meta) return "";
|
|
191
|
+
const parts: string[] = [];
|
|
192
|
+
if (meta.inputPrice != null || meta.outputPrice != null) {
|
|
193
|
+
const input = meta.inputPrice != null ? formatUsdPerMillion(meta.inputPrice) : "?";
|
|
194
|
+
const output = meta.outputPrice != null ? formatUsdPerMillion(meta.outputPrice) : "?";
|
|
195
|
+
parts.push(`$${input}/$${output}/1M`);
|
|
196
|
+
}
|
|
197
|
+
if (meta.cacheReadPrice != null) parts.push(`$${formatUsdPerMillion(meta.cacheReadPrice)} cache/1M`);
|
|
198
|
+
const quants = meta.quantizations.filter((quant) => !slug.includes(quant));
|
|
199
|
+
if (quants.length > 0) parts.push(quants.join(","));
|
|
200
|
+
if (meta.uptime1d != null) parts.push(`${meta.uptime1d.toFixed(0)}%`);
|
|
201
|
+
return parts.length > 0 ? this.theme.fg("dim", ` ${parts.join(" · ")}`) : "";
|
|
202
|
+
}
|
|
203
|
+
|
|
131
204
|
render(width: number): string[] {
|
|
132
205
|
const lines: string[] = [];
|
|
133
206
|
const start = Math.max(0, Math.min(this.cursor - Math.floor(this.maxVisible / 2), this.filteredIds.length - this.maxVisible));
|
|
@@ -139,8 +212,9 @@ class ProviderRoutingList implements Component {
|
|
|
139
212
|
|
|
140
213
|
const prefix = isSelected ? this.theme.fg("accent", "❯ ") : " ";
|
|
141
214
|
const marker = rank >= 0 ? this.theme.fg("accent", "[x]") : this.theme.fg("dim", "[ ]");
|
|
142
|
-
const
|
|
143
|
-
|
|
215
|
+
const label = slug.padEnd(this.labelWidth);
|
|
216
|
+
const slugText = rank >= 0 ? label : this.theme.fg("dim", label);
|
|
217
|
+
lines.push(truncateToWidth(`${prefix}${marker} ${slugText}${this.metaText(slug)}`, width, "…"));
|
|
144
218
|
}
|
|
145
219
|
if (this.filteredIds.length > this.maxVisible) {
|
|
146
220
|
lines.push(this.theme.fg("dim", ` (${this.cursor + 1}/${this.filteredIds.length})`));
|
|
@@ -155,7 +229,7 @@ class ProviderRoutingList implements Component {
|
|
|
155
229
|
const sorted = this.sortedIds();
|
|
156
230
|
this.filteredIds = query ? sorted.filter((slug) => fuzzyMatch(query, slug).matches) : sorted;
|
|
157
231
|
this.cursor = Math.min(this.cursor, Math.max(0, this.filteredIds.length - 1));
|
|
158
|
-
const base = ` ${this.enabledIds.length}/${this.allSlugs.length} enabled · ${FOOTER_HINT}`;
|
|
232
|
+
const base = ` ${this.enabledIds.length}/${this.allSlugs.length} enabled · fallbacks ${this.allowFallbacks ? "on" : "off"} · ${FOOTER_HINT}`;
|
|
159
233
|
this.footer.setText(
|
|
160
234
|
this.dirty
|
|
161
235
|
? this.theme.fg("dim", `${base} `) + this.theme.fg("warning", "(unsaved)")
|
|
@@ -198,9 +272,15 @@ class ProviderRoutingList implements Component {
|
|
|
198
272
|
return;
|
|
199
273
|
}
|
|
200
274
|
|
|
275
|
+
if (matchesKey(data, Key.ctrl("f"))) {
|
|
276
|
+
this.allowFallbacks = !this.allowFallbacks;
|
|
277
|
+
this.refresh();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
201
281
|
if (matchesKey(data, Key.ctrl("s"))) {
|
|
202
|
-
this.pendingSave =
|
|
203
|
-
this.onSave?.([...this.enabledIds]);
|
|
282
|
+
this.pendingSave = this.state;
|
|
283
|
+
this.onSave?.([...this.enabledIds], this.allowFallbacks);
|
|
204
284
|
return;
|
|
205
285
|
}
|
|
206
286
|
|
|
@@ -216,12 +296,6 @@ class ProviderRoutingList implements Component {
|
|
|
216
296
|
return;
|
|
217
297
|
}
|
|
218
298
|
|
|
219
|
-
if (matchesKey(data, Key.ctrl("r"))) {
|
|
220
|
-
this.enabledIds = [...this.allSlugs];
|
|
221
|
-
this.refresh();
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
299
|
if (matchesKey(data, "escape")) {
|
|
226
300
|
this.onCancel?.();
|
|
227
301
|
return;
|
|
@@ -232,7 +306,7 @@ class ProviderRoutingList implements Component {
|
|
|
232
306
|
}
|
|
233
307
|
|
|
234
308
|
markSaved(): void {
|
|
235
|
-
if (this.pendingSave !== null &&
|
|
309
|
+
if (this.pendingSave !== null && this.state === this.pendingSave) {
|
|
236
310
|
this.baseline = this.pendingSave;
|
|
237
311
|
this.pendingSave = null;
|
|
238
312
|
this.refresh();
|
|
@@ -266,12 +340,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
266
340
|
const modelId = ctx.model.id.replace(/^openrouter\//, "");
|
|
267
341
|
const theme = ctx.ui.theme;
|
|
268
342
|
|
|
269
|
-
const
|
|
343
|
+
const fetched = await fetchProviders(modelId);
|
|
270
344
|
const routing = getRouting(await readModelsJson(), modelId);
|
|
271
345
|
const configured = routing?.order ?? routing?.only;
|
|
272
346
|
|
|
273
|
-
const slugs = new Set<string>([...(routing?.only ?? []), ...configured ?? []]);
|
|
274
|
-
for (const slug of endpoints ?? []) slugs.add(slug);
|
|
347
|
+
const slugs = new Set<string>([...(routing?.only ?? []), ...configured ?? [], ...(fetched?.slugs ?? [])]);
|
|
275
348
|
|
|
276
349
|
if (slugs.size === 0) {
|
|
277
350
|
ctx.ui.notify(`No providers found for ${modelId}`, "warning");
|
|
@@ -281,7 +354,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
281
354
|
const allSlugs = [...slugs].sort();
|
|
282
355
|
const enabled = configured ? configured.filter((slug) => slugs.has(slug)) : allSlugs;
|
|
283
356
|
|
|
284
|
-
async function saveRouting(enabledIds: string[]): Promise<void> {
|
|
357
|
+
async function saveRouting(enabledIds: string[], allowFallbacks: boolean): Promise<void> {
|
|
285
358
|
const { allow_fallbacks: _a, only: _o, order: _r, ...preserved } = getRouting(await readModelsJson(), modelId) ?? {};
|
|
286
359
|
const next = preserved as OpenRouterRouting;
|
|
287
360
|
|
|
@@ -291,12 +364,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
291
364
|
|
|
292
365
|
if (enabledIds.length > 0 && !allEnabled) {
|
|
293
366
|
next.only = sorted;
|
|
294
|
-
next.allow_fallbacks =
|
|
367
|
+
next.allow_fallbacks = allowFallbacks;
|
|
295
368
|
}
|
|
296
369
|
if (!isDefaultOrder) next.order = enabledIds;
|
|
297
370
|
|
|
298
371
|
const detail = next.only
|
|
299
|
-
? `${enabledIds.length}/${allSlugs.length} providers`
|
|
372
|
+
? `${enabledIds.length}/${allSlugs.length} providers${allowFallbacks ? ", fallbacks allowed" : ""}`
|
|
300
373
|
: next.order
|
|
301
374
|
? `${enabledIds.length}/${allSlugs.length} providers, priority set`
|
|
302
375
|
: "default";
|
|
@@ -322,9 +395,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
322
395
|
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
323
396
|
container.addChild(new Text(theme.fg("accent", theme.bold(`Provider routing — ${modelId}`)), 1, 0));
|
|
324
397
|
|
|
325
|
-
const list = new ProviderRoutingList(theme, allSlugs, enabled);
|
|
326
|
-
list.onSave = (enabledIds) => {
|
|
327
|
-
void saveRouting(enabledIds).then(() => list.markSaved());
|
|
398
|
+
const list = new ProviderRoutingList(theme, allSlugs, enabled, routing?.allow_fallbacks ?? false, fetched?.info ?? {});
|
|
399
|
+
list.onSave = (enabledIds, allowFallbacks) => {
|
|
400
|
+
void saveRouting(enabledIds, allowFallbacks).then(() => list.markSaved());
|
|
328
401
|
};
|
|
329
402
|
list.onCancel = () => done(null);
|
|
330
403
|
container.addChild(list.getSearchInput());
|