@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 +39 -33
- package/package.json +4 -4
- package/src/catalog.ts +45 -0
- package/src/command.ts +212 -0
- package/src/data.ts +8 -5
- package/src/defaults.ts +44 -0
- package/src/http.ts +5 -39
- package/src/index.ts +10 -8
- package/src/provider.ts +3 -3
- package/src/recorder.ts +103 -0
- package/src/stt-command.ts +127 -0
- package/src/transcribe.ts +13 -2
- package/src/tts.ts +210 -0
- package/src/fetch.ts +0 -260
- package/src/search.ts +0 -282
package/README.md
CHANGED
|
@@ -1,58 +1,64 @@
|
|
|
1
1
|
# pix-9router
|
|
2
2
|
|
|
3
|
-
Pi
|
|
3
|
+
Pi extension with a 9Router model provider, speech-to-text, and text-to-speech tools.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Tools
|
|
6
6
|
|
|
7
|
-
|
|
|
8
|
-
|
|
9
|
-
| `
|
|
10
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
pi install npm:@xynogen/pix-9router
|
|
18
|
-
```
|
|
16
|
+
## Settings
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
Run:
|
|
21
19
|
|
|
22
|
-
|
|
20
|
+
```text
|
|
21
|
+
/9router
|
|
22
|
+
```
|
|
23
23
|
|
|
24
|
-
|
|
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
|
-
|
|
26
|
+
- STT model
|
|
27
|
+
- TTS model or voice
|
|
28
|
+
- TTS playback (`on` by default)
|
|
30
29
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
## Environment
|
|
37
|
+
|
|
38
|
+
Use the upstream standard names:
|
|
41
39
|
|
|
42
|
-
|
|
40
|
+
```bash
|
|
41
|
+
export NINEROUTER_URL="https://your-router.example.com"
|
|
42
|
+
export NINEROUTER_KEY="your-key-here"
|
|
43
|
+
```
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
Legacy aliases remain supported:
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
```text
|
|
48
|
+
ROUTER_API_BASE
|
|
49
|
+
ROUTER_API_KEY
|
|
50
|
+
```
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
The standard names win when both sets exist.
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
## Install
|
|
51
55
|
|
|
52
56
|
```bash
|
|
53
|
-
|
|
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
|
-
"description": "Pi extension — 9Router provider
|
|
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.
|
|
47
|
+
"@xynogen/pix-pretty": "^1.25.0",
|
|
48
48
|
"typebox": "^1.1.38",
|
|
49
|
-
"@xynogen/pix-runtime": "^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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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)),
|
package/src/defaults.ts
ADDED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
|
16
|
+
import registerRouterCommand from "./command.js";
|
|
17
17
|
import registerProvider from "./provider.js";
|
|
18
|
-
import
|
|
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
|
-
|
|
24
|
-
|
|
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: "$
|
|
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
|
|
140
|
+
apiKey,
|
|
141
141
|
api: "openai-completions",
|
|
142
142
|
headers: { "User-Agent": "pi-coding-agent" },
|
|
143
143
|
models: models.map(toModelConfig(devIndex)),
|