@xynogen/pix-9router 0.1.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/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * pix-9router — Pi extension bundle
3
+ *
4
+ * Registers:
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
+ * - transcribe tool (speech-to-text via audio transcription API)
9
+ *
10
+ * Environment:
11
+ * ROUTER_API_BASE — override base URL (default: https://9r.xynogen.xyz/v1)
12
+ * ROUTER_API_KEY — bearer token (required for provider + tools)
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import registerProvider from "./provider.js";
17
+ import registerFetch from "./fetch.js";
18
+ import registerSearch from "./search.js";
19
+ import registerTranscribe from "./transcribe.js";
20
+
21
+ export default async function (pi: ExtensionAPI): Promise<void> {
22
+ await registerProvider(pi);
23
+ registerFetch(pi);
24
+ registerSearch(pi);
25
+ registerTranscribe(pi);
26
+ }
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type { ModelsDevModel, RouterModel } from "./data.ts";
3
+
4
+ // ── Re-export internal helpers for testing via module augmentation ────────────
5
+ // provider.ts exports only the default fn; we test the pure mapping logic
6
+ // inline here to avoid coupling tests to private internals.
7
+
8
+ const DEFAULT_CONTEXT_WINDOW = 128_000;
9
+ const DEFAULT_MAX_TOKENS = 16_384;
10
+
11
+ const IMAGE_CAPABLE_PATTERNS = [
12
+ /claude/i,
13
+ /gpt-5/i,
14
+ /gpt-4/i,
15
+ /kimi-k2/i,
16
+ /hy3/i,
17
+ ];
18
+
19
+ function getInputTypes(
20
+ model: RouterModel,
21
+ devModel?: ModelsDevModel,
22
+ ): ("text" | "image")[] {
23
+ if (devModel?.modalities?.input) {
24
+ const inputs = devModel.modalities.input.filter(
25
+ (i): i is "text" | "image" => i === "text" || i === "image",
26
+ );
27
+ if (inputs.length > 0) return inputs;
28
+ }
29
+ const id = model.id ?? "";
30
+ if (IMAGE_CAPABLE_PATTERNS.some((p) => p.test(id))) return ["text", "image"];
31
+ return ["text"];
32
+ }
33
+
34
+ function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
35
+ return model.name || devModel?.name || model.id || "unknown";
36
+ }
37
+
38
+ function getContextWindow(
39
+ model: RouterModel,
40
+ devModel?: ModelsDevModel,
41
+ ): number {
42
+ return (
43
+ model.context_window ||
44
+ model.contextWindow ||
45
+ devModel?.limit?.context ||
46
+ DEFAULT_CONTEXT_WINDOW
47
+ );
48
+ }
49
+
50
+ function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): number {
51
+ return (
52
+ model.max_tokens ||
53
+ model.maxTokens ||
54
+ devModel?.limit?.output ||
55
+ DEFAULT_MAX_TOKENS
56
+ );
57
+ }
58
+
59
+ function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
60
+ if (typeof devModel?.reasoning === "boolean") return devModel.reasoning;
61
+ return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(
62
+ model.id ?? "",
63
+ );
64
+ }
65
+
66
+ // ── getInputTypes ────────────────────────────────────────────────────────────
67
+
68
+ describe("getInputTypes", () => {
69
+ it("uses devModel modalities when present", () => {
70
+ const model: RouterModel = { id: "some-model" };
71
+ const dev: ModelsDevModel = {
72
+ id: "some-model",
73
+ modalities: { input: ["text", "image"] },
74
+ };
75
+ expect(getInputTypes(model, dev)).toEqual(["text", "image"]);
76
+ });
77
+
78
+ it("filters out non text/image modalities from devModel", () => {
79
+ const model: RouterModel = { id: "some-model" };
80
+ const dev: ModelsDevModel = {
81
+ id: "some-model",
82
+ modalities: { input: ["text", "audio"] },
83
+ };
84
+ expect(getInputTypes(model, dev)).toEqual(["text"]);
85
+ });
86
+
87
+ it("falls back to pattern match for claude", () => {
88
+ expect(getInputTypes({ id: "claude-sonnet-4-5" })).toEqual([
89
+ "text",
90
+ "image",
91
+ ]);
92
+ });
93
+
94
+ it("falls back to pattern match for gpt-4o", () => {
95
+ expect(getInputTypes({ id: "gpt-4o" })).toEqual(["text", "image"]);
96
+ });
97
+
98
+ it("falls back to pattern match for kimi-k2", () => {
99
+ expect(getInputTypes({ id: "kimi-k2-instruct" })).toEqual([
100
+ "text",
101
+ "image",
102
+ ]);
103
+ });
104
+
105
+ it("returns text-only for unknown model with no devModel", () => {
106
+ expect(getInputTypes({ id: "some-random-llm" })).toEqual(["text"]);
107
+ });
108
+
109
+ it("uses devModel over pattern when both match", () => {
110
+ const model: RouterModel = { id: "claude-opus-4" };
111
+ const dev: ModelsDevModel = {
112
+ id: "claude-opus-4",
113
+ modalities: { input: ["text"] }, // explicitly text-only despite claude pattern
114
+ };
115
+ expect(getInputTypes(model, dev)).toEqual(["text"]);
116
+ });
117
+ });
118
+
119
+ // ── getModelName ─────────────────────────────────────────────────────────────
120
+
121
+ describe("getModelName", () => {
122
+ it("prefers router model.name", () => {
123
+ expect(
124
+ getModelName(
125
+ { id: "x", name: "Router Name" },
126
+ { id: "x", name: "Dev Name" },
127
+ ),
128
+ ).toBe("Router Name");
129
+ });
130
+
131
+ it("falls back to devModel.name", () => {
132
+ expect(getModelName({ id: "x" }, { id: "x", name: "Dev Name" })).toBe(
133
+ "Dev Name",
134
+ );
135
+ });
136
+
137
+ it("falls back to model.id", () => {
138
+ expect(getModelName({ id: "some-id" })).toBe("some-id");
139
+ });
140
+
141
+ it("returns 'unknown' when all empty", () => {
142
+ expect(getModelName({})).toBe("unknown");
143
+ });
144
+ });
145
+
146
+ // ── getContextWindow ──────────────────────────────────────────────────────────
147
+
148
+ describe("getContextWindow", () => {
149
+ it("prefers model.context_window", () => {
150
+ expect(
151
+ getContextWindow(
152
+ { id: "x", context_window: 32_000 },
153
+ { id: "x", limit: { context: 100_000 } },
154
+ ),
155
+ ).toBe(32_000);
156
+ });
157
+
158
+ it("falls back to model.contextWindow", () => {
159
+ expect(getContextWindow({ id: "x", contextWindow: 64_000 })).toBe(64_000);
160
+ });
161
+
162
+ it("falls back to devModel.limit.context", () => {
163
+ expect(
164
+ getContextWindow({ id: "x" }, { id: "x", limit: { context: 200_000 } }),
165
+ ).toBe(200_000);
166
+ });
167
+
168
+ it("falls back to DEFAULT_CONTEXT_WINDOW", () => {
169
+ expect(getContextWindow({ id: "x" })).toBe(DEFAULT_CONTEXT_WINDOW);
170
+ });
171
+ });
172
+
173
+ // ── getMaxTokens ──────────────────────────────────────────────────────────────
174
+
175
+ describe("getMaxTokens", () => {
176
+ it("prefers model.max_tokens", () => {
177
+ expect(
178
+ getMaxTokens(
179
+ { id: "x", max_tokens: 4_096 },
180
+ { id: "x", limit: { output: 32_000 } },
181
+ ),
182
+ ).toBe(4_096);
183
+ });
184
+
185
+ it("falls back to model.maxTokens", () => {
186
+ expect(getMaxTokens({ id: "x", maxTokens: 8_192 })).toBe(8_192);
187
+ });
188
+
189
+ it("falls back to devModel.limit.output", () => {
190
+ expect(
191
+ getMaxTokens({ id: "x" }, { id: "x", limit: { output: 16_000 } }),
192
+ ).toBe(16_000);
193
+ });
194
+
195
+ it("falls back to DEFAULT_MAX_TOKENS", () => {
196
+ expect(getMaxTokens({ id: "x" })).toBe(DEFAULT_MAX_TOKENS);
197
+ });
198
+ });
199
+
200
+ // ── getReasoning ──────────────────────────────────────────────────────────────
201
+
202
+ describe("getReasoning", () => {
203
+ it("uses devModel.reasoning when boolean true", () => {
204
+ expect(getReasoning({ id: "x" }, { id: "x", reasoning: true })).toBe(true);
205
+ });
206
+
207
+ it("uses devModel.reasoning when boolean false", () => {
208
+ // even if pattern would match, devModel wins
209
+ expect(
210
+ getReasoning(
211
+ { id: "claude-opus-4" },
212
+ { id: "claude-opus-4", reasoning: false },
213
+ ),
214
+ ).toBe(false);
215
+ });
216
+
217
+ it("falls back to pattern — opus", () => {
218
+ expect(getReasoning({ id: "claude-opus-4" })).toBe(true);
219
+ });
220
+
221
+ it("falls back to pattern — sonnet", () => {
222
+ expect(getReasoning({ id: "claude-sonnet-4-5" })).toBe(true);
223
+ });
224
+
225
+ it("falls back to pattern — thinking", () => {
226
+ expect(getReasoning({ id: "deepseek-thinking" })).toBe(true);
227
+ });
228
+
229
+ it("falls back to pattern — reasoner", () => {
230
+ expect(getReasoning({ id: "o1-reasoner" })).toBe(true);
231
+ });
232
+
233
+ it("returns false for plain model with no devModel", () => {
234
+ expect(getReasoning({ id: "llama-3-8b-instruct" })).toBe(false);
235
+ });
236
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * provider.ts — 9Router model provider
3
+ *
4
+ * Registers the "9router" provider in Pi, pulling live model list from the
5
+ * router API. Falls back to an empty model list if ROUTER_API_KEY is unset.
6
+ *
7
+ * Environment:
8
+ * ROUTER_API_BASE — override API base URL (default: https://9r.xynogen.xyz/v1)
9
+ * ROUTER_API_KEY — bearer token (required for live model list)
10
+ */
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ fetchModelsDevIndex,
15
+ lookupInIndex,
16
+ routerBaseUrl,
17
+ routerModels,
18
+ } from "./data.js";
19
+ import type { ModelsDevModel, RouterModel } from "./data.js";
20
+
21
+ const DEFAULT_CONTEXT_WINDOW = 128_000;
22
+ const DEFAULT_MAX_TOKENS = 16_384;
23
+
24
+ const ZERO_COST = {
25
+ input: 0,
26
+ output: 0,
27
+ cacheRead: 0,
28
+ cacheWrite: 0,
29
+ };
30
+
31
+ // Fallback pattern-based detection if models.dev lookup fails
32
+ const IMAGE_CAPABLE_PATTERNS = [
33
+ /claude/i,
34
+ /gpt-5/i,
35
+ /gpt-4/i,
36
+ /kimi-k2/i,
37
+ /hy3/i,
38
+ ];
39
+
40
+ function getInputTypes(
41
+ model: RouterModel,
42
+ devModel?: ModelsDevModel,
43
+ ): ("text" | "image")[] {
44
+ if (devModel?.modalities?.input) {
45
+ const inputs = devModel.modalities.input.filter(
46
+ (i): i is "text" | "image" => i === "text" || i === "image",
47
+ );
48
+ if (inputs.length > 0) return inputs;
49
+ }
50
+ const id = model.id ?? "";
51
+ if (IMAGE_CAPABLE_PATTERNS.some((p) => p.test(id))) return ["text", "image"];
52
+ return ["text"];
53
+ }
54
+
55
+ function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
56
+ return model.name || devModel?.name || model.id || "unknown";
57
+ }
58
+
59
+ function getContextWindow(
60
+ model: RouterModel,
61
+ devModel?: ModelsDevModel,
62
+ ): number {
63
+ return (
64
+ model.context_window ||
65
+ model.contextWindow ||
66
+ devModel?.limit?.context ||
67
+ DEFAULT_CONTEXT_WINDOW
68
+ );
69
+ }
70
+
71
+ function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): number {
72
+ return (
73
+ model.max_tokens ||
74
+ model.maxTokens ||
75
+ devModel?.limit?.output ||
76
+ DEFAULT_MAX_TOKENS
77
+ );
78
+ }
79
+
80
+ function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
81
+ if (typeof devModel?.reasoning === "boolean") return devModel.reasoning;
82
+ return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(
83
+ model.id ?? "",
84
+ );
85
+ }
86
+
87
+ export default async function registerProvider(
88
+ pi: ExtensionAPI,
89
+ ): Promise<void> {
90
+ const apiKey = process.env.ROUTER_API_KEY;
91
+
92
+ if (!apiKey) {
93
+ // Register shell provider so the name is known; no models available yet.
94
+ pi.registerProvider("9router", {
95
+ name: "9Router",
96
+ baseUrl: routerBaseUrl(),
97
+ apiKey: "$ROUTER_API_KEY",
98
+ api: "openai-completions",
99
+ models: [],
100
+ });
101
+ return;
102
+ }
103
+
104
+ const [models, devIndex] = await Promise.all([
105
+ routerModels.get(),
106
+ fetchModelsDevIndex().catch(() => new Map<string, ModelsDevModel>()),
107
+ ]);
108
+
109
+ pi.registerProvider("9router", {
110
+ name: "9Router",
111
+ baseUrl: routerBaseUrl(),
112
+ apiKey: "$ROUTER_API_KEY",
113
+ api: "openai-completions",
114
+ headers: { "User-Agent": "pi-coding-agent" },
115
+ compat: {
116
+ supportsDeveloperRole: false,
117
+ supportsUsageInStreaming: false,
118
+ maxTokensField: "max_tokens",
119
+ },
120
+ models: models.map((model) => {
121
+ const devModel = lookupInIndex(model.id!, devIndex);
122
+ return {
123
+ id: model.id!,
124
+ name: getModelName(model, devModel),
125
+ reasoning: getReasoning(model, devModel),
126
+ input: getInputTypes(model, devModel),
127
+ cost: ZERO_COST,
128
+ contextWindow: getContextWindow(model, devModel),
129
+ maxTokens: getMaxTokens(model, devModel),
130
+ };
131
+ }),
132
+ });
133
+ }
package/src/search.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * search.ts — search tool via 9Router API, with curl fallback.
3
+ *
4
+ * Environment:
5
+ * ROUTER_API_BASE — router API base URL (default: https://9r.xynogen.xyz/v1)
6
+ * ROUTER_API_KEY — bearer token for the router
7
+ */
8
+
9
+ import { execFile } from "node:child_process";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { Type } from "typebox";
12
+ import { StringEnum } from "@earendil-works/pi-ai";
13
+ import { routerBaseUrl } from "./data.js";
14
+
15
+ const REQUEST_TIMEOUT_MS = 30_000;
16
+
17
+ function auth(): string | undefined {
18
+ return process.env.ROUTER_API_KEY;
19
+ }
20
+
21
+ async function apiPost(
22
+ path: string,
23
+ body: Record<string, unknown>,
24
+ signal?: AbortSignal,
25
+ ): Promise<string> {
26
+ const url = `${routerBaseUrl()}${path}`;
27
+ const key = auth();
28
+ const controller = new AbortController();
29
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
30
+ const s = signal
31
+ ? AbortSignal.any([signal, controller.signal])
32
+ : controller.signal;
33
+
34
+ try {
35
+ const res = await fetch(url, {
36
+ method: "POST",
37
+ headers: {
38
+ "Content-Type": "application/json",
39
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
40
+ },
41
+ body: JSON.stringify(body),
42
+ signal: s,
43
+ });
44
+ if (!res.ok) {
45
+ const errText = await res.text().catch(() => "");
46
+ throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
47
+ }
48
+ return await res.text();
49
+ } finally {
50
+ clearTimeout(timeout);
51
+ }
52
+ }
53
+
54
+ function curl(args: string[], stdin?: string): Promise<string> {
55
+ return new Promise((resolve, reject) => {
56
+ const child = execFile(
57
+ "curl",
58
+ ["-sS", "--connect-timeout", "10", "--max-time", "25", ...args],
59
+ { maxBuffer: 10 * 1024 * 1024, timeout: 30_000 },
60
+ (err, stdout, stderr) => {
61
+ if (err) {
62
+ const msg = (err as NodeJS.ErrnoException).killed
63
+ ? "curl timed out"
64
+ : `curl exit ${(err as NodeJS.ErrnoException).code ?? "??"}: ${stderr.slice(0, 300)}`;
65
+ reject(new Error(msg));
66
+ return;
67
+ }
68
+ resolve(stdout);
69
+ },
70
+ );
71
+ if (stdin && child.stdin) {
72
+ child.stdin.write(stdin);
73
+ child.stdin.end();
74
+ }
75
+ });
76
+ }
77
+
78
+ export default function registerSearch(pi: ExtensionAPI): void {
79
+ pi.registerTool({
80
+ name: "search",
81
+ label: "Search",
82
+ description:
83
+ "Web or news search via exa. Returns title, url, and snippet for each result.",
84
+ promptSnippet:
85
+ "search(query, search_type, max_results?) — search_type: 'web' or 'news'. Defaults to 5 results, max 10.",
86
+ promptGuidelines: [
87
+ "Use search when you need up-to-date information or facts from the web.",
88
+ "Set search_type='web' for general web results, search_type='news' for recent news articles.",
89
+ "Always prefer search over raw curl/browser requests for information lookup.",
90
+ ],
91
+ parameters: Type.Object({
92
+ query: Type.String({ description: "Search query" }),
93
+ search_type: StringEnum(["web", "news"] as const, {
94
+ description: "web or news search",
95
+ }),
96
+ max_results: Type.Optional(
97
+ Type.Number({
98
+ description: "Max results (default 5, max 10)",
99
+ default: 5,
100
+ }),
101
+ ),
102
+ }),
103
+
104
+ async execute(_toolCallId, params, signal, onUpdate) {
105
+ const max = Math.min(params.max_results ?? 5, 10);
106
+ const searchType = params.search_type ?? "web";
107
+ let apiMsg = "";
108
+
109
+ try {
110
+ onUpdate?.({
111
+ content: [
112
+ {
113
+ type: "text",
114
+ text: `Searching (${searchType}): ${params.query}...`,
115
+ },
116
+ ],
117
+ });
118
+
119
+ const raw = await apiPost(
120
+ "/search",
121
+ {
122
+ model: "exa",
123
+ query: params.query,
124
+ search_type: searchType,
125
+ max_results: max,
126
+ },
127
+ signal,
128
+ );
129
+
130
+ let parsed: unknown;
131
+ try {
132
+ parsed = JSON.parse(raw);
133
+ } catch {
134
+ parsed = raw;
135
+ }
136
+
137
+ return {
138
+ content: [
139
+ {
140
+ type: "text",
141
+ text: JSON.stringify(parsed, null, 2).slice(0, 20_000),
142
+ },
143
+ ],
144
+ details: { source: "api" },
145
+ };
146
+ } catch (apiErr: unknown) {
147
+ apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
148
+ onUpdate?.({
149
+ content: [
150
+ {
151
+ type: "text",
152
+ text: `API failed: ${apiMsg}\nFalling back to curl...`,
153
+ },
154
+ ],
155
+ });
156
+ }
157
+
158
+ try {
159
+ const body = JSON.stringify({
160
+ model: "exa",
161
+ query: params.query,
162
+ search_type: searchType,
163
+ max_results: max,
164
+ });
165
+ const raw = await curl([
166
+ "-X",
167
+ "POST",
168
+ "-H",
169
+ "Content-Type: application/json",
170
+ ...(auth() ? ["-H", `Authorization: Bearer ${auth()}`] : []),
171
+ "-d",
172
+ body,
173
+ `${routerBaseUrl()}/search`,
174
+ ]);
175
+
176
+ let parsed: unknown;
177
+ try {
178
+ parsed = JSON.parse(raw);
179
+ } catch {
180
+ parsed = raw;
181
+ }
182
+
183
+ return {
184
+ content: [
185
+ {
186
+ type: "text",
187
+ text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${JSON.stringify(parsed, null, 2).slice(0, 19_500)}`,
188
+ },
189
+ ],
190
+ details: { source: "curl-fallback" },
191
+ };
192
+ } catch (curlErr: unknown) {
193
+ const curlMsg =
194
+ curlErr instanceof Error ? curlErr.message : String(curlErr);
195
+ return {
196
+ content: [
197
+ {
198
+ type: "text",
199
+ text: `Search failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
200
+ },
201
+ ],
202
+ details: { source: "failed" },
203
+ isError: true,
204
+ };
205
+ }
206
+ },
207
+ });
208
+ }