@xynogen/pix-9router 0.3.2 → 0.3.3
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/package.json +1 -1
- package/src/data.test.ts +8 -21
- package/src/data.ts +3 -9
- package/src/fetch.ts +5 -15
- package/src/format.test.ts +4 -12
- package/src/provider.test.ts +18 -58
- package/src/provider.ts +7 -33
- package/src/render.ts +4 -11
- package/src/search.ts +3 -8
- package/src/transcribe.test.ts +9 -45
- package/src/transcribe.ts +9 -28
package/package.json
CHANGED
package/src/data.test.ts
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
buildModelsDevIndex,
|
|
4
|
-
lookupInIndex,
|
|
5
|
-
type ModelGrepModel,
|
|
6
|
-
routerBaseUrl,
|
|
7
|
-
} from "./data.ts";
|
|
2
|
+
import { buildModelsDevIndex, lookupInIndex, type ModelGrepModel, routerBaseUrl } from "./data.ts";
|
|
8
3
|
|
|
9
4
|
// modelgrep-shaped fixture builder (buildModelsDevIndex now takes ModelGrepModel[]).
|
|
10
5
|
function mg(
|
|
@@ -92,33 +87,25 @@ describe("lookupInIndex", () => {
|
|
|
92
87
|
]);
|
|
93
88
|
|
|
94
89
|
it("finds exact match", () => {
|
|
95
|
-
expect(lookupInIndex("claude-sonnet-4-5", index)?.name).toBe(
|
|
96
|
-
"Claude Sonnet 4.5",
|
|
97
|
-
);
|
|
90
|
+
expect(lookupInIndex("claude-sonnet-4-5", index)?.name).toBe("Claude Sonnet 4.5");
|
|
98
91
|
});
|
|
99
92
|
|
|
100
93
|
it("finds by stripping provider prefix (router style: provider/model)", () => {
|
|
101
|
-
expect(lookupInIndex("anthropic/claude-opus-4", index)?.name).toBe(
|
|
102
|
-
"Claude Opus 4",
|
|
103
|
-
);
|
|
94
|
+
expect(lookupInIndex("anthropic/claude-opus-4", index)?.name).toBe("Claude Opus 4");
|
|
104
95
|
});
|
|
105
96
|
|
|
106
97
|
it("finds with deep prefix e.g. cc/claude-opus-4", () => {
|
|
107
|
-
expect(lookupInIndex("cc/claude-opus-4", index)?.name).toBe(
|
|
108
|
-
"Claude Opus 4",
|
|
109
|
-
);
|
|
98
|
+
expect(lookupInIndex("cc/claude-opus-4", index)?.name).toBe("Claude Opus 4");
|
|
110
99
|
});
|
|
111
100
|
|
|
112
101
|
it("finds with date suffix stripped", () => {
|
|
113
|
-
expect(lookupInIndex("claude-sonnet-4-5-20250514", index)?.name).toBe(
|
|
114
|
-
"Claude Sonnet 4.5",
|
|
115
|
-
);
|
|
102
|
+
expect(lookupInIndex("claude-sonnet-4-5-20250514", index)?.name).toBe("Claude Sonnet 4.5");
|
|
116
103
|
});
|
|
117
104
|
|
|
118
105
|
it("finds with provider prefix + date suffix", () => {
|
|
119
|
-
expect(
|
|
120
|
-
|
|
121
|
-
)
|
|
106
|
+
expect(lookupInIndex("anthropic/claude-sonnet-4-5-20250514", index)?.name).toBe(
|
|
107
|
+
"Claude Sonnet 4.5",
|
|
108
|
+
);
|
|
122
109
|
});
|
|
123
110
|
|
|
124
111
|
it("returns undefined for unknown model", () => {
|
package/src/data.ts
CHANGED
|
@@ -52,10 +52,7 @@ interface RouterModelsResponse {
|
|
|
52
52
|
const ROUTER_DEFAULT_BASE = "https://9router.example.com/v1";
|
|
53
53
|
|
|
54
54
|
export function routerBaseUrl(): string {
|
|
55
|
-
return (process.env.ROUTER_API_BASE || ROUTER_DEFAULT_BASE).replace(
|
|
56
|
-
/\/$/,
|
|
57
|
-
"",
|
|
58
|
-
);
|
|
55
|
+
return (process.env.ROUTER_API_BASE || ROUTER_DEFAULT_BASE).replace(/\/$/, "");
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
// ── Router models ──────────────────────────────────────────────────────────────
|
|
@@ -70,12 +67,9 @@ export const routerModels = new DataSource<RouterModel[]>({
|
|
|
70
67
|
skip: () => !process.env.ROUTER_API_KEY,
|
|
71
68
|
cachePath: join(CACHE_DIR, "9router.json"),
|
|
72
69
|
ttlMs: 30 * 60 * 1000, // 30 minutes
|
|
73
|
-
parse: (raw) =>
|
|
74
|
-
((raw as RouterModelsResponse).data ?? []).filter((m) => Boolean(m.id)),
|
|
70
|
+
parse: (raw) => ((raw as RouterModelsResponse).data ?? []).filter((m) => Boolean(m.id)),
|
|
75
71
|
parseCache: (data) =>
|
|
76
|
-
((data as RouterModelsResponse | undefined)?.data ?? []).filter((m) =>
|
|
77
|
-
Boolean(m.id),
|
|
78
|
-
),
|
|
72
|
+
((data as RouterModelsResponse | undefined)?.data ?? []).filter((m) => Boolean(m.id)),
|
|
79
73
|
empty: [],
|
|
80
74
|
});
|
|
81
75
|
|
package/src/fetch.ts
CHANGED
|
@@ -28,9 +28,7 @@ async function apiPost(
|
|
|
28
28
|
const key = auth();
|
|
29
29
|
const controller = new AbortController();
|
|
30
30
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
31
|
-
const s = signal
|
|
32
|
-
? AbortSignal.any([signal, controller.signal])
|
|
33
|
-
: controller.signal;
|
|
31
|
+
const s = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
34
32
|
|
|
35
33
|
try {
|
|
36
34
|
const res = await fetch(url, {
|
|
@@ -93,10 +91,7 @@ export function formatFetchResult(raw: string): string {
|
|
|
93
91
|
content?: { text?: string | null } | string | null;
|
|
94
92
|
};
|
|
95
93
|
|
|
96
|
-
const text =
|
|
97
|
-
typeof obj.content === "string"
|
|
98
|
-
? obj.content
|
|
99
|
-
: (obj.content?.text ?? undefined);
|
|
94
|
+
const text = typeof obj.content === "string" ? obj.content : (obj.content?.text ?? undefined);
|
|
100
95
|
if (typeof text !== "string") return raw;
|
|
101
96
|
|
|
102
97
|
const header: string[] = [];
|
|
@@ -110,15 +105,13 @@ export default function registerFetch(pi: ExtensionAPI): void {
|
|
|
110
105
|
pi.registerTool({
|
|
111
106
|
name: "fetch",
|
|
112
107
|
label: "Fetch",
|
|
113
|
-
description:
|
|
114
|
-
"Fetch a web page as markdown, text, or raw HTML via exa. Returns page content.",
|
|
108
|
+
description: "Fetch a web page as markdown, text, or raw HTML via exa. Returns page content.",
|
|
115
109
|
promptSnippet:
|
|
116
110
|
"fetch(url, format, max_characters?) — format: 'markdown', 'text', or 'html'. Read page content via exa.",
|
|
117
111
|
promptGuidelines: [
|
|
118
112
|
"Use fetch when you need to read the full content of a specific URL.",
|
|
119
113
|
"Prefer format='markdown' for readable content, 'text' for plain text extraction, 'html' for raw source.",
|
|
120
114
|
"Set max_characters to cap response size (default 1000, 0 = unlimited). Use 5000-10000 for typical pages.",
|
|
121
|
-
"Always prefer fetch over raw curl/browser for reading page content.",
|
|
122
115
|
],
|
|
123
116
|
renderCall: makeRenderCall("fetch", (args) => String(args.url ?? "")),
|
|
124
117
|
renderResult: makeRenderResult(),
|
|
@@ -142,9 +135,7 @@ export default function registerFetch(pi: ExtensionAPI): void {
|
|
|
142
135
|
|
|
143
136
|
try {
|
|
144
137
|
onUpdate?.({
|
|
145
|
-
content: [
|
|
146
|
-
{ type: "text", text: `Fetching (${fmt}): ${params.url}...` },
|
|
147
|
-
],
|
|
138
|
+
content: [{ type: "text", text: `Fetching (${fmt}): ${params.url}...` }],
|
|
148
139
|
details: undefined,
|
|
149
140
|
});
|
|
150
141
|
|
|
@@ -203,8 +194,7 @@ export default function registerFetch(pi: ExtensionAPI): void {
|
|
|
203
194
|
details: { source: "curl-fallback", chars: html.length },
|
|
204
195
|
};
|
|
205
196
|
} catch (curlErr: unknown) {
|
|
206
|
-
const curlMsg =
|
|
207
|
-
curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
197
|
+
const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
208
198
|
return {
|
|
209
199
|
content: [
|
|
210
200
|
{
|
package/src/format.test.ts
CHANGED
|
@@ -36,9 +36,7 @@ describe("formatSearchResults", () => {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
it("handles empty results", () => {
|
|
39
|
-
expect(formatSearchResults(JSON.stringify({ results: [] }))).toBe(
|
|
40
|
-
"No results.",
|
|
41
|
-
);
|
|
39
|
+
expect(formatSearchResults(JSON.stringify({ results: [] }))).toBe("No results.");
|
|
42
40
|
});
|
|
43
41
|
|
|
44
42
|
it("falls back to pretty JSON for unexpected shapes", () => {
|
|
@@ -52,14 +50,10 @@ describe("formatSearchResults", () => {
|
|
|
52
50
|
|
|
53
51
|
it("truncates long snippets to 300 chars", () => {
|
|
54
52
|
const raw = JSON.stringify({
|
|
55
|
-
results: [
|
|
56
|
-
{ title: "T", url: "https://x.dev", snippet: "y".repeat(1000) },
|
|
57
|
-
],
|
|
53
|
+
results: [{ title: "T", url: "https://x.dev", snippet: "y".repeat(1000) }],
|
|
58
54
|
});
|
|
59
55
|
const out = formatSearchResults(raw);
|
|
60
|
-
const snippetLine = out
|
|
61
|
-
.split("\n")
|
|
62
|
-
.find((l) => l.trimStart().startsWith("yyy"));
|
|
56
|
+
const snippetLine = out.split("\n").find((l) => l.trimStart().startsWith("yyy"));
|
|
63
57
|
expect(snippetLine?.trim().length).toBe(300);
|
|
64
58
|
});
|
|
65
59
|
});
|
|
@@ -74,9 +68,7 @@ describe("formatFetchResult", () => {
|
|
|
74
68
|
});
|
|
75
69
|
|
|
76
70
|
const out = formatFetchResult(raw);
|
|
77
|
-
expect(out).toBe(
|
|
78
|
-
"# Example Page\nURL: https://example.com\n\n# Hello\n\nBody text.",
|
|
79
|
-
);
|
|
71
|
+
expect(out).toBe("# Example Page\nURL: https://example.com\n\n# Hello\n\nBody text.");
|
|
80
72
|
});
|
|
81
73
|
|
|
82
74
|
it("supports content as a plain string", () => {
|
package/src/provider.test.ts
CHANGED
|
@@ -8,18 +8,9 @@ import type { ModelsDevModel, RouterModel } from "./data.ts";
|
|
|
8
8
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
9
9
|
const DEFAULT_MAX_TOKENS = 16_384;
|
|
10
10
|
|
|
11
|
-
const IMAGE_CAPABLE_PATTERNS = [
|
|
12
|
-
|
|
13
|
-
|
|
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")[] {
|
|
11
|
+
const IMAGE_CAPABLE_PATTERNS = [/claude/i, /gpt-5/i, /gpt-4/i, /kimi-k2/i, /hy3/i];
|
|
12
|
+
|
|
13
|
+
function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" | "image")[] {
|
|
23
14
|
if (devModel?.modalities?.input) {
|
|
24
15
|
const inputs = devModel.modalities.input.filter(
|
|
25
16
|
(i): i is "text" | "image" => i === "text" || i === "image",
|
|
@@ -35,10 +26,7 @@ function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
|
|
|
35
26
|
return model.name || devModel?.name || model.id || "unknown";
|
|
36
27
|
}
|
|
37
28
|
|
|
38
|
-
function getContextWindow(
|
|
39
|
-
model: RouterModel,
|
|
40
|
-
devModel?: ModelsDevModel,
|
|
41
|
-
): number {
|
|
29
|
+
function getContextWindow(model: RouterModel, devModel?: ModelsDevModel): number {
|
|
42
30
|
return (
|
|
43
31
|
model.context_window ||
|
|
44
32
|
model.contextWindow ||
|
|
@@ -48,19 +36,12 @@ function getContextWindow(
|
|
|
48
36
|
}
|
|
49
37
|
|
|
50
38
|
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
|
-
);
|
|
39
|
+
return model.max_tokens || model.maxTokens || devModel?.limit?.output || DEFAULT_MAX_TOKENS;
|
|
57
40
|
}
|
|
58
41
|
|
|
59
42
|
function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
|
|
60
43
|
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
|
-
);
|
|
44
|
+
return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(model.id ?? "");
|
|
64
45
|
}
|
|
65
46
|
|
|
66
47
|
// ── getInputTypes ────────────────────────────────────────────────────────────
|
|
@@ -85,10 +66,7 @@ describe("getInputTypes", () => {
|
|
|
85
66
|
});
|
|
86
67
|
|
|
87
68
|
it("falls back to pattern match for claude", () => {
|
|
88
|
-
expect(getInputTypes({ id: "claude-sonnet-4-5" })).toEqual([
|
|
89
|
-
"text",
|
|
90
|
-
"image",
|
|
91
|
-
]);
|
|
69
|
+
expect(getInputTypes({ id: "claude-sonnet-4-5" })).toEqual(["text", "image"]);
|
|
92
70
|
});
|
|
93
71
|
|
|
94
72
|
it("falls back to pattern match for gpt-4o", () => {
|
|
@@ -96,10 +74,7 @@ describe("getInputTypes", () => {
|
|
|
96
74
|
});
|
|
97
75
|
|
|
98
76
|
it("falls back to pattern match for kimi-k2", () => {
|
|
99
|
-
expect(getInputTypes({ id: "kimi-k2-instruct" })).toEqual([
|
|
100
|
-
"text",
|
|
101
|
-
"image",
|
|
102
|
-
]);
|
|
77
|
+
expect(getInputTypes({ id: "kimi-k2-instruct" })).toEqual(["text", "image"]);
|
|
103
78
|
});
|
|
104
79
|
|
|
105
80
|
it("returns text-only for unknown model with no devModel", () => {
|
|
@@ -120,18 +95,13 @@ describe("getInputTypes", () => {
|
|
|
120
95
|
|
|
121
96
|
describe("getModelName", () => {
|
|
122
97
|
it("prefers router model.name", () => {
|
|
123
|
-
expect(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
{ id: "x", name: "Dev Name" },
|
|
127
|
-
),
|
|
128
|
-
).toBe("Router Name");
|
|
98
|
+
expect(getModelName({ id: "x", name: "Router Name" }, { id: "x", name: "Dev Name" })).toBe(
|
|
99
|
+
"Router Name",
|
|
100
|
+
);
|
|
129
101
|
});
|
|
130
102
|
|
|
131
103
|
it("falls back to devModel.name", () => {
|
|
132
|
-
expect(getModelName({ id: "x" }, { id: "x", name: "Dev Name" })).toBe(
|
|
133
|
-
"Dev Name",
|
|
134
|
-
);
|
|
104
|
+
expect(getModelName({ id: "x" }, { id: "x", name: "Dev Name" })).toBe("Dev Name");
|
|
135
105
|
});
|
|
136
106
|
|
|
137
107
|
it("falls back to model.id", () => {
|
|
@@ -160,9 +130,7 @@ describe("getContextWindow", () => {
|
|
|
160
130
|
});
|
|
161
131
|
|
|
162
132
|
it("falls back to devModel.limit.context", () => {
|
|
163
|
-
expect(
|
|
164
|
-
getContextWindow({ id: "x" }, { id: "x", limit: { context: 200_000 } }),
|
|
165
|
-
).toBe(200_000);
|
|
133
|
+
expect(getContextWindow({ id: "x" }, { id: "x", limit: { context: 200_000 } })).toBe(200_000);
|
|
166
134
|
});
|
|
167
135
|
|
|
168
136
|
it("falls back to DEFAULT_CONTEXT_WINDOW", () => {
|
|
@@ -175,10 +143,7 @@ describe("getContextWindow", () => {
|
|
|
175
143
|
describe("getMaxTokens", () => {
|
|
176
144
|
it("prefers model.max_tokens", () => {
|
|
177
145
|
expect(
|
|
178
|
-
getMaxTokens(
|
|
179
|
-
{ id: "x", max_tokens: 4_096 },
|
|
180
|
-
{ id: "x", limit: { output: 32_000 } },
|
|
181
|
-
),
|
|
146
|
+
getMaxTokens({ id: "x", max_tokens: 4_096 }, { id: "x", limit: { output: 32_000 } }),
|
|
182
147
|
).toBe(4_096);
|
|
183
148
|
});
|
|
184
149
|
|
|
@@ -187,9 +152,7 @@ describe("getMaxTokens", () => {
|
|
|
187
152
|
});
|
|
188
153
|
|
|
189
154
|
it("falls back to devModel.limit.output", () => {
|
|
190
|
-
expect(
|
|
191
|
-
getMaxTokens({ id: "x" }, { id: "x", limit: { output: 16_000 } }),
|
|
192
|
-
).toBe(16_000);
|
|
155
|
+
expect(getMaxTokens({ id: "x" }, { id: "x", limit: { output: 16_000 } })).toBe(16_000);
|
|
193
156
|
});
|
|
194
157
|
|
|
195
158
|
it("falls back to DEFAULT_MAX_TOKENS", () => {
|
|
@@ -206,12 +169,9 @@ describe("getReasoning", () => {
|
|
|
206
169
|
|
|
207
170
|
it("uses devModel.reasoning when boolean false", () => {
|
|
208
171
|
// even if pattern would match, devModel wins
|
|
209
|
-
expect(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
{ id: "claude-opus-4", reasoning: false },
|
|
213
|
-
),
|
|
214
|
-
).toBe(false);
|
|
172
|
+
expect(getReasoning({ id: "claude-opus-4" }, { id: "claude-opus-4", reasoning: false })).toBe(
|
|
173
|
+
false,
|
|
174
|
+
);
|
|
215
175
|
});
|
|
216
176
|
|
|
217
177
|
it("falls back to pattern — opus", () => {
|
package/src/provider.ts
CHANGED
|
@@ -11,12 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import type { ModelsDevModel, RouterModel } from "./data.js";
|
|
14
|
-
import {
|
|
15
|
-
fetchModelsDevIndex,
|
|
16
|
-
lookupInIndex,
|
|
17
|
-
routerBaseUrl,
|
|
18
|
-
routerModels,
|
|
19
|
-
} from "./data.js";
|
|
14
|
+
import { fetchModelsDevIndex, lookupInIndex, routerBaseUrl, routerModels } from "./data.js";
|
|
20
15
|
|
|
21
16
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
22
17
|
const DEFAULT_MAX_TOKENS = 16_384;
|
|
@@ -29,18 +24,9 @@ const ZERO_COST = {
|
|
|
29
24
|
};
|
|
30
25
|
|
|
31
26
|
// 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
|
-
];
|
|
27
|
+
const IMAGE_CAPABLE_PATTERNS = [/claude/i, /gpt-5/i, /gpt-4/i, /kimi-k2/i, /hy3/i];
|
|
39
28
|
|
|
40
|
-
function getInputTypes(
|
|
41
|
-
model: RouterModel,
|
|
42
|
-
devModel?: ModelsDevModel,
|
|
43
|
-
): ("text" | "image")[] {
|
|
29
|
+
function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" | "image")[] {
|
|
44
30
|
if (devModel?.modalities?.input) {
|
|
45
31
|
const inputs = devModel.modalities.input.filter(
|
|
46
32
|
(i): i is "text" | "image" => i === "text" || i === "image",
|
|
@@ -56,10 +42,7 @@ function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
|
|
|
56
42
|
return model.name || devModel?.name || model.id || "unknown";
|
|
57
43
|
}
|
|
58
44
|
|
|
59
|
-
function getContextWindow(
|
|
60
|
-
model: RouterModel,
|
|
61
|
-
devModel?: ModelsDevModel,
|
|
62
|
-
): number {
|
|
45
|
+
function getContextWindow(model: RouterModel, devModel?: ModelsDevModel): number {
|
|
63
46
|
return (
|
|
64
47
|
model.context_window ||
|
|
65
48
|
model.contextWindow ||
|
|
@@ -69,24 +52,15 @@ function getContextWindow(
|
|
|
69
52
|
}
|
|
70
53
|
|
|
71
54
|
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
|
-
);
|
|
55
|
+
return model.max_tokens || model.maxTokens || devModel?.limit?.output || DEFAULT_MAX_TOKENS;
|
|
78
56
|
}
|
|
79
57
|
|
|
80
58
|
function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
|
|
81
59
|
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
|
-
);
|
|
60
|
+
return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(model.id ?? "");
|
|
85
61
|
}
|
|
86
62
|
|
|
87
|
-
export default async function registerProvider(
|
|
88
|
-
pi: ExtensionAPI,
|
|
89
|
-
): Promise<void> {
|
|
63
|
+
export default async function registerProvider(pi: ExtensionAPI): Promise<void> {
|
|
90
64
|
const apiKey = process.env.ROUTER_API_KEY;
|
|
91
65
|
|
|
92
66
|
if (!apiKey) {
|
package/src/render.ts
CHANGED
|
@@ -37,15 +37,13 @@ const MAX_PREVIEW_LINES = 32;
|
|
|
37
37
|
|
|
38
38
|
let TextComponent: TextCtor | undefined;
|
|
39
39
|
try {
|
|
40
|
-
TextComponent = (require("@earendil-works/pi-tui") as { Text: TextCtor })
|
|
41
|
-
.Text;
|
|
40
|
+
TextComponent = (require("@earendil-works/pi-tui") as { Text: TextCtor }).Text;
|
|
42
41
|
} catch {
|
|
43
42
|
TextComponent = undefined;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
function getText(lastComponent: Component | undefined): TextLike {
|
|
47
|
-
if (lastComponent && "setText" in lastComponent)
|
|
48
|
-
return lastComponent as TextLike;
|
|
46
|
+
if (lastComponent && "setText" in lastComponent) return lastComponent as TextLike;
|
|
49
47
|
// TextComponent is always present in the interactive TUI (pi-tui peer); the
|
|
50
48
|
// require() guard only matters for headless/test contexts where renderers
|
|
51
49
|
// are never invoked.
|
|
@@ -71,16 +69,11 @@ function dimBody(body: string, theme: Theme, expanded: boolean): string {
|
|
|
71
69
|
}
|
|
72
70
|
|
|
73
71
|
/** Build a `renderCall` that shows `<title> <accent arg>`. */
|
|
74
|
-
export function makeRenderCall<TArgs>(
|
|
75
|
-
title: string,
|
|
76
|
-
pickArg: (args: TArgs) => string,
|
|
77
|
-
) {
|
|
72
|
+
export function makeRenderCall<TArgs>(title: string, pickArg: (args: TArgs) => string) {
|
|
78
73
|
return (args: TArgs, theme: Theme, ctx: RenderCtx): Component => {
|
|
79
74
|
const text = getText(ctx.lastComponent);
|
|
80
75
|
const arg = pickArg(args);
|
|
81
|
-
text.setText(
|
|
82
|
-
`${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("accent", arg)}`,
|
|
83
|
-
);
|
|
76
|
+
text.setText(`${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("accent", arg)}`);
|
|
84
77
|
return text;
|
|
85
78
|
};
|
|
86
79
|
}
|
package/src/search.ts
CHANGED
|
@@ -28,9 +28,7 @@ async function apiPost(
|
|
|
28
28
|
const key = auth();
|
|
29
29
|
const controller = new AbortController();
|
|
30
30
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
31
|
-
const s = signal
|
|
32
|
-
? AbortSignal.any([signal, controller.signal])
|
|
33
|
-
: controller.signal;
|
|
31
|
+
const s = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
34
32
|
|
|
35
33
|
try {
|
|
36
34
|
const res = await fetch(url, {
|
|
@@ -128,14 +126,12 @@ export default function registerSearch(pi: ExtensionAPI): void {
|
|
|
128
126
|
pi.registerTool({
|
|
129
127
|
name: "search",
|
|
130
128
|
label: "Search",
|
|
131
|
-
description:
|
|
132
|
-
"Web or news search via exa. Returns title, url, and snippet for each result.",
|
|
129
|
+
description: "Web or news search via exa. Returns title, url, and snippet for each result.",
|
|
133
130
|
promptSnippet:
|
|
134
131
|
"search(query, search_type, max_results?) — search_type: 'web' or 'news'. Defaults to 5 results, max 10.",
|
|
135
132
|
promptGuidelines: [
|
|
136
133
|
"Use search when you need up-to-date information or facts from the web.",
|
|
137
134
|
"Set search_type='web' for general web results, search_type='news' for recent news articles.",
|
|
138
|
-
"Always prefer search over raw curl/browser requests for information lookup.",
|
|
139
135
|
],
|
|
140
136
|
renderCall: makeRenderCall("search", (args) => String(args.query ?? "")),
|
|
141
137
|
renderResult: makeRenderResult(),
|
|
@@ -229,8 +225,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
|
|
|
229
225
|
details: { source: "curl-fallback" },
|
|
230
226
|
};
|
|
231
227
|
} catch (curlErr: unknown) {
|
|
232
|
-
const curlMsg =
|
|
233
|
-
curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
228
|
+
const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
234
229
|
return {
|
|
235
230
|
content: [
|
|
236
231
|
{
|
package/src/transcribe.test.ts
CHANGED
|
@@ -95,9 +95,7 @@ describe("parseTranscriptionResponse", () => {
|
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
it("returns plain text as-is when not JSON", () => {
|
|
98
|
-
expect(parseTranscriptionResponse("Just plain text")).toBe(
|
|
99
|
-
"Just plain text",
|
|
100
|
-
);
|
|
98
|
+
expect(parseTranscriptionResponse("Just plain text")).toBe("Just plain text");
|
|
101
99
|
});
|
|
102
100
|
|
|
103
101
|
it("returns empty string from JSON with empty text", () => {
|
|
@@ -203,12 +201,7 @@ describe("buildTranscriptionResult", () => {
|
|
|
203
201
|
|
|
204
202
|
it("returns inline text (truncated at 50_000) when no output_file is set", async () => {
|
|
205
203
|
const text = "a".repeat(60_000);
|
|
206
|
-
const result = await buildTranscriptionResult(
|
|
207
|
-
text,
|
|
208
|
-
"dg/nova-3",
|
|
209
|
-
"api",
|
|
210
|
-
undefined,
|
|
211
|
-
);
|
|
204
|
+
const result = await buildTranscriptionResult(text, "dg/nova-3", "api", undefined);
|
|
212
205
|
expect(result.content).toHaveLength(1);
|
|
213
206
|
expect(result.content[0]?.type).toBe("text");
|
|
214
207
|
expect(result.content[0]?.text.length).toBe(50_000);
|
|
@@ -221,12 +214,7 @@ describe("buildTranscriptionResult", () => {
|
|
|
221
214
|
|
|
222
215
|
it("returns inline text (untruncated) when short and no output_file", async () => {
|
|
223
216
|
const text = "short transcript";
|
|
224
|
-
const result = await buildTranscriptionResult(
|
|
225
|
-
text,
|
|
226
|
-
"dg/nova-3",
|
|
227
|
-
"api",
|
|
228
|
-
undefined,
|
|
229
|
-
);
|
|
217
|
+
const result = await buildTranscriptionResult(text, "dg/nova-3", "api", undefined);
|
|
230
218
|
expect(result.content[0]?.text).toBe("short transcript");
|
|
231
219
|
expect(result.details.chars).toBe(text.length);
|
|
232
220
|
});
|
|
@@ -234,12 +222,7 @@ describe("buildTranscriptionResult", () => {
|
|
|
234
222
|
it("writes full text to file and returns short path summary when output_file is set", async () => {
|
|
235
223
|
const text = "a".repeat(100_000); // way over 50k
|
|
236
224
|
const file = join(tmpRoot, "result-a.txt");
|
|
237
|
-
const result = await buildTranscriptionResult(
|
|
238
|
-
text,
|
|
239
|
-
"dg/nova-3",
|
|
240
|
-
"api",
|
|
241
|
-
file,
|
|
242
|
-
);
|
|
225
|
+
const result = await buildTranscriptionResult(text, "dg/nova-3", "api", file);
|
|
243
226
|
|
|
244
227
|
// content is a short summary, not the full text
|
|
245
228
|
expect(result.content).toHaveLength(1);
|
|
@@ -261,12 +244,7 @@ describe("buildTranscriptionResult", () => {
|
|
|
261
244
|
|
|
262
245
|
it("passes through curl-fallback source label", async () => {
|
|
263
246
|
const text = "hi";
|
|
264
|
-
const result = await buildTranscriptionResult(
|
|
265
|
-
text,
|
|
266
|
-
"dg/nova-3",
|
|
267
|
-
"curl-fallback",
|
|
268
|
-
undefined,
|
|
269
|
-
);
|
|
247
|
+
const result = await buildTranscriptionResult(text, "dg/nova-3", "curl-fallback", undefined);
|
|
270
248
|
expect(result.details.source).toBe("curl-fallback");
|
|
271
249
|
});
|
|
272
250
|
|
|
@@ -274,12 +252,7 @@ describe("buildTranscriptionResult", () => {
|
|
|
274
252
|
const text = "relative path content";
|
|
275
253
|
const relDir = join(tmpRoot, "rel", "sub");
|
|
276
254
|
const relFile = join(relDir, "out.txt");
|
|
277
|
-
const result = await buildTranscriptionResult(
|
|
278
|
-
text,
|
|
279
|
-
"dg/nova-3",
|
|
280
|
-
"api",
|
|
281
|
-
relFile,
|
|
282
|
-
);
|
|
255
|
+
const result = await buildTranscriptionResult(text, "dg/nova-3", "api", relFile);
|
|
283
256
|
|
|
284
257
|
expect(result.details.output_path).toBe(relFile);
|
|
285
258
|
expect(await readFile(relFile, "utf-8")).toBe("relative path content");
|
|
@@ -302,9 +275,7 @@ describe("validateOutputPath", () => {
|
|
|
302
275
|
});
|
|
303
276
|
|
|
304
277
|
it("accepts a fresh path several levels deep", async () => {
|
|
305
|
-
const result = await validateOutputPath(
|
|
306
|
-
join(tmpRoot, "a", "b", "c", "deep.txt"),
|
|
307
|
-
);
|
|
278
|
+
const result = await validateOutputPath(join(tmpRoot, "a", "b", "c", "deep.txt"));
|
|
308
279
|
expect(result.ok).toBe(true);
|
|
309
280
|
});
|
|
310
281
|
|
|
@@ -404,9 +375,7 @@ describe("writeTranscriptionFile — rejection propagation", () => {
|
|
|
404
375
|
});
|
|
405
376
|
|
|
406
377
|
it("throws on null byte", async () => {
|
|
407
|
-
await expect(
|
|
408
|
-
writeTranscriptionFile("/tmp/pix-test-\0x.txt", "x"),
|
|
409
|
-
).rejects.toThrow(/null byte/);
|
|
378
|
+
await expect(writeTranscriptionFile("/tmp/pix-test-\0x.txt", "x")).rejects.toThrow(/null byte/);
|
|
410
379
|
});
|
|
411
380
|
});
|
|
412
381
|
|
|
@@ -415,12 +384,7 @@ describe("writeTranscriptionFile — rejection propagation", () => {
|
|
|
415
384
|
describe("buildTranscriptionResult — write failure path", () => {
|
|
416
385
|
it("returns isError + inline fallback when output_file is rejected", async () => {
|
|
417
386
|
const text = "the actual transcription that was successfully produced";
|
|
418
|
-
const result = await buildTranscriptionResult(
|
|
419
|
-
text,
|
|
420
|
-
"dg/nova-3",
|
|
421
|
-
"api",
|
|
422
|
-
"/etc/passwd",
|
|
423
|
-
);
|
|
387
|
+
const result = await buildTranscriptionResult(text, "dg/nova-3", "api", "/etc/passwd");
|
|
424
388
|
expect(result.isError).toBe(true);
|
|
425
389
|
expect(result.details.write_error).toMatch(/refusing to write/);
|
|
426
390
|
expect(result.details.output_path).toBeUndefined();
|
package/src/transcribe.ts
CHANGED
|
@@ -55,9 +55,7 @@ async function apiMultipart(
|
|
|
55
55
|
const key = auth();
|
|
56
56
|
const controller = new AbortController();
|
|
57
57
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
58
|
-
const s = signal
|
|
59
|
-
? AbortSignal.any([signal, controller.signal])
|
|
60
|
-
: controller.signal;
|
|
58
|
+
const s = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
61
59
|
|
|
62
60
|
try {
|
|
63
61
|
const fileData = await readFile(filePath);
|
|
@@ -119,9 +117,7 @@ export function parseTranscriptionResponse(raw: string): string {
|
|
|
119
117
|
|
|
120
118
|
/** Resolve a possibly-relative `output_file` to an absolute path. */
|
|
121
119
|
export function resolveOutputPath(outputFile: string): string {
|
|
122
|
-
return isAbsolute(outputFile)
|
|
123
|
-
? outputFile
|
|
124
|
-
: resolve(process.cwd(), outputFile);
|
|
120
|
+
return isAbsolute(outputFile) ? outputFile : resolve(process.cwd(), outputFile);
|
|
125
121
|
}
|
|
126
122
|
|
|
127
123
|
/**
|
|
@@ -132,9 +128,7 @@ export function resolveOutputPath(outputFile: string): string {
|
|
|
132
128
|
* Returns a discriminated result so the caller can surface a precise reason to
|
|
133
129
|
* the model without leaking OS internals.
|
|
134
130
|
*/
|
|
135
|
-
export type PathValidation =
|
|
136
|
-
| { ok: true; path: string }
|
|
137
|
-
| { ok: false; reason: string };
|
|
131
|
+
export type PathValidation = { ok: true; path: string } | { ok: false; reason: string };
|
|
138
132
|
|
|
139
133
|
/** Block-list of absolute prefixes that should never receive transcription output. */
|
|
140
134
|
function sensitivePrefixes(): string[] {
|
|
@@ -151,9 +145,7 @@ function sensitivePrefixes(): string[] {
|
|
|
151
145
|
];
|
|
152
146
|
}
|
|
153
147
|
|
|
154
|
-
export async function validateOutputPath(
|
|
155
|
-
absPath: string,
|
|
156
|
-
): Promise<PathValidation> {
|
|
148
|
+
export async function validateOutputPath(absPath: string): Promise<PathValidation> {
|
|
157
149
|
// 1. Null byte injection guard (defence in depth — Node already rejects, fail fast with clear msg)
|
|
158
150
|
if (absPath.includes("\0")) {
|
|
159
151
|
return { ok: false, reason: "path contains a null byte" };
|
|
@@ -240,10 +232,7 @@ export async function validateOutputPath(
|
|
|
240
232
|
|
|
241
233
|
/** Write transcription text to disk, creating parent directories as needed.
|
|
242
234
|
* Performs pre-flight safety checks; throws on rejection. */
|
|
243
|
-
export async function writeTranscriptionFile(
|
|
244
|
-
outputFile: string,
|
|
245
|
-
text: string,
|
|
246
|
-
): Promise<string> {
|
|
235
|
+
export async function writeTranscriptionFile(outputFile: string, text: string): Promise<string> {
|
|
247
236
|
const abs = resolveOutputPath(outputFile);
|
|
248
237
|
const validation = await validateOutputPath(abs);
|
|
249
238
|
if (!validation.ok) {
|
|
@@ -289,8 +278,7 @@ export async function buildTranscriptionResult(
|
|
|
289
278
|
details,
|
|
290
279
|
};
|
|
291
280
|
} catch (writeErr) {
|
|
292
|
-
const msg =
|
|
293
|
-
writeErr instanceof Error ? writeErr.message : String(writeErr);
|
|
281
|
+
const msg = writeErr instanceof Error ? writeErr.message : String(writeErr);
|
|
294
282
|
details.write_error = msg;
|
|
295
283
|
return {
|
|
296
284
|
content: [
|
|
@@ -348,8 +336,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
348
336
|
),
|
|
349
337
|
language: Type.Optional(
|
|
350
338
|
Type.String({
|
|
351
|
-
description:
|
|
352
|
-
"ISO 639-1 language code (e.g. 'en', 'es', 'fr') for better accuracy",
|
|
339
|
+
description: "ISO 639-1 language code (e.g. 'en', 'es', 'fr') for better accuracy",
|
|
353
340
|
}),
|
|
354
341
|
),
|
|
355
342
|
}),
|
|
@@ -361,12 +348,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
361
348
|
let apiMsg = "";
|
|
362
349
|
|
|
363
350
|
const run = async (source: "api" | "curl-fallback", raw: string) =>
|
|
364
|
-
buildTranscriptionResult(
|
|
365
|
-
parseTranscriptionResponse(raw),
|
|
366
|
-
model,
|
|
367
|
-
source,
|
|
368
|
-
outputFile,
|
|
369
|
-
);
|
|
351
|
+
buildTranscriptionResult(parseTranscriptionResponse(raw), model, source, outputFile);
|
|
370
352
|
|
|
371
353
|
try {
|
|
372
354
|
onUpdate?.({
|
|
@@ -418,8 +400,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
418
400
|
const raw = await curl(curlArgs);
|
|
419
401
|
return await run("curl-fallback", raw);
|
|
420
402
|
} catch (curlErr: unknown) {
|
|
421
|
-
const curlMsg =
|
|
422
|
-
curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
403
|
+
const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
423
404
|
return {
|
|
424
405
|
content: [
|
|
425
406
|
{
|