@xynogen/pix-9router 0.3.6 → 0.3.8
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 +4 -3
- package/src/data.ts +6 -0
- package/src/fetch.ts +1 -64
- package/src/http.ts +91 -0
- package/src/provider.test.ts +62 -45
- package/src/provider.ts +13 -6
- package/src/render.ts +1 -1
- package/src/search.ts +1 -67
- package/src/transcribe.ts +2 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-9router",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "Pi extension — 9Router provider + fetch/search tools via router API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -43,8 +43,9 @@
|
|
|
43
43
|
"@earendil-works/pi-tui": "*"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@xynogen/pix-data": "^0.
|
|
46
|
+
"@xynogen/pix-data": "^0.4.0",
|
|
47
47
|
"@xynogen/pix-pretty": "^1.7.21",
|
|
48
|
-
"typebox": "^1.1.38"
|
|
48
|
+
"typebox": "^1.1.38",
|
|
49
|
+
"@xynogen/pix-runtime": "^0.2.0"
|
|
49
50
|
}
|
|
50
51
|
}
|
package/src/data.ts
CHANGED
|
@@ -41,6 +41,12 @@ export interface RouterModel {
|
|
|
41
41
|
max_tokens?: number;
|
|
42
42
|
maxTokens?: number;
|
|
43
43
|
owned_by?: string;
|
|
44
|
+
/** 9router v1 API nests these inside capabilities */
|
|
45
|
+
capabilities?: {
|
|
46
|
+
contextWindow?: number;
|
|
47
|
+
maxOutput?: number;
|
|
48
|
+
[key: string]: unknown;
|
|
49
|
+
};
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
interface RouterModelsResponse {
|
package/src/fetch.ts
CHANGED
|
@@ -6,15 +6,12 @@
|
|
|
6
6
|
* ROUTER_API_KEY — bearer token for the router
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { type ExecFileException, execFile } from "node:child_process";
|
|
10
9
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
11
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
11
|
import { Type } from "typebox";
|
|
13
|
-
import {
|
|
12
|
+
import { apiPost, curl, isCancelled } from "./http.js";
|
|
14
13
|
import { makeRenderCall, makeRenderResult } from "./render.js";
|
|
15
14
|
|
|
16
|
-
const REQUEST_TIMEOUT_MS = 30_000;
|
|
17
|
-
|
|
18
15
|
type RouterOutcome = "running" | "fallback" | "success" | "cancelled" | "error";
|
|
19
16
|
type FetchFormat = "markdown" | "text" | "html";
|
|
20
17
|
|
|
@@ -45,62 +42,6 @@ interface FetchDependencies {
|
|
|
45
42
|
curl: typeof curl;
|
|
46
43
|
}
|
|
47
44
|
|
|
48
|
-
function auth(): string | undefined {
|
|
49
|
-
return process.env.ROUTER_API_KEY;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function apiPost(
|
|
53
|
-
path: string,
|
|
54
|
-
body: Record<string, unknown>,
|
|
55
|
-
signal?: AbortSignal,
|
|
56
|
-
): Promise<string> {
|
|
57
|
-
const url = `${routerBaseUrl()}${path}`;
|
|
58
|
-
const key = auth();
|
|
59
|
-
const controller = new AbortController();
|
|
60
|
-
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
61
|
-
const s = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
const res = await fetch(url, {
|
|
65
|
-
method: "POST",
|
|
66
|
-
headers: {
|
|
67
|
-
"Content-Type": "application/json",
|
|
68
|
-
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
69
|
-
},
|
|
70
|
-
body: JSON.stringify(body),
|
|
71
|
-
signal: s,
|
|
72
|
-
});
|
|
73
|
-
if (!res.ok) {
|
|
74
|
-
const errText = await res.text().catch(() => "");
|
|
75
|
-
throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
|
|
76
|
-
}
|
|
77
|
-
return await res.text();
|
|
78
|
-
} finally {
|
|
79
|
-
clearTimeout(timeout);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function curl(args: string[]): Promise<string> {
|
|
84
|
-
return new Promise((resolve, reject) => {
|
|
85
|
-
execFile(
|
|
86
|
-
"curl",
|
|
87
|
-
["-sS", "--connect-timeout", "10", "--max-time", "25", ...args],
|
|
88
|
-
{ maxBuffer: 10 * 1024 * 1024, timeout: 30_000 },
|
|
89
|
-
(err, stdout, stderr) => {
|
|
90
|
-
if (err) {
|
|
91
|
-
const e = err as ExecFileException;
|
|
92
|
-
const msg = e.killed
|
|
93
|
-
? "curl timed out"
|
|
94
|
-
: `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
|
|
95
|
-
reject(new Error(msg));
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
resolve(stdout);
|
|
99
|
-
},
|
|
100
|
-
);
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
45
|
/**
|
|
105
46
|
* Extract readable page content from the exa fetch JSON envelope.
|
|
106
47
|
* Shape: { provider, url, title, content: { format, text }, ... }
|
|
@@ -131,10 +72,6 @@ export function formatFetchResult(raw: string): string {
|
|
|
131
72
|
return header.length > 0 ? `${header.join("\n")}\n\n${text}` : text;
|
|
132
73
|
}
|
|
133
74
|
|
|
134
|
-
function isCancelled(error: unknown, signal: AbortSignal | undefined): boolean {
|
|
135
|
-
return signal?.aborted === true || (error instanceof DOMException && error.name === "AbortError");
|
|
136
|
-
}
|
|
137
|
-
|
|
138
75
|
export async function executeFetch(
|
|
139
76
|
params: FetchParams,
|
|
140
77
|
signal?: AbortSignal,
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http.ts — shared 9Router HTTP plumbing.
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type ExecFileException, execFile } from "node:child_process";
|
|
11
|
+
import { routerBaseUrl } from "./data.js";
|
|
12
|
+
|
|
13
|
+
/** Default request timeout — audio transcription overrides with a longer one. */
|
|
14
|
+
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
export function auth(): string | undefined {
|
|
17
|
+
return process.env.ROUTER_API_KEY;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** POST a JSON body to a router path, returning the raw response text. */
|
|
21
|
+
export async function apiPost(
|
|
22
|
+
path: string,
|
|
23
|
+
body: Record<string, unknown>,
|
|
24
|
+
signal?: AbortSignal,
|
|
25
|
+
timeoutMs: number = REQUEST_TIMEOUT_MS,
|
|
26
|
+
): Promise<string> {
|
|
27
|
+
const url = `${routerBaseUrl()}${path}`;
|
|
28
|
+
const key = auth();
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
31
|
+
const s = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(url, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
signal: s,
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
const errText = await res.text().catch(() => "");
|
|
45
|
+
throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
|
|
46
|
+
}
|
|
47
|
+
return await res.text();
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timeout);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CurlOptions {
|
|
54
|
+
/** curl --max-time in seconds (default 25). */
|
|
55
|
+
maxTime?: number;
|
|
56
|
+
/** child-process kill timeout in ms (default 30_000). */
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
/** Optional stdin body piped into curl. */
|
|
59
|
+
stdin?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Run curl with sane defaults, rejecting on non-zero exit or timeout. */
|
|
63
|
+
export function curl(args: string[], opts: CurlOptions = {}): Promise<string> {
|
|
64
|
+
const { maxTime = 25, timeoutMs = 30_000, stdin } = opts;
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const child = execFile(
|
|
67
|
+
"curl",
|
|
68
|
+
["-sS", "--connect-timeout", "10", "--max-time", String(maxTime), ...args],
|
|
69
|
+
{ maxBuffer: 10 * 1024 * 1024, timeout: timeoutMs },
|
|
70
|
+
(err, stdout, stderr) => {
|
|
71
|
+
if (err) {
|
|
72
|
+
const e = err as ExecFileException;
|
|
73
|
+
const msg = e.killed
|
|
74
|
+
? "curl timed out"
|
|
75
|
+
: `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
|
|
76
|
+
reject(new Error(msg));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
resolve(stdout);
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
if (stdin && child.stdin) {
|
|
83
|
+
child.stdin.write(stdin);
|
|
84
|
+
child.stdin.end();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isCancelled(error: unknown, signal: AbortSignal | undefined): boolean {
|
|
90
|
+
return signal?.aborted === true || (error instanceof DOMException && error.name === "AbortError");
|
|
91
|
+
}
|
package/src/provider.test.ts
CHANGED
|
@@ -1,48 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import type { ModelsDevModel, RouterModel } from "./data.ts";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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")[] {
|
|
14
|
-
if (devModel?.modalities?.input) {
|
|
15
|
-
const inputs = devModel.modalities.input.filter(
|
|
16
|
-
(i): i is "text" | "image" => i === "text" || i === "image",
|
|
17
|
-
);
|
|
18
|
-
if (inputs.length > 0) return inputs;
|
|
19
|
-
}
|
|
20
|
-
const id = model.id ?? "";
|
|
21
|
-
if (IMAGE_CAPABLE_PATTERNS.some((p) => p.test(id))) return ["text", "image"];
|
|
22
|
-
return ["text"];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
|
|
26
|
-
return model.name || devModel?.name || model.id || "unknown";
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function getContextWindow(model: RouterModel, devModel?: ModelsDevModel): number {
|
|
30
|
-
return (
|
|
31
|
-
model.context_window ||
|
|
32
|
-
model.contextWindow ||
|
|
33
|
-
devModel?.limit?.context ||
|
|
34
|
-
DEFAULT_CONTEXT_WINDOW
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): number {
|
|
39
|
-
return model.max_tokens || model.maxTokens || devModel?.limit?.output || DEFAULT_MAX_TOKENS;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
|
|
43
|
-
if (typeof devModel?.reasoning === "boolean") return devModel.reasoning;
|
|
44
|
-
return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(model.id ?? "");
|
|
45
|
-
}
|
|
3
|
+
import {
|
|
4
|
+
getContextWindow,
|
|
5
|
+
getInputTypes,
|
|
6
|
+
getMaxTokens,
|
|
7
|
+
getModelName,
|
|
8
|
+
getReasoning,
|
|
9
|
+
} from "./provider.ts";
|
|
46
10
|
|
|
47
11
|
// ── getInputTypes ────────────────────────────────────────────────────────────
|
|
48
12
|
|
|
@@ -129,12 +93,37 @@ describe("getContextWindow", () => {
|
|
|
129
93
|
expect(getContextWindow({ id: "x", contextWindow: 64_000 })).toBe(64_000);
|
|
130
94
|
});
|
|
131
95
|
|
|
96
|
+
it("falls back to capabilities.contextWindow (9router v1 nested shape)", () => {
|
|
97
|
+
expect(getContextWindow({ id: "x", capabilities: { contextWindow: 1_000_000 } })).toBe(
|
|
98
|
+
1_000_000,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("top-level fields beat capabilities.contextWindow", () => {
|
|
103
|
+
expect(
|
|
104
|
+
getContextWindow({
|
|
105
|
+
id: "x",
|
|
106
|
+
contextWindow: 64_000,
|
|
107
|
+
capabilities: { contextWindow: 1_000_000 },
|
|
108
|
+
}),
|
|
109
|
+
).toBe(64_000);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("capabilities.contextWindow beats devModel.limit.context", () => {
|
|
113
|
+
expect(
|
|
114
|
+
getContextWindow(
|
|
115
|
+
{ id: "x", capabilities: { contextWindow: 1_000_000 } },
|
|
116
|
+
{ id: "x", limit: { context: 200_000 } },
|
|
117
|
+
),
|
|
118
|
+
).toBe(1_000_000);
|
|
119
|
+
});
|
|
120
|
+
|
|
132
121
|
it("falls back to devModel.limit.context", () => {
|
|
133
122
|
expect(getContextWindow({ id: "x" }, { id: "x", limit: { context: 200_000 } })).toBe(200_000);
|
|
134
123
|
});
|
|
135
124
|
|
|
136
125
|
it("falls back to DEFAULT_CONTEXT_WINDOW", () => {
|
|
137
|
-
expect(getContextWindow({ id: "x" })).toBe(
|
|
126
|
+
expect(getContextWindow({ id: "x" })).toBe(128_000);
|
|
138
127
|
});
|
|
139
128
|
});
|
|
140
129
|
|
|
@@ -151,12 +140,40 @@ describe("getMaxTokens", () => {
|
|
|
151
140
|
expect(getMaxTokens({ id: "x", maxTokens: 8_192 })).toBe(8_192);
|
|
152
141
|
});
|
|
153
142
|
|
|
143
|
+
it("falls back to capabilities.maxOutput (9router v1 nested shape)", () => {
|
|
144
|
+
expect(getMaxTokens({ id: "x", capabilities: { maxOutput: 64_000 } })).toBe(64_000);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("top-level fields beat capabilities.maxOutput", () => {
|
|
148
|
+
expect(getMaxTokens({ id: "x", maxTokens: 8_192, capabilities: { maxOutput: 64_000 } })).toBe(
|
|
149
|
+
8_192,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("capabilities.maxOutput beats devModel.limit.output", () => {
|
|
154
|
+
expect(
|
|
155
|
+
getMaxTokens(
|
|
156
|
+
{ id: "x", capabilities: { maxOutput: 64_000 } },
|
|
157
|
+
{ id: "x", limit: { output: 16_000 } },
|
|
158
|
+
),
|
|
159
|
+
).toBe(64_000);
|
|
160
|
+
});
|
|
161
|
+
|
|
154
162
|
it("falls back to devModel.limit.output", () => {
|
|
155
163
|
expect(getMaxTokens({ id: "x" }, { id: "x", limit: { output: 16_000 } })).toBe(16_000);
|
|
156
164
|
});
|
|
157
165
|
|
|
158
166
|
it("falls back to DEFAULT_MAX_TOKENS", () => {
|
|
159
|
-
expect(getMaxTokens({ id: "x" })).toBe(
|
|
167
|
+
expect(getMaxTokens({ id: "x" })).toBe(16_384);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("handles the PR #9 regression case (qwen3-coder-flash nested capabilities)", () => {
|
|
171
|
+
const model: RouterModel = {
|
|
172
|
+
id: "alims-intl/qwen3-coder-flash-2025-07-28",
|
|
173
|
+
capabilities: { contextWindow: 1_000_000, maxOutput: 64_000 },
|
|
174
|
+
};
|
|
175
|
+
expect(getContextWindow(model)).toBe(1_000_000);
|
|
176
|
+
expect(getMaxTokens(model)).toBe(64_000);
|
|
160
177
|
});
|
|
161
178
|
});
|
|
162
179
|
|
package/src/provider.ts
CHANGED
|
@@ -26,7 +26,7 @@ const ZERO_COST = {
|
|
|
26
26
|
// Fallback pattern-based detection if models.dev lookup fails
|
|
27
27
|
const IMAGE_CAPABLE_PATTERNS = [/claude/i, /gpt-5/i, /gpt-4/i, /kimi-k2/i, /hy3/i];
|
|
28
28
|
|
|
29
|
-
function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" | "image")[] {
|
|
29
|
+
export function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" | "image")[] {
|
|
30
30
|
if (devModel?.modalities?.input) {
|
|
31
31
|
const inputs = devModel.modalities.input.filter(
|
|
32
32
|
(i): i is "text" | "image" => i === "text" || i === "image",
|
|
@@ -38,24 +38,31 @@ function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" |
|
|
|
38
38
|
return ["text"];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
|
|
41
|
+
export function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
|
|
42
42
|
return model.name || devModel?.name || model.id || "unknown";
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function getContextWindow(model: RouterModel, devModel?: ModelsDevModel): number {
|
|
45
|
+
export function getContextWindow(model: RouterModel, devModel?: ModelsDevModel): number {
|
|
46
46
|
return (
|
|
47
47
|
model.context_window ||
|
|
48
48
|
model.contextWindow ||
|
|
49
|
+
model.capabilities?.contextWindow ||
|
|
49
50
|
devModel?.limit?.context ||
|
|
50
51
|
DEFAULT_CONTEXT_WINDOW
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): number {
|
|
55
|
-
return
|
|
55
|
+
export function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): number {
|
|
56
|
+
return (
|
|
57
|
+
model.max_tokens ||
|
|
58
|
+
model.maxTokens ||
|
|
59
|
+
model.capabilities?.maxOutput ||
|
|
60
|
+
devModel?.limit?.output ||
|
|
61
|
+
DEFAULT_MAX_TOKENS
|
|
62
|
+
);
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
|
|
65
|
+
export function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
|
|
59
66
|
if (typeof devModel?.reasoning === "boolean") return devModel.reasoning;
|
|
60
67
|
return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(model.id ?? "");
|
|
61
68
|
}
|
package/src/render.ts
CHANGED
|
@@ -16,12 +16,12 @@ import type {
|
|
|
16
16
|
ToolRenderResultOptions,
|
|
17
17
|
} from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import type { Component } from "@earendil-works/pi-tui";
|
|
19
|
-
import { type CollapseState, tickCollapse } from "@xynogen/pix-data/collapse";
|
|
20
19
|
import {
|
|
21
20
|
type CollapsedToolStatus,
|
|
22
21
|
formatCollapsedToolRow,
|
|
23
22
|
hideCollapsedToolCall,
|
|
24
23
|
} from "@xynogen/pix-pretty/utils";
|
|
24
|
+
import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
|
|
25
25
|
|
|
26
26
|
interface TextLike extends Component {
|
|
27
27
|
setText(text: string): void;
|
package/src/search.ts
CHANGED
|
@@ -6,15 +6,13 @@
|
|
|
6
6
|
* ROUTER_API_KEY — bearer token for the router
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { type ExecFileException, execFile } from "node:child_process";
|
|
10
9
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
11
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
11
|
import { Type } from "typebox";
|
|
13
12
|
import { routerBaseUrl } from "./data.js";
|
|
13
|
+
import { apiPost, auth, curl, isCancelled } from "./http.js";
|
|
14
14
|
import { makeRenderCall, makeRenderResult } from "./render.js";
|
|
15
15
|
|
|
16
|
-
const REQUEST_TIMEOUT_MS = 30_000;
|
|
17
|
-
|
|
18
16
|
type RouterOutcome = "running" | "fallback" | "success" | "cancelled" | "error";
|
|
19
17
|
type SearchType = "web" | "news";
|
|
20
18
|
|
|
@@ -44,66 +42,6 @@ interface SearchDependencies {
|
|
|
44
42
|
curl: typeof curl;
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
function auth(): string | undefined {
|
|
48
|
-
return process.env.ROUTER_API_KEY;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function apiPost(
|
|
52
|
-
path: string,
|
|
53
|
-
body: Record<string, unknown>,
|
|
54
|
-
signal?: AbortSignal,
|
|
55
|
-
): Promise<string> {
|
|
56
|
-
const url = `${routerBaseUrl()}${path}`;
|
|
57
|
-
const key = auth();
|
|
58
|
-
const controller = new AbortController();
|
|
59
|
-
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
60
|
-
const s = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
const res = await fetch(url, {
|
|
64
|
-
method: "POST",
|
|
65
|
-
headers: {
|
|
66
|
-
"Content-Type": "application/json",
|
|
67
|
-
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
68
|
-
},
|
|
69
|
-
body: JSON.stringify(body),
|
|
70
|
-
signal: s,
|
|
71
|
-
});
|
|
72
|
-
if (!res.ok) {
|
|
73
|
-
const errText = await res.text().catch(() => "");
|
|
74
|
-
throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
|
|
75
|
-
}
|
|
76
|
-
return await res.text();
|
|
77
|
-
} finally {
|
|
78
|
-
clearTimeout(timeout);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function curl(args: string[], stdin?: string): Promise<string> {
|
|
83
|
-
return new Promise((resolve, reject) => {
|
|
84
|
-
const child = execFile(
|
|
85
|
-
"curl",
|
|
86
|
-
["-sS", "--connect-timeout", "10", "--max-time", "25", ...args],
|
|
87
|
-
{ maxBuffer: 10 * 1024 * 1024, timeout: 30_000 },
|
|
88
|
-
(err, stdout, stderr) => {
|
|
89
|
-
if (err) {
|
|
90
|
-
const e = err as ExecFileException;
|
|
91
|
-
const msg = e.killed
|
|
92
|
-
? "curl timed out"
|
|
93
|
-
: `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
|
|
94
|
-
reject(new Error(msg));
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
resolve(stdout);
|
|
98
|
-
},
|
|
99
|
-
);
|
|
100
|
-
if (stdin && child.stdin) {
|
|
101
|
-
child.stdin.write(stdin);
|
|
102
|
-
child.stdin.end();
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
45
|
interface SearchResultItem {
|
|
108
46
|
title?: string | null;
|
|
109
47
|
url?: string | null;
|
|
@@ -156,10 +94,6 @@ export function formatSearchResults(raw: string): string {
|
|
|
156
94
|
return parseSearchResults(raw).text;
|
|
157
95
|
}
|
|
158
96
|
|
|
159
|
-
function isCancelled(error: unknown, signal: AbortSignal | undefined): boolean {
|
|
160
|
-
return signal?.aborted === true || (error instanceof DOMException && error.name === "AbortError");
|
|
161
|
-
}
|
|
162
|
-
|
|
163
97
|
export async function executeSearch(
|
|
164
98
|
params: SearchParams,
|
|
165
99
|
signal?: AbortSignal,
|
package/src/transcribe.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
* ROUTER_API_KEY — bearer token for the router
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { type ExecFileException, execFile } from "node:child_process";
|
|
15
14
|
import { constants as fsConstants } from "node:fs";
|
|
16
15
|
import { access, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
16
|
import { homedir } from "node:os";
|
|
@@ -19,6 +18,7 @@ import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
|
|
|
19
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
19
|
import { Type } from "typebox";
|
|
21
20
|
import { routerBaseUrl } from "./data.js";
|
|
21
|
+
import { auth, curl } from "./http.js";
|
|
22
22
|
import { makeRenderCall, makeRenderResult } from "./render.js";
|
|
23
23
|
|
|
24
24
|
const REQUEST_TIMEOUT_MS = 120_000; // audio transcription can take longer
|
|
@@ -46,10 +46,6 @@ interface TranscribeResult {
|
|
|
46
46
|
isError?: boolean;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function auth(): string | undefined {
|
|
50
|
-
return process.env.ROUTER_API_KEY;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
49
|
/** Map file extension to MIME type for common audio formats. */
|
|
54
50
|
export function mimeType(filePath: string): string {
|
|
55
51
|
const ext = extname(filePath).toLowerCase();
|
|
@@ -106,27 +102,6 @@ async function apiMultipart(
|
|
|
106
102
|
}
|
|
107
103
|
}
|
|
108
104
|
|
|
109
|
-
function curl(args: string[]): Promise<string> {
|
|
110
|
-
return new Promise((resolve, reject) => {
|
|
111
|
-
execFile(
|
|
112
|
-
"curl",
|
|
113
|
-
["-sS", "--connect-timeout", "10", "--max-time", "120", ...args],
|
|
114
|
-
{ maxBuffer: 10 * 1024 * 1024, timeout: REQUEST_TIMEOUT_MS },
|
|
115
|
-
(err, stdout, stderr) => {
|
|
116
|
-
if (err) {
|
|
117
|
-
const e = err as ExecFileException;
|
|
118
|
-
const msg = e.killed
|
|
119
|
-
? "curl timed out"
|
|
120
|
-
: `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
|
|
121
|
-
reject(new Error(msg));
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
resolve(stdout);
|
|
125
|
-
},
|
|
126
|
-
);
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
105
|
/** Extract the `text` field from a JSON envelope, or return the raw string. */
|
|
131
106
|
export function parseTranscriptionResponse(raw: string): string {
|
|
132
107
|
try {
|
|
@@ -473,7 +448,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
473
448
|
`${routerBaseUrl()}/audio/transcriptions`,
|
|
474
449
|
];
|
|
475
450
|
|
|
476
|
-
const raw = await curl(curlArgs);
|
|
451
|
+
const raw = await curl(curlArgs, { maxTime: 120, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
477
452
|
return await run("curl-fallback", raw);
|
|
478
453
|
} catch (curlErr: unknown) {
|
|
479
454
|
const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
|