@xynogen/pix-9router 0.4.4 → 0.6.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/README.md CHANGED
@@ -1,58 +1,64 @@
1
1
  # pix-9router
2
2
 
3
- Pi coding agent extension — **9Router provider** + **fetch**, **search**, and **transcribe** tools backed by an internal OpenAI-compatible router API.
3
+ Pi extension with a 9Router model provider, speech-to-text, and text-to-speech tools.
4
4
 
5
- ## What's included
5
+ ## Tools
6
6
 
7
- | Module | Type | Description |
8
- |---|---|---|
9
- | `provider` | provider | Registers `9router` provider with live model list from the router API |
10
- | `fetch` | tool | `fetch(url, format, max_characters?)` — fetches web pages via exa through the router, falls back to curl |
11
- | `search` | tool | `search(query, search_type, max_results?)` — web/news search via exa through the router, falls back to curl |
12
- | `transcribe` | tool | `transcribe(file, model?, language?)` — speech-to-text via `/audio/transcriptions` endpoint (default: `dg/nova-3`), falls back to curl |
7
+ | Tool | Default controlled by `/9router` |
8
+ |---|---|
9
+ | `transcribe` | STT model |
10
+ | `tts` | TTS model or voice, plus automatic playback |
13
11
 
14
- ## Install
12
+ Tool arguments override saved defaults. Tool calls never fetch the model catalog.
13
+ The `/9router` menu fetches live models or voices only when you open a setting,
14
+ then saves the choice in `~/.pi/agent/9router.json`.
15
15
 
16
- ```bash
17
- pi install npm:@xynogen/pix-9router
18
- ```
16
+ ## Settings
19
17
 
20
- > Standalone/opt-in — **not** bundled by [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core). Needs a 9Router API key, so only useful if you route through 9Router.
18
+ Run:
21
19
 
22
- ## Environment
20
+ ```text
21
+ /9router
22
+ ```
23
23
 
24
- | Variable | Required | Default | Description |
25
- |---|---|---|---|
26
- | `ROUTER_API_KEY` | Yes | — | Bearer token for the router API |
27
- | `ROUTER_API_BASE` | No | `https://9router.example.com/v1` | Override router base URL |
24
+ The menu controls:
28
25
 
29
- Add to your `~/.zsh_local` (or equivalent):
26
+ - STT model
27
+ - TTS model or voice
28
+ - TTS playback (`on` by default)
30
29
 
31
- ```bash
32
- export ROUTER_API_KEY="your-key-here"
33
- # export ROUTER_API_BASE="https://your-router.example.com/v1" # optional
34
- ```
30
+ Catalogs come from `/v1/models/stt` and `/v1/models/tts`.
35
31
 
36
- ## How it works
32
+ Web search and fetch moved to `@xynogen/pix-web`. Its `/search` and `/fetch`
33
+ commands include a 9Router provider that reuses `NINEROUTER_URL` and
34
+ `NINEROUTER_KEY`.
37
35
 
38
- - **Provider**: on load, fetches `/models` from the router and registers them with Pi. The [modelgrep](https://modelgrep.com) catalog (via [`@xynogen/pix-data`](https://github.com/xynogen/pix-mono/tree/main/packages/pix-data)'s shared cache) is used internally to fill missing context window / modality fields where the router response omits them. Model list is cached at `~/.cache/pi/9router.json` (TTL 30 min).
39
- - **fetch / search**: POST to `/web/fetch` and `/search` on the router (which proxies to exa). If the router is unreachable, falls back to `curl` (for `fetch`, raw URL fetch; for `search`, the same `/search` endpoint via curl). Tool output is rendered dimmed so fetched web content reads like faded context rather than primary output.
40
- - **transcribe**: POST to `/audio/transcriptions` (Deepgram Nova 3 by default). Accepts any audio file path; falls back to curl. Pass the optional `output_file` to write the full transcription to disk (parent dirs created, path pre-flight checked) and return only a short path summary to the model — useful for long results; without it the text is returned inline (truncated to 50,000 chars).
36
+ ## Environment
37
+
38
+ Use the upstream standard names:
41
39
 
42
- ## Compact results
40
+ ```bash
41
+ export NINEROUTER_URL="https://your-router.example.com"
42
+ export NINEROUTER_KEY="your-key-here"
43
+ ```
43
44
 
44
- Terminal tool results collapse after the shared Pix delay into metadata-driven rows while preserving all model-visible content. Examples include `✓ fetch https://example.com · 12.4K chars · markdown`, `⚡ search “query” · 8 results · curl fallback`, and `✓ transcribe meeting.mp3 · 12.4K chars · dg/nova-3`. Failures use `✗`; cancelled or fallback outcomes use `⚡`. Running and partial updates remain live, and expanding an elapsed row restores the normal dimmed content preview without restarting its timer. Configure the behavior with `collapse.delaySec` and the `fetch`, `search`, or `transcribe` entries under `collapse.tools` in `~/.pi/agent/pix.json`.
45
+ Legacy aliases remain supported:
45
46
 
46
- ## Full distro
47
+ ```text
48
+ ROUTER_API_BASE
49
+ ROUTER_API_KEY
50
+ ```
47
51
 
48
- Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
52
+ The standard names win when both sets exist.
49
53
 
50
- To install the complete pix suite (all packages + Pi itself):
54
+ ## Install
51
55
 
52
56
  ```bash
53
- curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
57
+ pi install npm:@xynogen/pix-9router
54
58
  ```
55
59
 
60
+ This package is standalone and opt-in. It is not bundled by `@xynogen/pix-core`.
61
+
56
62
  ## License
57
63
 
58
64
  MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xynogen/pix-9router",
3
- "version": "0.4.4",
4
- "description": "Pi extension — 9Router provider + fetch/search tools via router API",
3
+ "version": "0.6.0",
4
+ "description": "Pi extension — 9Router model provider plus audio tools",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
@@ -44,8 +44,8 @@
44
44
  "@earendil-works/pi-tui": "*"
45
45
  },
46
46
  "dependencies": {
47
- "@xynogen/pix-pretty": "^1.19.0",
47
+ "@xynogen/pix-pretty": "^1.25.0",
48
48
  "typebox": "^1.1.38",
49
- "@xynogen/pix-runtime": "^0.8.0"
49
+ "@xynogen/pix-runtime": "^0.10.1"
50
50
  }
51
51
  }
package/src/catalog.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { routerBaseUrl } from "./data.js";
2
+ import { auth } from "./http.js";
3
+
4
+ interface CatalogItem {
5
+ id?: string;
6
+ kind?: string;
7
+ model?: string;
8
+ name?: string;
9
+ }
10
+
11
+ async function get(path: string): Promise<unknown> {
12
+ const key = auth();
13
+ const response = await fetch(`${routerBaseUrl()}${path}`, {
14
+ headers: key ? { Authorization: `Bearer ${key}` } : undefined,
15
+ });
16
+ if (!response.ok) throw new Error(`9Router catalog ${response.status}`);
17
+ return response.json();
18
+ }
19
+
20
+ function items(value: unknown): CatalogItem[] {
21
+ if (!value || typeof value !== "object") return [];
22
+ const data = (value as { data?: unknown }).data;
23
+ return Array.isArray(data) ? (data as CatalogItem[]) : [];
24
+ }
25
+
26
+ export async function catalogModels(kind: "stt" | "tts"): Promise<string[]> {
27
+ return items(await get(`/models/${kind}`)).flatMap((item) => (item.id ? [item.id] : []));
28
+ }
29
+
30
+ export interface VoiceChoice {
31
+ id: string;
32
+ language: string;
33
+ provider: string;
34
+ }
35
+
36
+ export function groupVoice(id: string): VoiceChoice {
37
+ const [provider = "other", ...rest] = id.split("/");
38
+ const voice = rest.join("/") || provider;
39
+ const locale = voice.match(/(?:^|[^A-Za-z])([a-z]{2,3})[-_]([A-Z]{2})(?:[^A-Za-z]|$)/);
40
+ return { id, language: locale ? `${locale[1]}-${locale[2]}` : "Other", provider };
41
+ }
42
+
43
+ export async function catalogVoices(): Promise<VoiceChoice[]> {
44
+ return (await catalogModels("tts")).map(groupVoice);
45
+ }
package/src/command.ts ADDED
@@ -0,0 +1,212 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, type SelectItem, SelectList } from "@earendil-works/pi-tui";
3
+ import { icon } from "@xynogen/pix-pretty/icon-catalog";
4
+ import {
5
+ frameModal,
6
+ MIN_MODAL_HEIGHT,
7
+ ModalPager,
8
+ modalOverlayOptions,
9
+ modalWidth,
10
+ selectListTheme,
11
+ terminalModalHeight,
12
+ } from "@xynogen/pix-pretty/modal-frame";
13
+ import { catalogModels, catalogVoices } from "./catalog.js";
14
+ import { routerDefaults, saveDefaults } from "./defaults.js";
15
+
16
+ async function pick(
17
+ ctx: ExtensionContext,
18
+ title: string,
19
+ values: string[],
20
+ current?: string,
21
+ ): Promise<string | undefined> {
22
+ const items: SelectItem[] = [...new Set(current ? [current, ...values] : values)].map(
23
+ (value) => ({
24
+ value,
25
+ label: value,
26
+ description: value === current ? "current" : undefined,
27
+ }),
28
+ );
29
+ if (items.length === 0) return undefined;
30
+
31
+ return (
32
+ (await ctx.ui.custom<string | null>(
33
+ (tui, theme, _keybindings, done) => {
34
+ const list = new SelectList(items, items.length, selectListTheme(theme));
35
+ const currentIndex = items.findIndex((item) => item.value === current);
36
+ if (currentIndex >= 0) list.setSelectedIndex(currentIndex);
37
+ list.onSelect = (item) => done(item.value);
38
+ list.onCancel = () => done(null);
39
+ const pager = new ModalPager();
40
+
41
+ return {
42
+ render(width: number) {
43
+ const result = frameModal({
44
+ width: modalWidth(width),
45
+ maxHeight: terminalModalHeight(tui.terminal?.rows),
46
+ minHeight: MIN_MODAL_HEIGHT,
47
+ header: [theme.fg("accent", theme.bold(title)), ""],
48
+ body: list.render(modalWidth(width) - 4),
49
+ bodyOffset: pager.bodyOffset,
50
+ footer: ["", theme.fg("muted", "↑↓ navigate · enter select · esc cancel")],
51
+ color: (text) => theme.fg("accent", text),
52
+ bg: (text) => theme.bg("customMessageBg", text),
53
+ fg: (text) => theme.fg("text", text),
54
+ });
55
+ pager.sync(result);
56
+ return result.lines;
57
+ },
58
+ invalidate: () => list.invalidate(),
59
+ handleInput(data: string) {
60
+ if (pager.handleInput(data, undefined, true)) tui.requestRender();
61
+ else if (matchesKey(data, "escape")) done(null);
62
+ else list.handleInput(data);
63
+ },
64
+ };
65
+ },
66
+ { overlay: true, overlayOptions: modalOverlayOptions() },
67
+ )) ?? undefined
68
+ );
69
+ }
70
+
71
+ interface SettingRow {
72
+ key: string;
73
+ section: string;
74
+ label: string;
75
+ value: string;
76
+ }
77
+
78
+ async function pickSetting(ctx: ExtensionContext, rows: SettingRow[]): Promise<string | undefined> {
79
+ return (
80
+ (await ctx.ui.custom<string | null>(
81
+ (tui, theme, keybindings, done) => {
82
+ let selected = 0;
83
+ const move = (direction: -1 | 1) => {
84
+ selected = (selected + direction + rows.length) % rows.length;
85
+ };
86
+ return {
87
+ render(width: number) {
88
+ const labelWidth = Math.max(...rows.map((row) => row.label.length));
89
+ const body: string[] = [];
90
+ const rowLines: number[] = [];
91
+ let section = "";
92
+ for (let index = 0; index < rows.length; index++) {
93
+ const row = rows[index];
94
+ if (!row) continue;
95
+ if (row.section !== section) {
96
+ if (section) body.push("");
97
+ body.push(theme.fg("dim", ` ${row.section}`));
98
+ section = row.section;
99
+ }
100
+ const active = index === selected;
101
+ rowLines[index] = body.length;
102
+ body.push(
103
+ `${active ? theme.fg("accent", "→") : " "} ${theme.fg(active ? "accent" : "text", row.label.padEnd(labelWidth))} ${theme.fg("success", row.value)}`,
104
+ );
105
+ }
106
+ const result = frameModal({
107
+ width: modalWidth(width),
108
+ maxHeight: terminalModalHeight(tui.terminal?.rows),
109
+ minHeight: MIN_MODAL_HEIGHT,
110
+ title: `${icon("settings")} 9Router Settings`,
111
+ titleColor: (text) => theme.fg("accent", theme.bold(text)),
112
+ header: [""],
113
+ body,
114
+ footer: ["", theme.fg("muted", "↑↓ move · enter change · esc close")],
115
+ selectedBodyLine: rowLines[selected],
116
+ color: (text) => theme.fg("accent", text),
117
+ bg: (text) => theme.bg("customMessageBg", text),
118
+ });
119
+ return result.lines;
120
+ },
121
+ invalidate: () => {},
122
+ handleInput(data: string) {
123
+ if (keybindings.matches(data, "tui.select.cancel")) done(null);
124
+ else if (keybindings.matches(data, "tui.select.up")) move(-1);
125
+ else if (keybindings.matches(data, "tui.select.down")) move(1);
126
+ else if (matchesKey(data, "enter")) done(rows[selected]?.key ?? null);
127
+ else if (matchesKey(data, Key.left) || matchesKey(data, Key.right))
128
+ done(rows[selected]?.key ?? null);
129
+ else return;
130
+ tui.requestRender();
131
+ },
132
+ };
133
+ },
134
+ { overlay: true, overlayOptions: modalOverlayOptions() },
135
+ )) ?? undefined
136
+ );
137
+ }
138
+
139
+ async function choose(
140
+ ctx: ExtensionContext,
141
+ title: string,
142
+ current: string,
143
+ load: () => Promise<string[]>,
144
+ ): Promise<string | undefined> {
145
+ return pick(ctx, `${title} · current: ${current}`, await load(), current);
146
+ }
147
+
148
+ export default function registerRouterCommand(pi: ExtensionAPI): void {
149
+ pi.registerCommand("9router", {
150
+ description: "Set default models, voice, and TTS playback",
151
+ handler: async (_args, ctx) => {
152
+ while (true) {
153
+ const setting = await pickSetting(ctx, [
154
+ { key: "stt", section: "Audio", label: "STT model", value: routerDefaults.sttModel },
155
+ {
156
+ key: "tts",
157
+ section: "Audio",
158
+ label: "TTS voice",
159
+ value: routerDefaults.ttsModel,
160
+ },
161
+ {
162
+ key: "playback",
163
+ section: "Audio",
164
+ label: "play after generation",
165
+ value: routerDefaults.ttsPlay ? "on" : "off",
166
+ },
167
+ ]);
168
+ if (!setting) return;
169
+
170
+ try {
171
+ let value: string | undefined;
172
+ if (setting === "stt") {
173
+ value = await choose(ctx, "STT model", routerDefaults.sttModel, () =>
174
+ catalogModels("stt"),
175
+ );
176
+ if (value) routerDefaults.sttModel = value;
177
+ } else if (setting === "tts") {
178
+ const voices = await catalogVoices();
179
+ const language = await pick(
180
+ ctx,
181
+ "TTS · 1/3 · language",
182
+ [...new Set(voices.map((voice) => voice.language))].sort(),
183
+ );
184
+ if (!language) continue;
185
+ const languageVoices = voices.filter((voice) => voice.language === language);
186
+ const provider = await pick(
187
+ ctx,
188
+ `TTS · 2/3 · ${language} · provider`,
189
+ [...new Set(languageVoices.map((voice) => voice.provider))].sort(),
190
+ );
191
+ if (!provider) continue;
192
+ value = await pick(
193
+ ctx,
194
+ `TTS · 3/3 · ${language} · ${provider}`,
195
+ languageVoices
196
+ .filter((voice) => voice.provider === provider)
197
+ .map((voice) => voice.id),
198
+ routerDefaults.ttsModel,
199
+ );
200
+ if (value) routerDefaults.ttsModel = value;
201
+ } else if (setting === "playback") {
202
+ value = await pick(ctx, "Play generated speech by default", ["on", "off"]);
203
+ if (value) routerDefaults.ttsPlay = value === "on";
204
+ }
205
+ if (value) saveDefaults(routerDefaults);
206
+ } catch (error) {
207
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
208
+ }
209
+ }
210
+ },
211
+ });
212
+ }
package/src/data.ts CHANGED
@@ -11,8 +11,9 @@
11
11
  * models.dev index helpers live there — imported here, not duplicated.
12
12
  *
13
13
  * Environment:
14
- * ROUTER_API_BASE — override base URL (default: https://9router.example.com/v1)
15
- * ROUTER_API_KEY — bearer token (required)
14
+ * NINEROUTER_URL — canonical base URL
15
+ * NINEROUTER_KEY — canonical bearer token
16
+ * ROUTER_API_BASE / ROUTER_API_KEY — legacy aliases
16
17
  */
17
18
 
18
19
  import { join } from "node:path";
@@ -58,7 +59,9 @@ interface RouterModelsResponse {
58
59
  const ROUTER_DEFAULT_BASE = "https://9router.example.com/v1";
59
60
 
60
61
  export function routerBaseUrl(): string {
61
- return (process.env.ROUTER_API_BASE || ROUTER_DEFAULT_BASE).replace(/\/$/, "");
62
+ const configured = process.env.NINEROUTER_URL || process.env.ROUTER_API_BASE;
63
+ const base = (configured || ROUTER_DEFAULT_BASE).replace(/\/$/, "");
64
+ return base.endsWith("/v1") ? base : `${base}/v1`;
62
65
  }
63
66
 
64
67
  // ── Router models ──────────────────────────────────────────────────────────────
@@ -67,10 +70,10 @@ export const routerModels = new DataSource<RouterModel[]>({
67
70
  label: "9router",
68
71
  url: () => `${routerBaseUrl()}/models`,
69
72
  headers: () => {
70
- const key = process.env.ROUTER_API_KEY;
73
+ const key = process.env.NINEROUTER_KEY || process.env.ROUTER_API_KEY;
71
74
  return key ? { Authorization: `Bearer ${key}` } : undefined;
72
75
  },
73
- skip: () => !process.env.ROUTER_API_KEY,
76
+ skip: () => !(process.env.NINEROUTER_KEY || process.env.ROUTER_API_KEY),
74
77
  cachePath: join(CACHE_DIR, "9router.json"),
75
78
  ttlMs: 30 * 60 * 1000, // 30 minutes
76
79
  parse: (raw) => ((raw as RouterModelsResponse).data ?? []).filter((m) => Boolean(m.id)),
@@ -0,0 +1,44 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export interface RouterDefaults {
6
+ sttModel: string;
7
+ ttsModel: string;
8
+ ttsPlay: boolean;
9
+ sttDevice: string;
10
+ }
11
+
12
+ export const DEFAULTS_PATH = join(homedir(), ".pi", "agent", "9router.json");
13
+
14
+ const FALLBACK: RouterDefaults = {
15
+ sttModel: "dg/nova-3",
16
+ ttsModel: "edge-tts/en-US-AriaNeural",
17
+ ttsPlay: true,
18
+ sttDevice: "default",
19
+ };
20
+
21
+ export function loadDefaults(path = DEFAULTS_PATH): RouterDefaults {
22
+ let value: unknown;
23
+ try {
24
+ value = JSON.parse(readFileSync(path, "utf8"));
25
+ } catch {
26
+ return { ...FALLBACK };
27
+ }
28
+ if (!value || typeof value !== "object") return { ...FALLBACK };
29
+ const item = value as Record<string, unknown>;
30
+ return {
31
+ sttModel: typeof item.sttModel === "string" ? item.sttModel : FALLBACK.sttModel,
32
+ ttsModel: typeof item.ttsModel === "string" ? item.ttsModel : FALLBACK.ttsModel,
33
+ ttsPlay: typeof item.ttsPlay === "boolean" ? item.ttsPlay : FALLBACK.ttsPlay,
34
+ sttDevice: typeof item.sttDevice === "string" ? item.sttDevice : FALLBACK.sttDevice,
35
+ };
36
+ }
37
+
38
+ export function saveDefaults(defaults: RouterDefaults, path = DEFAULTS_PATH): void {
39
+ mkdirSync(dirname(path), { recursive: true });
40
+ writeFileSync(path, `${JSON.stringify(defaults, null, 2)}\n`, { mode: 0o600 });
41
+ }
42
+
43
+ /** Shared in-memory defaults. Tool calls never fetch the catalog. */
44
+ export const routerDefaults = loadDefaults();
package/src/http.ts CHANGED
@@ -1,47 +1,17 @@
1
1
  /**
2
2
  * http.ts — shared 9Router HTTP plumbing.
3
3
  *
4
- * auth / apiPost / curl / isCancelled were duplicated across fetch.ts,
5
- * search.ts and transcribe.ts. Extracted here; the only real differences were
6
- * timeout values (audio needs longer) and curl stdin (search pipes a body),
7
- * so both are parameters with the old per-file defaults.
4
+ * auth and curl serve the transcribe/tts fallback paths and the catalog reader.
5
+ * curl differs only by timeout (audio needs longer) and stdin body, so both are
6
+ * parameters with the old per-file defaults.
8
7
  */
9
8
 
10
9
  import { readFileSync } from "node:fs";
11
10
  import { basename } from "node:path";
12
- import { ioTimeoutMs, ioTimeoutSignal } from "@xynogen/pix-runtime/io";
13
- import { routerBaseUrl } from "./data.js";
11
+ import { ioTimeoutMs } from "@xynogen/pix-runtime/io";
14
12
 
15
13
  export function auth(): string | undefined {
16
- return process.env.ROUTER_API_KEY;
17
- }
18
-
19
- /** POST a JSON body to a router path, returning the raw response text. */
20
- export async function apiPost(
21
- path: string,
22
- body: Record<string, unknown>,
23
- signal?: AbortSignal,
24
- timeoutMs: number = ioTimeoutMs(),
25
- ): Promise<string> {
26
- const url = `${routerBaseUrl()}${path}`;
27
- const key = auth();
28
- const timeoutSignal =
29
- timeoutMs === ioTimeoutMs() ? ioTimeoutSignal() : AbortSignal.timeout(timeoutMs);
30
- const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
31
- const res = await fetch(url, {
32
- method: "POST",
33
- headers: {
34
- "Content-Type": "application/json",
35
- ...(key ? { Authorization: `Bearer ${key}` } : {}),
36
- },
37
- body: JSON.stringify(body),
38
- signal: requestSignal,
39
- });
40
- if (!res.ok) {
41
- const errText = await res.text().catch(() => "");
42
- throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
43
- }
44
- return res.text();
14
+ return process.env.NINEROUTER_KEY || process.env.ROUTER_API_KEY;
45
15
  }
46
16
 
47
17
  export interface CurlOptions {
@@ -130,7 +100,3 @@ export async function curl(args: string[], opts: CurlOptions = {}): Promise<stri
130
100
  }
131
101
  return res.text();
132
102
  }
133
-
134
- export function isCancelled(error: unknown, signal: AbortSignal | undefined): boolean {
135
- return signal?.aborted === true || (error instanceof DOMException && error.name === "AbortError");
136
- }
package/src/index.ts CHANGED
@@ -3,24 +3,26 @@
3
3
  *
4
4
  * Registers:
5
5
  * - 9router provider (model list from self-hosted router API)
6
- * - fetch tool (web page fetch via exa through router)
7
- * - search tool (web/news search via exa through router)
8
6
  * - transcribe tool (speech-to-text via audio transcription API)
7
+ * - tts tool (text-to-speech via audio speech API)
9
8
  *
10
9
  * Environment:
11
- * ROUTER_API_BASE — override base URL (default: https://9router.example.com/v1)
12
- * ROUTER_API_KEY — bearer token (required for provider + tools)
10
+ * NINEROUTER_URL — canonical base URL
11
+ * NINEROUTER_KEY — canonical bearer token
12
+ * ROUTER_API_BASE / ROUTER_API_KEY — legacy aliases
13
13
  */
14
14
 
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
- import registerFetch from "./fetch.js";
16
+ import registerRouterCommand from "./command.js";
17
17
  import registerProvider from "./provider.js";
18
- import registerSearch from "./search.js";
18
+ import registerSttCommand from "./stt-command.js";
19
19
  import registerTranscribe from "./transcribe.js";
20
+ import registerTts from "./tts.js";
20
21
 
21
22
  export default async function (pi: ExtensionAPI): Promise<void> {
22
23
  await registerProvider(pi);
23
- registerFetch(pi);
24
- registerSearch(pi);
24
+ registerRouterCommand(pi);
25
+ registerSttCommand(pi);
25
26
  registerTranscribe(pi);
27
+ registerTts(pi);
26
28
  }
package/src/provider.ts CHANGED
@@ -97,14 +97,14 @@ export function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boo
97
97
  }
98
98
 
99
99
  export default async function registerProvider(pi: ExtensionAPI): Promise<void> {
100
- const apiKey = process.env.ROUTER_API_KEY;
100
+ const apiKey = process.env.NINEROUTER_KEY || process.env.ROUTER_API_KEY;
101
101
 
102
102
  if (!apiKey) {
103
103
  // Register shell provider so the name is known; no models available yet.
104
104
  pi.registerProvider("9router", {
105
105
  name: "9Router",
106
106
  baseUrl: routerBaseUrl(),
107
- apiKey: "$ROUTER_API_KEY",
107
+ apiKey: "$NINEROUTER_KEY",
108
108
  api: "openai-completions",
109
109
  models: [],
110
110
  });
@@ -137,7 +137,7 @@ function providerConfig(
137
137
  return {
138
138
  name: "9Router",
139
139
  baseUrl: routerBaseUrl(),
140
- apiKey: "$ROUTER_API_KEY",
140
+ apiKey,
141
141
  api: "openai-completions",
142
142
  headers: { "User-Agent": "pi-coding-agent" },
143
143
  models: models.map(toModelConfig(devIndex)),