@xynogen/pix-9router 0.1.0 → 0.1.1
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 +45 -38
- package/src/data.test.ts +2 -2
- package/src/data.ts +70 -21
- package/src/fetch.ts +47 -10
- package/src/format.test.ts +95 -0
- package/src/index.ts +1 -1
- package/src/provider.test.ts +1 -1
- package/src/provider.ts +11 -6
- package/src/search.ts +56 -20
- package/src/transcribe.test.ts +1 -1
- package/src/transcribe.ts +6 -3
package/package.json
CHANGED
|
@@ -1,40 +1,47 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
2
|
+
"name": "@xynogen/pix-9router",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pi extension — 9Router provider + fetch/search tools via router API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bun test"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"src/index.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"pi",
|
|
22
|
+
"pi-package",
|
|
23
|
+
"pi-extension",
|
|
24
|
+
"9router"
|
|
25
|
+
],
|
|
26
|
+
"author": "xynogen",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/xynogen/pix-mono.git",
|
|
31
|
+
"directory": "packages/pix-9router"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-9router#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/xynogen/pix-mono/issues"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
42
|
+
"@earendil-works/pi-ai": "*"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"typebox": "^1.1.38"
|
|
46
|
+
}
|
|
40
47
|
}
|
package/src/data.test.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
-
routerBaseUrl,
|
|
4
3
|
buildModelsDevIndex,
|
|
5
4
|
lookupInIndex,
|
|
6
5
|
type ModelsDevApi,
|
|
7
6
|
type ModelsDevModel,
|
|
7
|
+
routerBaseUrl,
|
|
8
8
|
} from "./data.ts";
|
|
9
9
|
|
|
10
10
|
// ── routerBaseUrl ────────────────────────────────────────────────────────────
|
package/src/data.ts
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* ROUTER_API_KEY — bearer token (required)
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
16
|
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
|
|
@@ -38,10 +38,18 @@ export interface ModelsDevModel {
|
|
|
38
38
|
reasoning?: boolean;
|
|
39
39
|
modalities?: { input?: string[]; output?: string[] };
|
|
40
40
|
limit?: { context?: number; output?: number };
|
|
41
|
-
cost?: {
|
|
41
|
+
cost?: {
|
|
42
|
+
input?: number;
|
|
43
|
+
output?: number;
|
|
44
|
+
cache_read?: number;
|
|
45
|
+
cache_write?: number;
|
|
46
|
+
};
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
export type ModelsDevApi = Record<
|
|
49
|
+
export type ModelsDevApi = Record<
|
|
50
|
+
string,
|
|
51
|
+
{ models?: Record<string, ModelsDevModel> }
|
|
52
|
+
>;
|
|
45
53
|
|
|
46
54
|
interface RouterModelsResponse {
|
|
47
55
|
data?: RouterModel[];
|
|
@@ -79,7 +87,9 @@ class DataSource<T> {
|
|
|
79
87
|
|
|
80
88
|
async get(): Promise<T> {
|
|
81
89
|
if (this._inflight) return this._inflight;
|
|
82
|
-
this._inflight = this._load().finally(() => {
|
|
90
|
+
this._inflight = this._load().finally(() => {
|
|
91
|
+
this._inflight = null;
|
|
92
|
+
});
|
|
83
93
|
return this._inflight;
|
|
84
94
|
}
|
|
85
95
|
|
|
@@ -87,7 +97,9 @@ class DataSource<T> {
|
|
|
87
97
|
if (this._mem) return this._mem;
|
|
88
98
|
try {
|
|
89
99
|
if (existsSync(this.opts.cachePath)) {
|
|
90
|
-
const raw = JSON.parse(readFileSync(this.opts.cachePath, "utf-8")) as {
|
|
100
|
+
const raw = JSON.parse(readFileSync(this.opts.cachePath, "utf-8")) as {
|
|
101
|
+
data: unknown;
|
|
102
|
+
};
|
|
91
103
|
this._mem = this.opts.parseCache(raw.data);
|
|
92
104
|
return this._mem;
|
|
93
105
|
}
|
|
@@ -98,7 +110,10 @@ class DataSource<T> {
|
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
private async _load(): Promise<T> {
|
|
101
|
-
if (this.opts.skip()) {
|
|
113
|
+
if (this.opts.skip()) {
|
|
114
|
+
this._mem = this.opts.empty;
|
|
115
|
+
return this.opts.empty;
|
|
116
|
+
}
|
|
102
117
|
const cached = await this._readCache();
|
|
103
118
|
if (cached !== undefined && Date.now() - cached.ts < this.opts.ttlMs) {
|
|
104
119
|
const val = this.opts.parseCache(cached.data);
|
|
@@ -106,11 +121,16 @@ class DataSource<T> {
|
|
|
106
121
|
return val;
|
|
107
122
|
}
|
|
108
123
|
try {
|
|
109
|
-
const url =
|
|
124
|
+
const url =
|
|
125
|
+
typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
|
|
110
126
|
const controller = new AbortController();
|
|
111
127
|
const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs);
|
|
112
|
-
const response = await fetch(url, {
|
|
113
|
-
|
|
128
|
+
const response = await fetch(url, {
|
|
129
|
+
signal: controller.signal,
|
|
130
|
+
headers: this.opts.headers(),
|
|
131
|
+
}).finally(() => clearTimeout(timer));
|
|
132
|
+
if (!response.ok)
|
|
133
|
+
throw new Error(`${this.opts.label} fetch failed: ${response.status}`);
|
|
114
134
|
const raw = await response.json();
|
|
115
135
|
const val = this.opts.parse(raw);
|
|
116
136
|
this._mem = val;
|
|
@@ -119,7 +139,9 @@ class DataSource<T> {
|
|
|
119
139
|
} catch (error) {
|
|
120
140
|
const msg = error instanceof Error ? error.message : String(error);
|
|
121
141
|
if (cached !== undefined) {
|
|
122
|
-
console.warn(
|
|
142
|
+
console.warn(
|
|
143
|
+
`${this.opts.label} fetch failed, using stale cache: ${msg}`,
|
|
144
|
+
);
|
|
123
145
|
const val = this.opts.parseCache(cached.data);
|
|
124
146
|
this._mem = val;
|
|
125
147
|
return val;
|
|
@@ -129,19 +151,26 @@ class DataSource<T> {
|
|
|
129
151
|
}
|
|
130
152
|
}
|
|
131
153
|
|
|
132
|
-
private async _readCache(): Promise<
|
|
154
|
+
private async _readCache(): Promise<
|
|
155
|
+
{ ts: number; data: unknown } | undefined
|
|
156
|
+
> {
|
|
133
157
|
try {
|
|
134
158
|
const raw = await readFile(this.opts.cachePath, "utf8");
|
|
135
159
|
const parsed = JSON.parse(raw) as { ts: number; data: unknown };
|
|
136
160
|
if (typeof parsed.ts !== "number") return undefined;
|
|
137
161
|
return parsed;
|
|
138
|
-
} catch {
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
139
165
|
}
|
|
140
166
|
|
|
141
167
|
private async _writeCache(data: unknown): Promise<void> {
|
|
142
168
|
try {
|
|
143
169
|
await mkdir(dirname(this.opts.cachePath), { recursive: true });
|
|
144
|
-
await writeFile(
|
|
170
|
+
await writeFile(
|
|
171
|
+
this.opts.cachePath,
|
|
172
|
+
JSON.stringify({ ts: Date.now(), data }),
|
|
173
|
+
);
|
|
145
174
|
} catch {
|
|
146
175
|
// Write failure is non-fatal
|
|
147
176
|
}
|
|
@@ -150,12 +179,18 @@ class DataSource<T> {
|
|
|
150
179
|
|
|
151
180
|
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
152
181
|
|
|
153
|
-
const CACHE_DIR = join(
|
|
182
|
+
const CACHE_DIR = join(
|
|
183
|
+
process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
|
|
184
|
+
"pi",
|
|
185
|
+
);
|
|
154
186
|
|
|
155
187
|
const ROUTER_DEFAULT_BASE = "https://9router.example.com/v1";
|
|
156
188
|
|
|
157
189
|
export function routerBaseUrl(): string {
|
|
158
|
-
return (process.env.ROUTER_API_BASE || ROUTER_DEFAULT_BASE).replace(
|
|
190
|
+
return (process.env.ROUTER_API_BASE || ROUTER_DEFAULT_BASE).replace(
|
|
191
|
+
/\/$/,
|
|
192
|
+
"",
|
|
193
|
+
);
|
|
159
194
|
}
|
|
160
195
|
|
|
161
196
|
// ── Router models ──────────────────────────────────────────────────────────────
|
|
@@ -170,8 +205,12 @@ export const routerModels = new DataSource<RouterModel[]>({
|
|
|
170
205
|
skip: () => !process.env.ROUTER_API_KEY,
|
|
171
206
|
cachePath: join(CACHE_DIR, "9router.json"),
|
|
172
207
|
ttlMs: 30 * 60 * 1000, // 30 minutes
|
|
173
|
-
parse: (raw) =>
|
|
174
|
-
|
|
208
|
+
parse: (raw) =>
|
|
209
|
+
((raw as RouterModelsResponse).data ?? []).filter((m) => Boolean(m.id)),
|
|
210
|
+
parseCache: (data) =>
|
|
211
|
+
((data as RouterModelsResponse | undefined)?.data ?? []).filter((m) =>
|
|
212
|
+
Boolean(m.id),
|
|
213
|
+
),
|
|
175
214
|
empty: [],
|
|
176
215
|
});
|
|
177
216
|
|
|
@@ -187,7 +226,10 @@ const modelsDevCache = new DataSource<ModelsDevApi>({
|
|
|
187
226
|
});
|
|
188
227
|
|
|
189
228
|
function normalize(id: string): string {
|
|
190
|
-
return id
|
|
229
|
+
return id
|
|
230
|
+
.toLowerCase()
|
|
231
|
+
.replace(/[:@].*$/, "")
|
|
232
|
+
.replace(/-\d{8}$/, "");
|
|
191
233
|
}
|
|
192
234
|
|
|
193
235
|
function stripPrefix(id: string): string {
|
|
@@ -195,7 +237,9 @@ function stripPrefix(id: string): string {
|
|
|
195
237
|
return i >= 0 ? id.slice(i + 1) : id;
|
|
196
238
|
}
|
|
197
239
|
|
|
198
|
-
export function buildModelsDevIndex(
|
|
240
|
+
export function buildModelsDevIndex(
|
|
241
|
+
api: ModelsDevApi,
|
|
242
|
+
): Map<string, ModelsDevModel> {
|
|
199
243
|
const index = new Map<string, ModelsDevModel>();
|
|
200
244
|
for (const provider of Object.values(api)) {
|
|
201
245
|
if (!provider?.models) continue;
|
|
@@ -209,7 +253,10 @@ export function buildModelsDevIndex(api: ModelsDevApi): Map<string, ModelsDevMod
|
|
|
209
253
|
return index;
|
|
210
254
|
}
|
|
211
255
|
|
|
212
|
-
export function lookupInIndex(
|
|
256
|
+
export function lookupInIndex(
|
|
257
|
+
id: string,
|
|
258
|
+
index: Map<string, ModelsDevModel>,
|
|
259
|
+
): ModelsDevModel | undefined {
|
|
213
260
|
const stripped = stripPrefix(id);
|
|
214
261
|
const direct = index.get(stripped) ?? index.get(normalize(stripped));
|
|
215
262
|
if (direct) return direct;
|
|
@@ -220,6 +267,8 @@ export function lookupInIndex(id: string, index: Map<string, ModelsDevModel>): M
|
|
|
220
267
|
return undefined;
|
|
221
268
|
}
|
|
222
269
|
|
|
223
|
-
export async function fetchModelsDevIndex(): Promise<
|
|
270
|
+
export async function fetchModelsDevIndex(): Promise<
|
|
271
|
+
Map<string, ModelsDevModel>
|
|
272
|
+
> {
|
|
224
273
|
return buildModelsDevIndex(await modelsDevCache.get());
|
|
225
274
|
}
|
package/src/fetch.ts
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* ROUTER_API_KEY — bearer token for the router
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { execFile } from "node:child_process";
|
|
9
|
+
import { type ExecFileException, execFile } from "node:child_process";
|
|
10
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
import { Type } from "typebox";
|
|
12
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
13
13
|
import { routerBaseUrl } from "./data.js";
|
|
14
14
|
|
|
15
15
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
@@ -59,9 +59,10 @@ function curl(args: string[]): Promise<string> {
|
|
|
59
59
|
{ maxBuffer: 10 * 1024 * 1024, timeout: 30_000 },
|
|
60
60
|
(err, stdout, stderr) => {
|
|
61
61
|
if (err) {
|
|
62
|
-
const
|
|
62
|
+
const e = err as ExecFileException;
|
|
63
|
+
const msg = e.killed
|
|
63
64
|
? "curl timed out"
|
|
64
|
-
: `curl exit ${
|
|
65
|
+
: `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
|
|
65
66
|
reject(new Error(msg));
|
|
66
67
|
return;
|
|
67
68
|
}
|
|
@@ -71,6 +72,39 @@ function curl(args: string[]): Promise<string> {
|
|
|
71
72
|
});
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Extract readable page content from the exa fetch JSON envelope.
|
|
77
|
+
* Shape: { provider, url, title, content: { format, text }, ... }
|
|
78
|
+
* Falls back to the raw string when it isn't the expected JSON shape.
|
|
79
|
+
*/
|
|
80
|
+
export function formatFetchResult(raw: string): string {
|
|
81
|
+
let parsed: unknown;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(raw);
|
|
84
|
+
} catch {
|
|
85
|
+
return raw;
|
|
86
|
+
}
|
|
87
|
+
if (typeof parsed !== "object" || parsed === null) return raw;
|
|
88
|
+
|
|
89
|
+
const obj = parsed as {
|
|
90
|
+
title?: string | null;
|
|
91
|
+
url?: string | null;
|
|
92
|
+
content?: { text?: string | null } | string | null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const text =
|
|
96
|
+
typeof obj.content === "string"
|
|
97
|
+
? obj.content
|
|
98
|
+
: (obj.content?.text ?? undefined);
|
|
99
|
+
if (typeof text !== "string") return raw;
|
|
100
|
+
|
|
101
|
+
const header: string[] = [];
|
|
102
|
+
if (obj.title) header.push(`# ${obj.title}`);
|
|
103
|
+
if (obj.url) header.push(`URL: ${obj.url}`);
|
|
104
|
+
|
|
105
|
+
return header.length > 0 ? `${header.join("\n")}\n\n${text}` : text;
|
|
106
|
+
}
|
|
107
|
+
|
|
74
108
|
export default function registerFetch(pi: ExtensionAPI): void {
|
|
75
109
|
pi.registerTool({
|
|
76
110
|
name: "fetch",
|
|
@@ -108,6 +142,7 @@ export default function registerFetch(pi: ExtensionAPI): void {
|
|
|
108
142
|
content: [
|
|
109
143
|
{ type: "text", text: `Fetching (${fmt}): ${params.url}...` },
|
|
110
144
|
],
|
|
145
|
+
details: undefined,
|
|
111
146
|
});
|
|
112
147
|
|
|
113
148
|
const raw = await apiPost(
|
|
@@ -121,17 +156,18 @@ export default function registerFetch(pi: ExtensionAPI): void {
|
|
|
121
156
|
signal,
|
|
122
157
|
);
|
|
123
158
|
|
|
159
|
+
const formatted = formatFetchResult(raw);
|
|
124
160
|
const truncated =
|
|
125
|
-
maxChars > 0 &&
|
|
126
|
-
?
|
|
127
|
-
:
|
|
161
|
+
maxChars > 0 && formatted.length > maxChars
|
|
162
|
+
? `${formatted.slice(0, maxChars)}\n\n[truncated]`
|
|
163
|
+
: formatted;
|
|
128
164
|
|
|
129
165
|
return {
|
|
130
166
|
content: [{ type: "text", text: truncated.slice(0, 20_000) }],
|
|
131
167
|
details: {
|
|
132
168
|
source: "api",
|
|
133
|
-
chars:
|
|
134
|
-
truncated: maxChars > 0 &&
|
|
169
|
+
chars: formatted.length,
|
|
170
|
+
truncated: maxChars > 0 && formatted.length > maxChars,
|
|
135
171
|
},
|
|
136
172
|
};
|
|
137
173
|
} catch (apiErr: unknown) {
|
|
@@ -143,13 +179,14 @@ export default function registerFetch(pi: ExtensionAPI): void {
|
|
|
143
179
|
text: `API failed: ${apiMsg}\nFalling back to curl...`,
|
|
144
180
|
},
|
|
145
181
|
],
|
|
182
|
+
details: undefined,
|
|
146
183
|
});
|
|
147
184
|
}
|
|
148
185
|
|
|
149
186
|
try {
|
|
150
187
|
let html = await curl(["-L", params.url]);
|
|
151
188
|
if (maxChars > 0 && html.length > maxChars)
|
|
152
|
-
html = html.slice(0, maxChars)
|
|
189
|
+
html = `${html.slice(0, maxChars)}\n\n[truncated]`;
|
|
153
190
|
|
|
154
191
|
const banner =
|
|
155
192
|
"[FALLBACK — raw curl] API unavailable, content may differ from exa formatted output.";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { formatFetchResult } from "./fetch.ts";
|
|
3
|
+
import { formatSearchResults } from "./search.ts";
|
|
4
|
+
|
|
5
|
+
describe("formatSearchResults", () => {
|
|
6
|
+
it("formats results as a numbered markdown list", () => {
|
|
7
|
+
const raw = JSON.stringify({
|
|
8
|
+
provider: "exa",
|
|
9
|
+
query: "test",
|
|
10
|
+
results: [
|
|
11
|
+
{
|
|
12
|
+
title: "First Result",
|
|
13
|
+
url: "https://example.com/a",
|
|
14
|
+
snippet: "Some snippet\n text here",
|
|
15
|
+
published_at: "2026-03-04T22:36:57.000Z",
|
|
16
|
+
metadata: { author: "alice" },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
title: "",
|
|
20
|
+
url: "https://example.com/b",
|
|
21
|
+
snippet: null,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const out = formatSearchResults(raw);
|
|
27
|
+
expect(out).toContain("1. First Result");
|
|
28
|
+
expect(out).toContain(" https://example.com/a");
|
|
29
|
+
expect(out).toContain("(alice — 2026-03-04)");
|
|
30
|
+
expect(out).toContain("Some snippet text here");
|
|
31
|
+
// untitled result falls back to url as title
|
|
32
|
+
expect(out).toContain("2. https://example.com/b");
|
|
33
|
+
// no raw JSON noise
|
|
34
|
+
expect(out).not.toContain("{");
|
|
35
|
+
expect(out).not.toContain('"provider"');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("handles empty results", () => {
|
|
39
|
+
expect(formatSearchResults(JSON.stringify({ results: [] }))).toBe(
|
|
40
|
+
"No results.",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("falls back to pretty JSON for unexpected shapes", () => {
|
|
45
|
+
const raw = JSON.stringify({ answer: "42" });
|
|
46
|
+
expect(formatSearchResults(raw)).toContain('"answer"');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns raw string when not JSON", () => {
|
|
50
|
+
expect(formatSearchResults("plain text error")).toBe("plain text error");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("truncates long snippets to 300 chars", () => {
|
|
54
|
+
const raw = JSON.stringify({
|
|
55
|
+
results: [
|
|
56
|
+
{ title: "T", url: "https://x.dev", snippet: "y".repeat(1000) },
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
const out = formatSearchResults(raw);
|
|
60
|
+
const snippetLine = out
|
|
61
|
+
.split("\n")
|
|
62
|
+
.find((l) => l.trimStart().startsWith("yyy"));
|
|
63
|
+
expect(snippetLine?.trim().length).toBe(300);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("formatFetchResult", () => {
|
|
68
|
+
it("extracts content.text with title/url header", () => {
|
|
69
|
+
const raw = JSON.stringify({
|
|
70
|
+
provider: "exa",
|
|
71
|
+
url: "https://example.com",
|
|
72
|
+
title: "Example Page",
|
|
73
|
+
content: { format: "markdown", text: "# Hello\n\nBody text." },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const out = formatFetchResult(raw);
|
|
77
|
+
expect(out).toBe(
|
|
78
|
+
"# Example Page\nURL: https://example.com\n\n# Hello\n\nBody text.",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("supports content as a plain string", () => {
|
|
83
|
+
const raw = JSON.stringify({ content: "just text" });
|
|
84
|
+
expect(formatFetchResult(raw)).toBe("just text");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns raw when content.text is missing", () => {
|
|
88
|
+
const raw = JSON.stringify({ status: "error" });
|
|
89
|
+
expect(formatFetchResult(raw)).toBe(raw);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns raw when not JSON", () => {
|
|
93
|
+
expect(formatFetchResult("<html>raw</html>")).toBe("<html>raw</html>");
|
|
94
|
+
});
|
|
95
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import registerProvider from "./provider.js";
|
|
17
16
|
import registerFetch from "./fetch.js";
|
|
17
|
+
import registerProvider from "./provider.js";
|
|
18
18
|
import registerSearch from "./search.js";
|
|
19
19
|
import registerTranscribe from "./transcribe.js";
|
|
20
20
|
|
package/src/provider.test.ts
CHANGED
package/src/provider.ts
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { ModelsDevModel, RouterModel } from "./data.js";
|
|
13
14
|
import {
|
|
14
15
|
fetchModelsDevIndex,
|
|
15
16
|
lookupInIndex,
|
|
16
17
|
routerBaseUrl,
|
|
17
18
|
routerModels,
|
|
18
19
|
} from "./data.js";
|
|
19
|
-
import type { ModelsDevModel, RouterModel } from "./data.js";
|
|
20
20
|
|
|
21
21
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
22
22
|
const DEFAULT_MAX_TOKENS = 16_384;
|
|
@@ -106,17 +106,21 @@ export default async function registerProvider(
|
|
|
106
106
|
fetchModelsDevIndex().catch(() => new Map<string, ModelsDevModel>()),
|
|
107
107
|
]);
|
|
108
108
|
|
|
109
|
+
// Upstream moved OpenAI `compat` settings from the provider level to the
|
|
110
|
+
// per-model level (ProviderModelConfig.compat). Apply the same shim to every
|
|
111
|
+
// registered model.
|
|
112
|
+
const COMPAT = {
|
|
113
|
+
supportsDeveloperRole: false,
|
|
114
|
+
supportsUsageInStreaming: false,
|
|
115
|
+
maxTokensField: "max_tokens",
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
109
118
|
pi.registerProvider("9router", {
|
|
110
119
|
name: "9Router",
|
|
111
120
|
baseUrl: routerBaseUrl(),
|
|
112
121
|
apiKey: "$ROUTER_API_KEY",
|
|
113
122
|
api: "openai-completions",
|
|
114
123
|
headers: { "User-Agent": "pi-coding-agent" },
|
|
115
|
-
compat: {
|
|
116
|
-
supportsDeveloperRole: false,
|
|
117
|
-
supportsUsageInStreaming: false,
|
|
118
|
-
maxTokensField: "max_tokens",
|
|
119
|
-
},
|
|
120
124
|
models: models.map((model) => {
|
|
121
125
|
const devModel = lookupInIndex(model.id!, devIndex);
|
|
122
126
|
return {
|
|
@@ -127,6 +131,7 @@ export default async function registerProvider(
|
|
|
127
131
|
cost: ZERO_COST,
|
|
128
132
|
contextWindow: getContextWindow(model, devModel),
|
|
129
133
|
maxTokens: getMaxTokens(model, devModel),
|
|
134
|
+
compat: COMPAT,
|
|
130
135
|
};
|
|
131
136
|
}),
|
|
132
137
|
});
|
package/src/search.ts
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* ROUTER_API_KEY — bearer token for the router
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { execFile } from "node:child_process";
|
|
9
|
+
import { type ExecFileException, execFile } from "node:child_process";
|
|
10
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
import { Type } from "typebox";
|
|
12
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
13
13
|
import { routerBaseUrl } from "./data.js";
|
|
14
14
|
|
|
15
15
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
@@ -59,9 +59,10 @@ function curl(args: string[], stdin?: string): Promise<string> {
|
|
|
59
59
|
{ maxBuffer: 10 * 1024 * 1024, timeout: 30_000 },
|
|
60
60
|
(err, stdout, stderr) => {
|
|
61
61
|
if (err) {
|
|
62
|
-
const
|
|
62
|
+
const e = err as ExecFileException;
|
|
63
|
+
const msg = e.killed
|
|
63
64
|
? "curl timed out"
|
|
64
|
-
: `curl exit ${
|
|
65
|
+
: `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
|
|
65
66
|
reject(new Error(msg));
|
|
66
67
|
return;
|
|
67
68
|
}
|
|
@@ -75,6 +76,53 @@ function curl(args: string[], stdin?: string): Promise<string> {
|
|
|
75
76
|
});
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
interface SearchResultItem {
|
|
80
|
+
title?: string | null;
|
|
81
|
+
url?: string | null;
|
|
82
|
+
snippet?: string | null;
|
|
83
|
+
published_at?: string | null;
|
|
84
|
+
metadata?: { author?: string | null } | null;
|
|
85
|
+
author?: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format a raw exa search response as a compact markdown list.
|
|
90
|
+
* Falls back to pretty-printed JSON when the shape is unexpected.
|
|
91
|
+
*/
|
|
92
|
+
export function formatSearchResults(raw: string): string {
|
|
93
|
+
let parsed: unknown;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(raw);
|
|
96
|
+
} catch {
|
|
97
|
+
return raw.slice(0, 20_000);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const obj = parsed as Record<string, unknown>;
|
|
101
|
+
const results = obj?.results;
|
|
102
|
+
if (!Array.isArray(results)) {
|
|
103
|
+
return JSON.stringify(parsed, null, 2).slice(0, 20_000);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (results.length === 0) return "No results.";
|
|
107
|
+
|
|
108
|
+
const lines: string[] = [];
|
|
109
|
+
results.forEach((r: SearchResultItem, i: number) => {
|
|
110
|
+
const title = (r.title ?? "").trim() || r.url || "(untitled)";
|
|
111
|
+
lines.push(`${i + 1}. ${title}`);
|
|
112
|
+
if (r.url) lines.push(` ${r.url}`);
|
|
113
|
+
const meta: string[] = [];
|
|
114
|
+
const author = r.author ?? r.metadata?.author;
|
|
115
|
+
if (author) meta.push(author);
|
|
116
|
+
if (r.published_at) meta.push(r.published_at.slice(0, 10));
|
|
117
|
+
if (meta.length > 0) lines.push(` (${meta.join(" — ")})`);
|
|
118
|
+
const snippet = (r.snippet ?? "").trim();
|
|
119
|
+
if (snippet) lines.push(` ${snippet.replace(/\s+/g, " ").slice(0, 300)}`);
|
|
120
|
+
lines.push("");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return lines.join("\n").trim().slice(0, 20_000);
|
|
124
|
+
}
|
|
125
|
+
|
|
78
126
|
export default function registerSearch(pi: ExtensionAPI): void {
|
|
79
127
|
pi.registerTool({
|
|
80
128
|
name: "search",
|
|
@@ -114,6 +162,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
|
|
|
114
162
|
text: `Searching (${searchType}): ${params.query}...`,
|
|
115
163
|
},
|
|
116
164
|
],
|
|
165
|
+
details: undefined,
|
|
117
166
|
});
|
|
118
167
|
|
|
119
168
|
const raw = await apiPost(
|
|
@@ -127,18 +176,11 @@ export default function registerSearch(pi: ExtensionAPI): void {
|
|
|
127
176
|
signal,
|
|
128
177
|
);
|
|
129
178
|
|
|
130
|
-
let parsed: unknown;
|
|
131
|
-
try {
|
|
132
|
-
parsed = JSON.parse(raw);
|
|
133
|
-
} catch {
|
|
134
|
-
parsed = raw;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
179
|
return {
|
|
138
180
|
content: [
|
|
139
181
|
{
|
|
140
182
|
type: "text",
|
|
141
|
-
text:
|
|
183
|
+
text: formatSearchResults(raw),
|
|
142
184
|
},
|
|
143
185
|
],
|
|
144
186
|
details: { source: "api" },
|
|
@@ -152,6 +194,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
|
|
|
152
194
|
text: `API failed: ${apiMsg}\nFalling back to curl...`,
|
|
153
195
|
},
|
|
154
196
|
],
|
|
197
|
+
details: undefined,
|
|
155
198
|
});
|
|
156
199
|
}
|
|
157
200
|
|
|
@@ -173,18 +216,11 @@ export default function registerSearch(pi: ExtensionAPI): void {
|
|
|
173
216
|
`${routerBaseUrl()}/search`,
|
|
174
217
|
]);
|
|
175
218
|
|
|
176
|
-
let parsed: unknown;
|
|
177
|
-
try {
|
|
178
|
-
parsed = JSON.parse(raw);
|
|
179
|
-
} catch {
|
|
180
|
-
parsed = raw;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
219
|
return {
|
|
184
220
|
content: [
|
|
185
221
|
{
|
|
186
222
|
type: "text",
|
|
187
|
-
text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${
|
|
223
|
+
text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${formatSearchResults(raw).slice(0, 19_500)}`,
|
|
188
224
|
},
|
|
189
225
|
],
|
|
190
226
|
details: { source: "curl-fallback" },
|
package/src/transcribe.test.ts
CHANGED
package/src/transcribe.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* ROUTER_API_KEY — bearer token for the router
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { type ExecFileException, execFile } from "node:child_process";
|
|
14
15
|
import { readFile } from "node:fs/promises";
|
|
15
|
-
import { execFile } from "node:child_process";
|
|
16
16
|
import { basename, extname } from "node:path";
|
|
17
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { Type } from "typebox";
|
|
@@ -91,9 +91,10 @@ function curl(args: string[]): Promise<string> {
|
|
|
91
91
|
{ maxBuffer: 10 * 1024 * 1024, timeout: REQUEST_TIMEOUT_MS },
|
|
92
92
|
(err, stdout, stderr) => {
|
|
93
93
|
if (err) {
|
|
94
|
-
const
|
|
94
|
+
const e = err as ExecFileException;
|
|
95
|
+
const msg = e.killed
|
|
95
96
|
? "curl timed out"
|
|
96
|
-
: `curl exit ${
|
|
97
|
+
: `curl exit ${e.code ?? "??"}: ${stderr.slice(0, 300)}`;
|
|
97
98
|
reject(new Error(msg));
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
@@ -149,6 +150,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
149
150
|
text: `Transcribing: ${filePath} (model: ${model})...`,
|
|
150
151
|
},
|
|
151
152
|
],
|
|
153
|
+
details: undefined,
|
|
152
154
|
});
|
|
153
155
|
|
|
154
156
|
const raw = await apiMultipart(
|
|
@@ -185,6 +187,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
|
|
|
185
187
|
text: `API failed: ${apiMsg}\nFalling back to curl...`,
|
|
186
188
|
},
|
|
187
189
|
],
|
|
190
|
+
details: undefined,
|
|
188
191
|
});
|
|
189
192
|
}
|
|
190
193
|
|