@mumboo/pi-openrouter-routing 0.1.2 → 0.1.4

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 (3) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mumboo/pi-openrouter-routing",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Configure OpenRouter provider routing preferences per model in pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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 async function fetchEndpoints(modelId: string): Promise<string[] | undefined> {
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<{ provider_name?: string; tag?: string }>;
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 slugs = (payload.data?.endpoints ?? [])
74
- .map((endpoint) => endpoint.tag?.split("/")[0] ?? endpoint.provider_name?.toLowerCase())
75
- .filter((slug): slug is string => Boolean(slug));
76
- return [...new Set(slugs)].sort();
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+r reset • ctrl+s save • esc close";
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 = JSON.stringify(this.enabledIds);
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 JSON.stringify(this.enabledIds) !== this.baseline;
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 slugText = rank >= 0 ? slug : this.theme.fg("dim", slug);
143
- lines.push(truncateToWidth(`${prefix}${marker} ${slugText}`, width, "…"));
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 = JSON.stringify(this.enabledIds);
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 && JSON.stringify(this.enabledIds) === this.pendingSave) {
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 endpoints = await fetchEndpoints(modelId);
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 = false;
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());