@mumboo/pi-openrouter-routing 0.1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/package.json +34 -0
  4. package/src/index.ts +345 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 mumboo
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,39 @@
1
+ # pi OpenRouter Routing
2
+
3
+ Pin which providers [pi](https://pi.dev) uses for an OpenRouter model, interactively, from inside the editor.
4
+
5
+ ![preview](https://raw.githubusercontent.com/mumboo/pi-openrouter-routing/refs/heads/master/assets/preview.png)
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:@mumboo/pi-openrouter-routing
11
+ ```
12
+
13
+ Or try it without installing:
14
+
15
+ ```bash
16
+ pi -e npm:@mumboo/pi-openrouter-routing
17
+ ```
18
+
19
+ ## Use
20
+
21
+ Switch to an OpenRouter model, then run `/routing` to pick its providers:
22
+
23
+ - **↑/↓ + enter** — toggle providers
24
+ - **alt+↑/alt+↓** — set priority order
25
+ - **ctrl+a / ctrl+x** — enable all / none
26
+ - **ctrl+s** — save · **esc** — close
27
+
28
+ 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.
29
+
30
+ ## Development
31
+
32
+ ```bash
33
+ npm install
34
+ npm run check
35
+ ```
36
+
37
+ ## License
38
+
39
+ MIT
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@mumboo/pi-openrouter-routing",
3
+ "version": "0.1.1",
4
+ "description": "Configure OpenRouter provider routing preferences per model in pi.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": [
8
+ "pi-package"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/mumboo/pi-openrouter-routing.git"
13
+ },
14
+ "files": [
15
+ "src/index.ts"
16
+ ],
17
+ "pi": {
18
+ "extensions": [
19
+ "./src/index.ts"
20
+ ]
21
+ },
22
+ "peerDependencies": {
23
+ "@earendil-works/pi-coding-agent": "*",
24
+ "@earendil-works/pi-tui": "*"
25
+ },
26
+ "devDependencies": {
27
+ "@earendil-works/pi-coding-agent": "0.84.3",
28
+ "@earendil-works/pi-tui": "0.84.3",
29
+ "typescript": "5.9.3"
30
+ },
31
+ "scripts": {
32
+ "check": "tsc --noEmit"
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,345 @@
1
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { Container, fuzzyMatch, getKeybindings, Input, Key, matchesKey, type Component, Text, truncateToWidth } from "@earendil-works/pi-tui";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+
7
+ interface OpenRouterRouting {
8
+ allow_fallbacks?: boolean;
9
+ require_parameters?: boolean;
10
+ data_collection?: "deny" | "allow";
11
+ zdr?: boolean;
12
+ enforce_distillable_text?: boolean;
13
+ order?: string[];
14
+ only?: string[];
15
+ ignore?: string[];
16
+ quantizations?: string[];
17
+ sort?: string | { by?: string; partition?: string | null };
18
+ max_price?: Record<string, number | string>;
19
+ }
20
+
21
+ interface ModelsJson {
22
+ providers?: Record<string, { modelOverrides?: Record<string, { compat?: { openRouterRouting?: OpenRouterRouting } }> }>;
23
+ }
24
+
25
+ function modelsJsonPath(): string {
26
+ return join(getAgentDir(), "models.json");
27
+ }
28
+
29
+ async function readModelsJson(): Promise<ModelsJson> {
30
+ try {
31
+ return JSON.parse(await readFile(modelsJsonPath(), "utf8")) as ModelsJson;
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ export function getRouting(config: ModelsJson, modelId: string): OpenRouterRouting | undefined {
38
+ return config.providers?.["openrouter"]?.modelOverrides?.[modelId]?.compat?.openRouterRouting;
39
+ }
40
+
41
+ export async function writeRouting(modelId: string, routing: OpenRouterRouting | null): Promise<void> {
42
+ const config = await readModelsJson();
43
+ const provider = (config.providers ??= {});
44
+ const openrouter = (provider["openrouter"] ??= {});
45
+ const overrides = (openrouter.modelOverrides ??= {});
46
+ const override = (overrides[modelId] ??= {});
47
+
48
+ if (routing && Object.keys(routing).length > 0) {
49
+ override.compat = { ...override.compat, openRouterRouting: routing };
50
+ } else {
51
+ delete override.compat?.openRouterRouting;
52
+ if (override.compat && Object.keys(override.compat).length === 0) delete override.compat;
53
+ if (Object.keys(override).length === 0) delete overrides[modelId];
54
+ }
55
+
56
+ await mkdir(dirname(modelsJsonPath()), { recursive: true });
57
+ await writeFile(modelsJsonPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
58
+ }
59
+
60
+ export async function fetchEndpoints(modelId: string): Promise<string[] | undefined> {
61
+ try {
62
+ const response = await fetch(`https://openrouter.ai/api/v1/models/${modelId}/endpoints`, {
63
+ signal: AbortSignal.timeout(10_000),
64
+ headers: { Accept: "application/json" },
65
+ });
66
+ if (!response.ok) return undefined;
67
+ const payload = (await response.json()) as {
68
+ data?: {
69
+ endpoints?: Array<{ provider_name?: string; tag?: string }>;
70
+ };
71
+ };
72
+
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();
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ const FOOTER_HINT = "↑↓ navigate • enter toggle • alt+↑↓ reorder • ctrl+a all • ctrl+x none • ctrl+r reset • ctrl+s save • esc close";
83
+
84
+ class ProviderRoutingList implements Component {
85
+ private readonly allSlugs: string[];
86
+ private enabledIds: string[];
87
+ private filteredIds: string[];
88
+ private cursor = 0;
89
+ private readonly searchInput = new Input();
90
+ private baseline: string;
91
+ private pendingSave: string | null = null;
92
+ private readonly maxVisible: number;
93
+ private readonly footer: Text;
94
+
95
+ onSave?: (enabled: string[]) => void;
96
+ onCancel?: () => void;
97
+
98
+ constructor(
99
+ private readonly theme: Theme,
100
+ slugs: string[],
101
+ enabled: string[],
102
+ ) {
103
+ this.allSlugs = slugs;
104
+ this.enabledIds = enabled.filter((slug) => slugs.includes(slug));
105
+ this.filteredIds = slugs;
106
+ this.maxVisible = Math.min(slugs.length, 15);
107
+ this.footer = new Text("", 0, 0);
108
+ this.baseline = JSON.stringify(this.enabledIds);
109
+ this.refresh();
110
+ }
111
+
112
+ private get dirty(): boolean {
113
+ return JSON.stringify(this.enabledIds) !== this.baseline;
114
+ }
115
+
116
+ getSearchInput(): Input {
117
+ return this.searchInput;
118
+ }
119
+
120
+ getFooter(): Text {
121
+ return this.footer;
122
+ }
123
+
124
+ private sortedIds(): string[] {
125
+ const disabled = this.allSlugs
126
+ .filter((slug) => !this.enabledIds.includes(slug))
127
+ .sort((a, b) => a.localeCompare(b));
128
+ return [...this.enabledIds, ...disabled];
129
+ }
130
+
131
+ render(width: number): string[] {
132
+ const lines: string[] = [];
133
+ const start = Math.max(0, Math.min(this.cursor - Math.floor(this.maxVisible / 2), this.filteredIds.length - this.maxVisible));
134
+ const visible = this.filteredIds.slice(Math.max(0, start), Math.max(0, start) + this.maxVisible);
135
+
136
+ for (const slug of visible) {
137
+ const rank = this.enabledIds.indexOf(slug);
138
+ const isSelected = slug === this.filteredIds[this.cursor];
139
+
140
+ const prefix = isSelected ? this.theme.fg("accent", "❯ ") : " ";
141
+ 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, "…"));
144
+ }
145
+ if (this.filteredIds.length > this.maxVisible) {
146
+ lines.push(this.theme.fg("dim", ` (${this.cursor + 1}/${this.filteredIds.length})`));
147
+ }
148
+ return lines;
149
+ }
150
+
151
+ invalidate(): void {}
152
+
153
+ private refresh(): void {
154
+ const query = this.searchInput.getValue();
155
+ const sorted = this.sortedIds();
156
+ this.filteredIds = query ? sorted.filter((slug) => fuzzyMatch(query, slug).matches) : sorted;
157
+ 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}`;
159
+ this.footer.setText(
160
+ this.dirty
161
+ ? this.theme.fg("dim", `${base} `) + this.theme.fg("warning", "(unsaved)")
162
+ : this.theme.fg("dim", base),
163
+ );
164
+ }
165
+
166
+ private moveCursor(delta: 1 | -1): void {
167
+ if (this.filteredIds.length === 0) return;
168
+ this.cursor = (this.cursor + delta + this.filteredIds.length) % this.filteredIds.length;
169
+ }
170
+
171
+ handleInput(data: string): void {
172
+ const kb = getKeybindings();
173
+
174
+ if (matchesKey(data, "up")) {
175
+ this.moveCursor(-1);
176
+ return;
177
+ }
178
+ if (matchesKey(data, "down")) {
179
+ this.moveCursor(1);
180
+ return;
181
+ }
182
+
183
+ const reorderUp = kb.matches(data, "app.models.reorderUp");
184
+ const reorderDown = kb.matches(data, "app.models.reorderDown");
185
+ if (reorderUp || reorderDown) {
186
+ this.reorder(reorderUp ? -1 : 1);
187
+ return;
188
+ }
189
+
190
+ if (matchesKey(data, "return")) {
191
+ const slug = this.filteredIds[this.cursor];
192
+ if (slug) {
193
+ const index = this.enabledIds.indexOf(slug);
194
+ if (index >= 0) this.enabledIds.splice(index, 1);
195
+ else this.enabledIds.push(slug);
196
+ this.refresh();
197
+ }
198
+ return;
199
+ }
200
+
201
+ if (matchesKey(data, Key.ctrl("s"))) {
202
+ this.pendingSave = JSON.stringify(this.enabledIds);
203
+ this.onSave?.([...this.enabledIds]);
204
+ return;
205
+ }
206
+
207
+ if (matchesKey(data, Key.ctrl("a"))) {
208
+ this.enabledIds = [...this.allSlugs];
209
+ this.refresh();
210
+ return;
211
+ }
212
+
213
+ if (matchesKey(data, Key.ctrl("x"))) {
214
+ this.enabledIds = [];
215
+ this.refresh();
216
+ return;
217
+ }
218
+
219
+ if (matchesKey(data, Key.ctrl("r"))) {
220
+ this.enabledIds = [...this.allSlugs];
221
+ this.refresh();
222
+ return;
223
+ }
224
+
225
+ if (matchesKey(data, "escape")) {
226
+ this.onCancel?.();
227
+ return;
228
+ }
229
+
230
+ this.searchInput.handleInput(data);
231
+ this.refresh();
232
+ }
233
+
234
+ markSaved(): void {
235
+ if (this.pendingSave !== null && JSON.stringify(this.enabledIds) === this.pendingSave) {
236
+ this.baseline = this.pendingSave;
237
+ this.pendingSave = null;
238
+ this.refresh();
239
+ }
240
+ }
241
+
242
+ private reorder(delta: -1 | 1): void {
243
+ const slug = this.filteredIds[this.cursor];
244
+ const index = this.enabledIds.indexOf(slug);
245
+ const target = index + delta;
246
+ if (index < 0 || target < 0 || target >= this.enabledIds.length) return;
247
+ [this.enabledIds[index], this.enabledIds[target]] = [this.enabledIds[target]!, this.enabledIds[index]!];
248
+ this.cursor += delta;
249
+ this.refresh();
250
+ }
251
+ }
252
+
253
+ export default function (pi: ExtensionAPI) {
254
+ pi.registerCommand("routing", {
255
+ description: "Configure OpenRouter provider routing for the current model (persisted to models.json)",
256
+ handler: async (_args, ctx) => {
257
+ if (ctx.mode !== "tui") {
258
+ ctx.ui.notify("/routing requires interactive mode", "warning");
259
+ return;
260
+ }
261
+
262
+ if (ctx.model?.provider !== "openrouter") {
263
+ ctx.ui.notify("Switch to an OpenRouter model first", "warning");
264
+ return;
265
+ }
266
+ const modelId = ctx.model.id.replace(/^openrouter\//, "");
267
+ const theme = ctx.ui.theme;
268
+
269
+ const endpoints = await fetchEndpoints(modelId);
270
+ const routing = getRouting(await readModelsJson(), modelId);
271
+ const configured = routing?.order ?? routing?.only;
272
+
273
+ const slugs = new Set<string>([...(routing?.only ?? []), ...configured ?? []]);
274
+ for (const slug of endpoints ?? []) slugs.add(slug);
275
+
276
+ if (slugs.size === 0) {
277
+ ctx.ui.notify(`No providers found for ${modelId}`, "warning");
278
+ return;
279
+ }
280
+
281
+ const allSlugs = [...slugs].sort();
282
+ const enabled = configured ? configured.filter((slug) => slugs.has(slug)) : allSlugs;
283
+
284
+ async function saveRouting(enabledIds: string[]): Promise<void> {
285
+ const { allow_fallbacks: _a, only: _o, order: _r, ...preserved } = getRouting(await readModelsJson(), modelId) ?? {};
286
+ const next = preserved as OpenRouterRouting;
287
+
288
+ const sorted = [...enabledIds].sort();
289
+ const allEnabled = enabledIds.length === allSlugs.length;
290
+ const isDefaultOrder = !enabledIds.some((slug, i) => slug !== sorted[i]);
291
+
292
+ if (enabledIds.length > 0 && !allEnabled) {
293
+ next.only = sorted;
294
+ next.allow_fallbacks = false;
295
+ }
296
+ if (!isDefaultOrder) next.order = enabledIds;
297
+
298
+ const detail = next.only
299
+ ? `${enabledIds.length}/${allSlugs.length} providers`
300
+ : next.order
301
+ ? `${enabledIds.length}/${allSlugs.length} providers, priority set`
302
+ : "default";
303
+
304
+ try {
305
+ await writeRouting(modelId, Object.keys(next).length > 0 ? next : null);
306
+ await ctx.modelRegistry.refresh();
307
+ let rebound = false;
308
+ if (ctx.model?.provider === "openrouter" && ctx.model.id === modelId) {
309
+ const fresh = ctx.modelRegistry.find("openrouter", modelId);
310
+ if (fresh) {
311
+ rebound = await pi.setModel(fresh);
312
+ }
313
+ }
314
+ ctx.ui.notify(`Routing saved (${detail})`, "info");
315
+ } catch (error) {
316
+ ctx.ui.notify(`Failed to save: ${error instanceof Error ? error.message : String(error)}`, "error");
317
+ }
318
+ }
319
+
320
+ await ctx.ui.custom<null>((tui, _theme, _kb, done) => {
321
+ const container = new Container();
322
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
323
+ container.addChild(new Text(theme.fg("accent", theme.bold(`Provider routing — ${modelId}`)), 1, 0));
324
+
325
+ const list = new ProviderRoutingList(theme, allSlugs, enabled);
326
+ list.onSave = (enabledIds) => {
327
+ void saveRouting(enabledIds).then(() => list.markSaved());
328
+ };
329
+ list.onCancel = () => done(null);
330
+ container.addChild(list.getSearchInput());
331
+ container.addChild(list);
332
+ container.addChild(list.getFooter());
333
+
334
+ return {
335
+ render: (width: number) => container.render(width),
336
+ invalidate: () => container.invalidate(),
337
+ handleInput: (data: string) => {
338
+ list.handleInput(data);
339
+ tui.requestRender();
340
+ },
341
+ };
342
+ });
343
+ },
344
+ });
345
+ }