@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/LICENSE +21 -0
- package/README.md +40 -0
- package/package.json +40 -0
- package/src/data.test.ts +179 -0
- package/src/data.ts +225 -0
- package/src/fetch.ts +181 -0
- package/src/index.ts +26 -0
- package/src/provider.test.ts +236 -0
- package/src/provider.ts +133 -0
- package/src/search.ts +208 -0
- package/src/transcribe.test.ts +160 -0
- package/src/transcribe.ts +240 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xynogen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# pix-9router
|
|
2
|
+
|
|
3
|
+
Pi coding agent extension — **9Router provider** + **fetch** and **search** tools backed by an internal OpenAI-compatible router API.
|
|
4
|
+
|
|
5
|
+
## What's included
|
|
6
|
+
|
|
7
|
+
| Module | Type | Description |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `provider` | provider | Registers `9router` provider with live model list from the router API |
|
|
10
|
+
| `fetch` | tool | `fetch(url, format, max_characters?)` — fetches web pages via exa through the router, falls back to curl |
|
|
11
|
+
| `search` | tool | `search(query, search_type, max_results?)` — web/news search via exa through the router, falls back to curl |
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install git:github.com/xynogen/pix-9router
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Environment
|
|
20
|
+
|
|
21
|
+
| Variable | Required | Default | Description |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| `ROUTER_API_KEY` | Yes | — | Bearer token for the router API |
|
|
24
|
+
| `ROUTER_API_BASE` | No | `https://9router.example.com/v1` | Override router base URL |
|
|
25
|
+
|
|
26
|
+
Add to your `~/.zsh_local` (or equivalent):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export ROUTER_API_KEY="your-key-here"
|
|
30
|
+
# export ROUTER_API_BASE="https://your-router.example.com/v1" # optional
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
- **Provider**: on load, fetches `/models` from the router and registers them with Pi. [models.dev](https://models.dev/api.json) is used internally to fill missing context window / modality fields where the router response omits them. Model list is cached at `~/.cache/pi/9router.json` (TTL 1h).
|
|
36
|
+
- **fetch / search**: POST to `/web/fetch` and `/search` on the router (which proxies to exa). If the router is unreachable, falls back to raw `curl`.
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xynogen/pix-9router",
|
|
3
|
+
"version": "0.1.0",
|
|
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": ["src/index.ts"]
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["pi", "pi-package", "pi-extension", "9router"],
|
|
19
|
+
"author": "xynogen",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/xynogen/pix-mono.git",
|
|
24
|
+
"directory": "packages/pix-9router"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-9router#readme",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/xynogen/pix-mono/issues"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
35
|
+
"@earendil-works/pi-ai": "*"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"typebox": "^1.1.38"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/data.test.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
routerBaseUrl,
|
|
4
|
+
buildModelsDevIndex,
|
|
5
|
+
lookupInIndex,
|
|
6
|
+
type ModelsDevApi,
|
|
7
|
+
type ModelsDevModel,
|
|
8
|
+
} from "./data.ts";
|
|
9
|
+
|
|
10
|
+
// ── routerBaseUrl ────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
describe("routerBaseUrl", () => {
|
|
13
|
+
const ORIGINAL = process.env.ROUTER_API_BASE;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
if (ORIGINAL === undefined) delete process.env.ROUTER_API_BASE;
|
|
17
|
+
else process.env.ROUTER_API_BASE = ORIGINAL;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns default when env unset", () => {
|
|
21
|
+
delete process.env.ROUTER_API_BASE;
|
|
22
|
+
expect(routerBaseUrl()).toBe("https://9router.example.com/v1");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("uses ROUTER_API_BASE when set", () => {
|
|
26
|
+
process.env.ROUTER_API_BASE = "https://my.router.dev/v1";
|
|
27
|
+
expect(routerBaseUrl()).toBe("https://my.router.dev/v1");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("strips trailing slash", () => {
|
|
31
|
+
process.env.ROUTER_API_BASE = "https://my.router.dev/v1/";
|
|
32
|
+
expect(routerBaseUrl()).toBe("https://my.router.dev/v1");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ── buildModelsDevIndex ──────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
describe("buildModelsDevIndex", () => {
|
|
39
|
+
const api: ModelsDevApi = {
|
|
40
|
+
anthropic: {
|
|
41
|
+
models: {
|
|
42
|
+
"claude-sonnet-4-5": {
|
|
43
|
+
id: "claude-sonnet-4-5",
|
|
44
|
+
name: "Claude Sonnet 4.5",
|
|
45
|
+
reasoning: false,
|
|
46
|
+
limit: { context: 200_000, output: 8_192 },
|
|
47
|
+
cost: { input: 3, output: 15 },
|
|
48
|
+
},
|
|
49
|
+
"claude-opus-4": {
|
|
50
|
+
id: "claude-opus-4",
|
|
51
|
+
name: "Claude Opus 4",
|
|
52
|
+
reasoning: true,
|
|
53
|
+
limit: { context: 200_000, output: 32_000 },
|
|
54
|
+
cost: { input: 15, output: 75 },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
openai: {
|
|
59
|
+
models: {
|
|
60
|
+
"gpt-4o": {
|
|
61
|
+
id: "gpt-4o",
|
|
62
|
+
name: "GPT-4o",
|
|
63
|
+
modalities: { input: ["text", "image"], output: ["text"] },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
it("indexes all models by exact id", () => {
|
|
70
|
+
const idx = buildModelsDevIndex(api);
|
|
71
|
+
expect(idx.has("claude-sonnet-4-5")).toBe(true);
|
|
72
|
+
expect(idx.has("claude-opus-4")).toBe(true);
|
|
73
|
+
expect(idx.has("gpt-4o")).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("indexes normalized ids (strip date suffix)", () => {
|
|
77
|
+
const apiWithDate: ModelsDevApi = {
|
|
78
|
+
anthropic: {
|
|
79
|
+
models: {
|
|
80
|
+
"claude-sonnet-4-5-20250514": {
|
|
81
|
+
id: "claude-sonnet-4-5-20250514",
|
|
82
|
+
name: "Claude Sonnet 4.5",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const idx = buildModelsDevIndex(apiWithDate);
|
|
88
|
+
// normalized: strip -20250514
|
|
89
|
+
expect(idx.has("claude-sonnet-4-5")).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("handles empty api gracefully", () => {
|
|
93
|
+
const idx = buildModelsDevIndex({});
|
|
94
|
+
expect(idx.size).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("handles provider with no models key", () => {
|
|
98
|
+
const idx = buildModelsDevIndex({ anthropic: {} });
|
|
99
|
+
expect(idx.size).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("preserves first-seen on id collision", () => {
|
|
103
|
+
const collide: ModelsDevApi = {
|
|
104
|
+
a: { models: { "gpt-4o": { id: "gpt-4o", name: "First" } } },
|
|
105
|
+
b: { models: { "gpt-4o": { id: "gpt-4o", name: "Second" } } },
|
|
106
|
+
};
|
|
107
|
+
const idx = buildModelsDevIndex(collide);
|
|
108
|
+
expect(idx.get("gpt-4o")?.name).toBe("First");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── lookupInIndex ────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe("lookupInIndex", () => {
|
|
115
|
+
let index: Map<string, ModelsDevModel>;
|
|
116
|
+
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
const api: ModelsDevApi = {
|
|
119
|
+
anthropic: {
|
|
120
|
+
models: {
|
|
121
|
+
"claude-sonnet-4-5": {
|
|
122
|
+
id: "claude-sonnet-4-5",
|
|
123
|
+
name: "Claude Sonnet 4.5",
|
|
124
|
+
},
|
|
125
|
+
"claude-opus-4": { id: "claude-opus-4", name: "Claude Opus 4" },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
openai: {
|
|
129
|
+
models: {
|
|
130
|
+
"gpt-4o": { id: "gpt-4o", name: "GPT-4o" },
|
|
131
|
+
"o3-mini": { id: "o3-mini", name: "o3 mini" },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
index = buildModelsDevIndex(api);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("finds exact match", () => {
|
|
139
|
+
expect(lookupInIndex("claude-sonnet-4-5", index)?.name).toBe(
|
|
140
|
+
"Claude Sonnet 4.5",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("finds by stripping provider prefix (router style: provider/model)", () => {
|
|
145
|
+
expect(lookupInIndex("anthropic/claude-opus-4", index)?.name).toBe(
|
|
146
|
+
"Claude Opus 4",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("finds with deep prefix e.g. cc/claude-opus-4", () => {
|
|
151
|
+
expect(lookupInIndex("cc/claude-opus-4", index)?.name).toBe(
|
|
152
|
+
"Claude Opus 4",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("finds with date suffix stripped", () => {
|
|
157
|
+
expect(lookupInIndex("claude-sonnet-4-5-20250514", index)?.name).toBe(
|
|
158
|
+
"Claude Sonnet 4.5",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("finds with provider prefix + date suffix", () => {
|
|
163
|
+
expect(
|
|
164
|
+
lookupInIndex("anthropic/claude-sonnet-4-5-20250514", index)?.name,
|
|
165
|
+
).toBe("Claude Sonnet 4.5");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("returns undefined for unknown model", () => {
|
|
169
|
+
expect(lookupInIndex("nonexistent-model-xyz", index)).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("finds gpt-4o exact", () => {
|
|
173
|
+
expect(lookupInIndex("gpt-4o", index)?.name).toBe("GPT-4o");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("finds o3-mini exact", () => {
|
|
177
|
+
expect(lookupInIndex("o3-mini", index)?.name).toBe("o3 mini");
|
|
178
|
+
});
|
|
179
|
+
});
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* data.ts — 9Router data layer
|
|
3
|
+
*
|
|
4
|
+
* Router-specific data only:
|
|
5
|
+
* - routerBaseUrl() — resolved API base URL from env
|
|
6
|
+
* - routerModels — DataSource<RouterModel[]>, cached 30m at ~/.cache/pi/9router.json
|
|
7
|
+
*
|
|
8
|
+
* models.dev + BenchLM data is provided by pix-data (github.com/xynogen/pix-data),
|
|
9
|
+
* which shares the same ~/.cache/pi/ cache directory.
|
|
10
|
+
*
|
|
11
|
+
* Environment:
|
|
12
|
+
* ROUTER_API_BASE — override base URL (default: https://9router.example.com/v1)
|
|
13
|
+
* ROUTER_API_KEY — bearer token (required)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
|
|
21
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface RouterModel {
|
|
24
|
+
id?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
context_window?: number;
|
|
27
|
+
contextWindow?: number;
|
|
28
|
+
max_tokens?: number;
|
|
29
|
+
maxTokens?: number;
|
|
30
|
+
owned_by?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Re-export ModelsDevModel for provider.ts — sourced from pix-data at runtime
|
|
34
|
+
// via shared cache; typed here to avoid a hard import dependency.
|
|
35
|
+
export interface ModelsDevModel {
|
|
36
|
+
id: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
reasoning?: boolean;
|
|
39
|
+
modalities?: { input?: string[]; output?: string[] };
|
|
40
|
+
limit?: { context?: number; output?: number };
|
|
41
|
+
cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ModelsDevApi = Record<string, { models?: Record<string, ModelsDevModel> }>;
|
|
45
|
+
|
|
46
|
+
interface RouterModelsResponse {
|
|
47
|
+
data?: RouterModel[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── DataSource (minimal, router-only) ────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
interface DataSourceOptions<T> {
|
|
53
|
+
url: string | (() => string);
|
|
54
|
+
headers?: () => Record<string, string> | undefined;
|
|
55
|
+
cachePath: string;
|
|
56
|
+
ttlMs?: number;
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
parse: (raw: unknown) => T;
|
|
59
|
+
parseCache: (data: unknown) => T;
|
|
60
|
+
empty: T;
|
|
61
|
+
label: string;
|
|
62
|
+
skip?: () => boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class DataSource<T> {
|
|
66
|
+
private _mem: T | null = null;
|
|
67
|
+
private _inflight: Promise<T> | null = null;
|
|
68
|
+
private readonly opts: Required<DataSourceOptions<T>>;
|
|
69
|
+
|
|
70
|
+
constructor(opts: DataSourceOptions<T>) {
|
|
71
|
+
this.opts = {
|
|
72
|
+
ttlMs: 24 * 60 * 60 * 1000,
|
|
73
|
+
timeoutMs: 10_000,
|
|
74
|
+
headers: () => undefined,
|
|
75
|
+
skip: () => false,
|
|
76
|
+
...opts,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async get(): Promise<T> {
|
|
81
|
+
if (this._inflight) return this._inflight;
|
|
82
|
+
this._inflight = this._load().finally(() => { this._inflight = null; });
|
|
83
|
+
return this._inflight;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getCached(): T {
|
|
87
|
+
if (this._mem) return this._mem;
|
|
88
|
+
try {
|
|
89
|
+
if (existsSync(this.opts.cachePath)) {
|
|
90
|
+
const raw = JSON.parse(readFileSync(this.opts.cachePath, "utf-8")) as { data: unknown };
|
|
91
|
+
this._mem = this.opts.parseCache(raw.data);
|
|
92
|
+
return this._mem;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// No cache file or parse error — return empty
|
|
96
|
+
}
|
|
97
|
+
return this.opts.empty;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async _load(): Promise<T> {
|
|
101
|
+
if (this.opts.skip()) { this._mem = this.opts.empty; return this.opts.empty; }
|
|
102
|
+
const cached = await this._readCache();
|
|
103
|
+
if (cached !== undefined && Date.now() - cached.ts < this.opts.ttlMs) {
|
|
104
|
+
const val = this.opts.parseCache(cached.data);
|
|
105
|
+
this._mem = val;
|
|
106
|
+
return val;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const url = typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs);
|
|
112
|
+
const response = await fetch(url, { signal: controller.signal, headers: this.opts.headers() }).finally(() => clearTimeout(timer));
|
|
113
|
+
if (!response.ok) throw new Error(`${this.opts.label} fetch failed: ${response.status}`);
|
|
114
|
+
const raw = await response.json();
|
|
115
|
+
const val = this.opts.parse(raw);
|
|
116
|
+
this._mem = val;
|
|
117
|
+
void this._writeCache(raw);
|
|
118
|
+
return val;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
121
|
+
if (cached !== undefined) {
|
|
122
|
+
console.warn(`${this.opts.label} fetch failed, using stale cache: ${msg}`);
|
|
123
|
+
const val = this.opts.parseCache(cached.data);
|
|
124
|
+
this._mem = val;
|
|
125
|
+
return val;
|
|
126
|
+
}
|
|
127
|
+
console.warn(`${this.opts.label} unavailable: ${msg}`);
|
|
128
|
+
return this.opts.empty;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async _readCache(): Promise<{ ts: number; data: unknown } | undefined> {
|
|
133
|
+
try {
|
|
134
|
+
const raw = await readFile(this.opts.cachePath, "utf8");
|
|
135
|
+
const parsed = JSON.parse(raw) as { ts: number; data: unknown };
|
|
136
|
+
if (typeof parsed.ts !== "number") return undefined;
|
|
137
|
+
return parsed;
|
|
138
|
+
} catch { return undefined; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async _writeCache(data: unknown): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
await mkdir(dirname(this.opts.cachePath), { recursive: true });
|
|
144
|
+
await writeFile(this.opts.cachePath, JSON.stringify({ ts: Date.now(), data }));
|
|
145
|
+
} catch {
|
|
146
|
+
// Write failure is non-fatal
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
const CACHE_DIR = join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "pi");
|
|
154
|
+
|
|
155
|
+
const ROUTER_DEFAULT_BASE = "https://9router.example.com/v1";
|
|
156
|
+
|
|
157
|
+
export function routerBaseUrl(): string {
|
|
158
|
+
return (process.env.ROUTER_API_BASE || ROUTER_DEFAULT_BASE).replace(/\/$/, "");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Router models ──────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export const routerModels = new DataSource<RouterModel[]>({
|
|
164
|
+
label: "9router",
|
|
165
|
+
url: () => `${routerBaseUrl()}/models`,
|
|
166
|
+
headers: () => {
|
|
167
|
+
const key = process.env.ROUTER_API_KEY;
|
|
168
|
+
return key ? { Authorization: `Bearer ${key}` } : undefined;
|
|
169
|
+
},
|
|
170
|
+
skip: () => !process.env.ROUTER_API_KEY,
|
|
171
|
+
cachePath: join(CACHE_DIR, "9router.json"),
|
|
172
|
+
ttlMs: 30 * 60 * 1000, // 30 minutes
|
|
173
|
+
parse: (raw) => ((raw as RouterModelsResponse).data ?? []).filter((m) => Boolean(m.id)),
|
|
174
|
+
parseCache: (data) => ((data as RouterModelsResponse | undefined)?.data ?? []).filter((m) => Boolean(m.id)),
|
|
175
|
+
empty: [],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── models.dev index (read from pix-data shared cache) ────────────────────────
|
|
179
|
+
|
|
180
|
+
const modelsDevCache = new DataSource<ModelsDevApi>({
|
|
181
|
+
label: "models.dev",
|
|
182
|
+
url: "https://models.dev/api.json",
|
|
183
|
+
cachePath: join(CACHE_DIR, "models.json"),
|
|
184
|
+
parse: (raw) => raw as ModelsDevApi,
|
|
185
|
+
parseCache: (data) => (data as ModelsDevApi) ?? {},
|
|
186
|
+
empty: {},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
function normalize(id: string): string {
|
|
190
|
+
return id.toLowerCase().replace(/[:@].*$/, "").replace(/-\d{8}$/, "");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function stripPrefix(id: string): string {
|
|
194
|
+
const i = id.lastIndexOf("/");
|
|
195
|
+
return i >= 0 ? id.slice(i + 1) : id;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function buildModelsDevIndex(api: ModelsDevApi): Map<string, ModelsDevModel> {
|
|
199
|
+
const index = new Map<string, ModelsDevModel>();
|
|
200
|
+
for (const provider of Object.values(api)) {
|
|
201
|
+
if (!provider?.models) continue;
|
|
202
|
+
for (const [modelId, model] of Object.entries(provider.models)) {
|
|
203
|
+
const m: ModelsDevModel = { ...model, id: modelId };
|
|
204
|
+
if (!index.has(modelId)) index.set(modelId, m);
|
|
205
|
+
const norm = normalize(modelId);
|
|
206
|
+
if (!index.has(norm)) index.set(norm, m);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return index;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function lookupInIndex(id: string, index: Map<string, ModelsDevModel>): ModelsDevModel | undefined {
|
|
213
|
+
const stripped = stripPrefix(id);
|
|
214
|
+
const direct = index.get(stripped) ?? index.get(normalize(stripped));
|
|
215
|
+
if (direct) return direct;
|
|
216
|
+
const norm = normalize(stripped);
|
|
217
|
+
for (const [key, model] of index) {
|
|
218
|
+
if (key.startsWith(norm) || norm.startsWith(key)) return model;
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function fetchModelsDevIndex(): Promise<Map<string, ModelsDevModel>> {
|
|
224
|
+
return buildModelsDevIndex(await modelsDevCache.get());
|
|
225
|
+
}
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch.ts — fetch 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[]): Promise<string> {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
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
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default function registerFetch(pi: ExtensionAPI): void {
|
|
75
|
+
pi.registerTool({
|
|
76
|
+
name: "fetch",
|
|
77
|
+
label: "Fetch",
|
|
78
|
+
description:
|
|
79
|
+
"Fetch a web page as markdown, text, or raw HTML via exa. Returns page content.",
|
|
80
|
+
promptSnippet:
|
|
81
|
+
"fetch(url, format, max_characters?) — format: 'markdown', 'text', or 'html'. Read page content via exa.",
|
|
82
|
+
promptGuidelines: [
|
|
83
|
+
"Use fetch when you need to read the full content of a specific URL.",
|
|
84
|
+
"Prefer format='markdown' for readable content, 'text' for plain text extraction, 'html' for raw source.",
|
|
85
|
+
"Set max_characters to cap response size (default 1000, 0 = unlimited). Use 5000-10000 for typical pages.",
|
|
86
|
+
"Always prefer fetch over raw curl/browser for reading page content.",
|
|
87
|
+
],
|
|
88
|
+
parameters: Type.Object({
|
|
89
|
+
url: Type.String({ description: "URL to fetch" }),
|
|
90
|
+
format: StringEnum(["markdown", "text", "html"] as const, {
|
|
91
|
+
description: "Desired output format",
|
|
92
|
+
}),
|
|
93
|
+
max_characters: Type.Optional(
|
|
94
|
+
Type.Number({
|
|
95
|
+
description: "Max characters (default 1000, 0 = unlimited)",
|
|
96
|
+
default: 1000,
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
}),
|
|
100
|
+
|
|
101
|
+
async execute(_toolCallId, params, signal, onUpdate) {
|
|
102
|
+
const maxChars = params.max_characters ?? 1000;
|
|
103
|
+
const fmt = params.format ?? "markdown";
|
|
104
|
+
let apiMsg = "";
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
onUpdate?.({
|
|
108
|
+
content: [
|
|
109
|
+
{ type: "text", text: `Fetching (${fmt}): ${params.url}...` },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const raw = await apiPost(
|
|
114
|
+
"/web/fetch",
|
|
115
|
+
{
|
|
116
|
+
model: "exa",
|
|
117
|
+
url: params.url,
|
|
118
|
+
format: fmt,
|
|
119
|
+
max_characters: maxChars,
|
|
120
|
+
},
|
|
121
|
+
signal,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const truncated =
|
|
125
|
+
maxChars > 0 && raw.length > maxChars
|
|
126
|
+
? raw.slice(0, maxChars) + "\n\n[truncated]"
|
|
127
|
+
: raw;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
content: [{ type: "text", text: truncated.slice(0, 20_000) }],
|
|
131
|
+
details: {
|
|
132
|
+
source: "api",
|
|
133
|
+
chars: raw.length,
|
|
134
|
+
truncated: maxChars > 0 && raw.length > maxChars,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
} catch (apiErr: unknown) {
|
|
138
|
+
apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
|
|
139
|
+
onUpdate?.({
|
|
140
|
+
content: [
|
|
141
|
+
{
|
|
142
|
+
type: "text",
|
|
143
|
+
text: `API failed: ${apiMsg}\nFalling back to curl...`,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
let html = await curl(["-L", params.url]);
|
|
151
|
+
if (maxChars > 0 && html.length > maxChars)
|
|
152
|
+
html = html.slice(0, maxChars) + "\n\n[truncated]";
|
|
153
|
+
|
|
154
|
+
const banner =
|
|
155
|
+
"[FALLBACK — raw curl] API unavailable, content may differ from exa formatted output.";
|
|
156
|
+
const body =
|
|
157
|
+
fmt === "html"
|
|
158
|
+
? `${banner}\n\n${html.slice(0, 19_500)}`
|
|
159
|
+
: `${banner}\n\nRaw HTML (format='${fmt}' not applied):\n\n${html.slice(0, 19_000)}`;
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
content: [{ type: "text", text: body.slice(0, 20_000) }],
|
|
163
|
+
details: { source: "curl-fallback", chars: html.length },
|
|
164
|
+
};
|
|
165
|
+
} catch (curlErr: unknown) {
|
|
166
|
+
const curlMsg =
|
|
167
|
+
curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
168
|
+
return {
|
|
169
|
+
content: [
|
|
170
|
+
{
|
|
171
|
+
type: "text",
|
|
172
|
+
text: `Fetch failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
details: { source: "failed" },
|
|
176
|
+
isError: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|