@xynogen/pix-9router 0.4.5 → 0.7.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/README.md +19 -37
- package/package.json +4 -5
- package/src/data.ts +11 -8
- package/src/index.ts +7 -14
- package/src/provider.ts +3 -3
- package/src/fetch.ts +0 -260
- package/src/http.ts +0 -136
- package/src/render.ts +0 -151
- package/src/search.ts +0 -282
- package/src/transcribe.ts +0 -461
package/README.md
CHANGED
|
@@ -1,58 +1,40 @@
|
|
|
1
1
|
# pix-9router
|
|
2
2
|
|
|
3
|
-
Pi
|
|
3
|
+
Pi extension with a 9Router model provider.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Other 9Router features live in separate packages. Each one reuses
|
|
6
|
+
`NINEROUTER_URL` and `NINEROUTER_KEY`:
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
| `transcribe` | tool | `transcribe(file, model?, language?)` — speech-to-text via `/audio/transcriptions` endpoint (default: `dg/nova-3`), falls back to curl |
|
|
13
|
-
|
|
14
|
-
## Install
|
|
15
|
-
|
|
16
|
-
```bash
|
|
17
|
-
pi install npm:@xynogen/pix-9router
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
> Standalone/opt-in — **not** bundled by [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core). Needs a 9Router API key, so only useful if you route through 9Router.
|
|
8
|
+
- Speech tools (`transcribe`, `speak`, `/stt`) moved to `@xynogen/pix-voice` in
|
|
9
|
+
0.7.0. Pick `9router` as the provider in its `/voice` command.
|
|
10
|
+
- Web search and fetch live in `@xynogen/pix-web`.
|
|
21
11
|
|
|
22
12
|
## Environment
|
|
23
13
|
|
|
24
|
-
|
|
25
|
-
|---|---|---|---|
|
|
26
|
-
| `ROUTER_API_KEY` | Yes | — | Bearer token for the router API |
|
|
27
|
-
| `ROUTER_API_BASE` | No | `https://9router.example.com/v1` | Override router base URL |
|
|
28
|
-
|
|
29
|
-
Add to your `~/.zsh_local` (or equivalent):
|
|
14
|
+
Use the upstream standard names:
|
|
30
15
|
|
|
31
16
|
```bash
|
|
32
|
-
export
|
|
33
|
-
|
|
17
|
+
export NINEROUTER_URL="https://your-router.example.com"
|
|
18
|
+
export NINEROUTER_KEY="your-key-here"
|
|
34
19
|
```
|
|
35
20
|
|
|
36
|
-
|
|
21
|
+
Legacy aliases remain supported:
|
|
37
22
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
## Compact results
|
|
43
|
-
|
|
44
|
-
Terminal tool results collapse after the shared Pix delay into metadata-driven rows while preserving all model-visible content. Examples include `✓ fetch https://example.com · 12.4K chars · markdown`, `⚡ search “query” · 8 results · curl fallback`, and `✓ transcribe meeting.mp3 · 12.4K chars · dg/nova-3`. Failures use `✗`; cancelled or fallback outcomes use `⚡`. Running and partial updates remain live, and expanding an elapsed row restores the normal dimmed content preview without restarting its timer. Configure the behavior with `collapse.delaySec` and the `fetch`, `search`, or `transcribe` entries under `collapse.tools` in `~/.pi/agent/pix.json`.
|
|
45
|
-
|
|
46
|
-
## Full distro
|
|
23
|
+
```text
|
|
24
|
+
ROUTER_API_BASE
|
|
25
|
+
ROUTER_API_KEY
|
|
26
|
+
```
|
|
47
27
|
|
|
48
|
-
|
|
28
|
+
The standard names win when both sets exist.
|
|
49
29
|
|
|
50
|
-
|
|
30
|
+
## Install
|
|
51
31
|
|
|
52
32
|
```bash
|
|
53
|
-
|
|
33
|
+
pi install npm:@xynogen/pix-9router
|
|
54
34
|
```
|
|
55
35
|
|
|
36
|
+
This package is standalone and opt-in. It is not bundled by `@xynogen/pix-core`.
|
|
37
|
+
|
|
56
38
|
## License
|
|
57
39
|
|
|
58
40
|
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-9router",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension — 9Router provider
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Pi extension — 9Router model provider",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -44,8 +44,7 @@
|
|
|
44
44
|
"@earendil-works/pi-tui": "*"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@xynogen/pix-
|
|
48
|
-
"
|
|
49
|
-
"@xynogen/pix-runtime": "^0.8.0"
|
|
47
|
+
"@xynogen/pix-data": "^0.5.1",
|
|
48
|
+
"@xynogen/pix-runtime": "^0.10.2"
|
|
50
49
|
}
|
|
51
50
|
}
|
package/src/data.ts
CHANGED
|
@@ -11,25 +11,26 @@
|
|
|
11
11
|
* models.dev index helpers live there — imported here, not duplicated.
|
|
12
12
|
*
|
|
13
13
|
* Environment:
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* NINEROUTER_URL — canonical base URL
|
|
15
|
+
* NINEROUTER_KEY — canonical bearer token
|
|
16
|
+
* ROUTER_API_BASE / ROUTER_API_KEY — legacy aliases
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
import { join } from "node:path";
|
|
19
|
-
import { CACHE_DIR, DataSource } from "
|
|
20
|
+
import { CACHE_DIR, DataSource } from "@xynogen/pix-data";
|
|
20
21
|
|
|
21
22
|
export type {
|
|
22
23
|
ModelGrepModel,
|
|
23
24
|
ModelsDevApi,
|
|
24
25
|
ModelsDevModel,
|
|
25
|
-
} from "
|
|
26
|
+
} from "@xynogen/pix-data";
|
|
26
27
|
// Re-export the shared models.dev API so existing consumers (provider.ts)
|
|
27
28
|
// can keep importing these names from "./data".
|
|
28
29
|
export {
|
|
29
30
|
buildModelsDevIndex,
|
|
30
31
|
fetchModelsDevIndex,
|
|
31
32
|
lookupInIndex,
|
|
32
|
-
} from "
|
|
33
|
+
} from "@xynogen/pix-data";
|
|
33
34
|
|
|
34
35
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
35
36
|
|
|
@@ -58,7 +59,9 @@ interface RouterModelsResponse {
|
|
|
58
59
|
const ROUTER_DEFAULT_BASE = "https://9router.example.com/v1";
|
|
59
60
|
|
|
60
61
|
export function routerBaseUrl(): string {
|
|
61
|
-
|
|
62
|
+
const configured = process.env.NINEROUTER_URL || process.env.ROUTER_API_BASE;
|
|
63
|
+
const base = (configured || ROUTER_DEFAULT_BASE).replace(/\/$/, "");
|
|
64
|
+
return base.endsWith("/v1") ? base : `${base}/v1`;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
// ── Router models ──────────────────────────────────────────────────────────────
|
|
@@ -67,10 +70,10 @@ export const routerModels = new DataSource<RouterModel[]>({
|
|
|
67
70
|
label: "9router",
|
|
68
71
|
url: () => `${routerBaseUrl()}/models`,
|
|
69
72
|
headers: () => {
|
|
70
|
-
const key = process.env.ROUTER_API_KEY;
|
|
73
|
+
const key = process.env.NINEROUTER_KEY || process.env.ROUTER_API_KEY;
|
|
71
74
|
return key ? { Authorization: `Bearer ${key}` } : undefined;
|
|
72
75
|
},
|
|
73
|
-
skip: () => !process.env.ROUTER_API_KEY,
|
|
76
|
+
skip: () => !(process.env.NINEROUTER_KEY || process.env.ROUTER_API_KEY),
|
|
74
77
|
cachePath: join(CACHE_DIR, "9router.json"),
|
|
75
78
|
ttlMs: 30 * 60 * 1000, // 30 minutes
|
|
76
79
|
parse: (raw) => ((raw as RouterModelsResponse).data ?? []).filter((m) => Boolean(m.id)),
|
package/src/index.ts
CHANGED
|
@@ -1,26 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pix-9router — Pi extension
|
|
2
|
+
* pix-9router — Pi extension
|
|
3
3
|
*
|
|
4
|
-
* Registers
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - search tool (web/news search via exa through router)
|
|
8
|
-
* - transcribe tool (speech-to-text via audio transcription API)
|
|
4
|
+
* Registers the 9router model provider (model list from the self-hosted router API).
|
|
5
|
+
* Speech tools moved to @xynogen/pix-voice. Web search and fetch live in
|
|
6
|
+
* @xynogen/pix-web. Both reuse the same 9Router environment variables.
|
|
9
7
|
*
|
|
10
8
|
* Environment:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* NINEROUTER_URL — canonical base URL
|
|
10
|
+
* NINEROUTER_KEY — canonical bearer token
|
|
11
|
+
* ROUTER_API_BASE / ROUTER_API_KEY — legacy aliases
|
|
13
12
|
*/
|
|
14
13
|
|
|
15
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import registerFetch from "./fetch.js";
|
|
17
15
|
import registerProvider from "./provider.js";
|
|
18
|
-
import registerSearch from "./search.js";
|
|
19
|
-
import registerTranscribe from "./transcribe.js";
|
|
20
16
|
|
|
21
17
|
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
22
18
|
await registerProvider(pi);
|
|
23
|
-
registerFetch(pi);
|
|
24
|
-
registerSearch(pi);
|
|
25
|
-
registerTranscribe(pi);
|
|
26
19
|
}
|
package/src/provider.ts
CHANGED
|
@@ -97,14 +97,14 @@ export function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boo
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
export default async function registerProvider(pi: ExtensionAPI): Promise<void> {
|
|
100
|
-
const apiKey = process.env.ROUTER_API_KEY;
|
|
100
|
+
const apiKey = process.env.NINEROUTER_KEY || process.env.ROUTER_API_KEY;
|
|
101
101
|
|
|
102
102
|
if (!apiKey) {
|
|
103
103
|
// Register shell provider so the name is known; no models available yet.
|
|
104
104
|
pi.registerProvider("9router", {
|
|
105
105
|
name: "9Router",
|
|
106
106
|
baseUrl: routerBaseUrl(),
|
|
107
|
-
apiKey: "$
|
|
107
|
+
apiKey: "$NINEROUTER_KEY",
|
|
108
108
|
api: "openai-completions",
|
|
109
109
|
models: [],
|
|
110
110
|
});
|
|
@@ -137,7 +137,7 @@ function providerConfig(
|
|
|
137
137
|
return {
|
|
138
138
|
name: "9Router",
|
|
139
139
|
baseUrl: routerBaseUrl(),
|
|
140
|
-
apiKey
|
|
140
|
+
apiKey,
|
|
141
141
|
api: "openai-completions",
|
|
142
142
|
headers: { "User-Agent": "pi-coding-agent" },
|
|
143
143
|
models: models.map(toModelConfig(devIndex)),
|
package/src/fetch.ts
DELETED
|
@@ -1,260 +0,0 @@
|
|
|
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://9router.example.com/v1)
|
|
6
|
-
* ROUTER_API_KEY — bearer token for the router
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { Type } from "typebox";
|
|
12
|
-
import { apiPost, curl, isCancelled } from "./http.js";
|
|
13
|
-
import { makeRenderCall, makeRenderResult } from "./render.js";
|
|
14
|
-
|
|
15
|
-
type RouterOutcome = "running" | "fallback" | "success" | "cancelled" | "error";
|
|
16
|
-
type FetchFormat = "markdown" | "text" | "html";
|
|
17
|
-
|
|
18
|
-
export interface FetchResultDetails {
|
|
19
|
-
_type: "fetchResult";
|
|
20
|
-
outcome: RouterOutcome;
|
|
21
|
-
url: string;
|
|
22
|
-
format: FetchFormat;
|
|
23
|
-
source?: "api" | "curl-fallback" | "failed";
|
|
24
|
-
chars?: number;
|
|
25
|
-
truncated?: boolean;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface FetchParams {
|
|
29
|
-
url: string;
|
|
30
|
-
format?: FetchFormat;
|
|
31
|
-
max_characters?: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface FetchResult {
|
|
35
|
-
content: { type: "text"; text: string }[];
|
|
36
|
-
details: FetchResultDetails;
|
|
37
|
-
isError?: boolean;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
interface FetchDependencies {
|
|
41
|
-
apiPost: typeof apiPost;
|
|
42
|
-
curl: typeof curl;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Extract readable page content from the exa fetch JSON envelope.
|
|
47
|
-
* Shape: { provider, url, title, content: { format, text }, ... }
|
|
48
|
-
* Falls back to the raw string when it isn't the expected JSON shape.
|
|
49
|
-
*/
|
|
50
|
-
export function formatFetchResult(raw: string): string {
|
|
51
|
-
let parsed: unknown;
|
|
52
|
-
try {
|
|
53
|
-
parsed = JSON.parse(raw);
|
|
54
|
-
} catch {
|
|
55
|
-
return raw;
|
|
56
|
-
}
|
|
57
|
-
if (typeof parsed !== "object" || parsed === null) return raw;
|
|
58
|
-
|
|
59
|
-
const obj = parsed as {
|
|
60
|
-
title?: string | null;
|
|
61
|
-
url?: string | null;
|
|
62
|
-
content?: { text?: string | null } | string | null;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const text = typeof obj.content === "string" ? obj.content : (obj.content?.text ?? undefined);
|
|
66
|
-
if (typeof text !== "string") return raw;
|
|
67
|
-
|
|
68
|
-
const header: string[] = [];
|
|
69
|
-
if (obj.title) header.push(`# ${obj.title}`);
|
|
70
|
-
if (obj.url) header.push(`URL: ${obj.url}`);
|
|
71
|
-
|
|
72
|
-
return header.length > 0 ? `${header.join("\n")}\n\n${text}` : text;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export async function executeFetch(
|
|
76
|
-
params: FetchParams,
|
|
77
|
-
signal?: AbortSignal,
|
|
78
|
-
onUpdate?: (result: FetchResult) => void,
|
|
79
|
-
dependencies: FetchDependencies = { apiPost, curl },
|
|
80
|
-
): Promise<FetchResult> {
|
|
81
|
-
const maxChars = params.max_characters ?? 1000;
|
|
82
|
-
const fmt = params.format ?? "markdown";
|
|
83
|
-
let apiMsg = "";
|
|
84
|
-
|
|
85
|
-
try {
|
|
86
|
-
onUpdate?.({
|
|
87
|
-
content: [{ type: "text", text: `Fetching (${fmt}): ${params.url}...` }],
|
|
88
|
-
details: {
|
|
89
|
-
_type: "fetchResult",
|
|
90
|
-
outcome: "running",
|
|
91
|
-
url: params.url,
|
|
92
|
-
format: fmt,
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
const raw = await dependencies.apiPost(
|
|
97
|
-
"/web/fetch",
|
|
98
|
-
{
|
|
99
|
-
model: "exa",
|
|
100
|
-
url: params.url,
|
|
101
|
-
format: fmt,
|
|
102
|
-
max_characters: maxChars,
|
|
103
|
-
},
|
|
104
|
-
signal,
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
const formatted = formatFetchResult(raw);
|
|
108
|
-
const truncated =
|
|
109
|
-
maxChars > 0 && formatted.length > maxChars
|
|
110
|
-
? `${formatted.slice(0, maxChars)}\n\n[truncated]`
|
|
111
|
-
: formatted;
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
content: [{ type: "text", text: truncated.slice(0, 20_000) }],
|
|
115
|
-
details: {
|
|
116
|
-
_type: "fetchResult",
|
|
117
|
-
outcome: "success",
|
|
118
|
-
url: params.url,
|
|
119
|
-
format: fmt,
|
|
120
|
-
source: "api",
|
|
121
|
-
chars: formatted.length,
|
|
122
|
-
truncated: maxChars > 0 && formatted.length > maxChars,
|
|
123
|
-
},
|
|
124
|
-
};
|
|
125
|
-
} catch (apiErr: unknown) {
|
|
126
|
-
apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
|
|
127
|
-
if (isCancelled(apiErr, signal)) {
|
|
128
|
-
return {
|
|
129
|
-
content: [{ type: "text", text: `Fetch cancelled.\nAPI: ${apiMsg}` }],
|
|
130
|
-
details: {
|
|
131
|
-
_type: "fetchResult",
|
|
132
|
-
outcome: "cancelled",
|
|
133
|
-
url: params.url,
|
|
134
|
-
format: fmt,
|
|
135
|
-
},
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
onUpdate?.({
|
|
139
|
-
content: [
|
|
140
|
-
{
|
|
141
|
-
type: "text",
|
|
142
|
-
text: `API failed: ${apiMsg}\nFalling back to curl...`,
|
|
143
|
-
},
|
|
144
|
-
],
|
|
145
|
-
details: {
|
|
146
|
-
_type: "fetchResult",
|
|
147
|
-
outcome: "running",
|
|
148
|
-
url: params.url,
|
|
149
|
-
format: fmt,
|
|
150
|
-
},
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
let html = await dependencies.curl(["-L", params.url]);
|
|
156
|
-
const chars = html.length;
|
|
157
|
-
const truncated = maxChars > 0 && html.length > maxChars;
|
|
158
|
-
if (truncated) html = `${html.slice(0, maxChars)}\n\n[truncated]`;
|
|
159
|
-
|
|
160
|
-
const banner =
|
|
161
|
-
"[FALLBACK — raw curl] API unavailable, content may differ from exa formatted output.";
|
|
162
|
-
const body =
|
|
163
|
-
fmt === "html"
|
|
164
|
-
? `${banner}\n\n${html.slice(0, 19_500)}`
|
|
165
|
-
: `${banner}\n\nRaw HTML (format='${fmt}' not applied):\n\n${html.slice(0, 19_000)}`;
|
|
166
|
-
|
|
167
|
-
return {
|
|
168
|
-
content: [{ type: "text", text: body.slice(0, 20_000) }],
|
|
169
|
-
details: {
|
|
170
|
-
_type: "fetchResult",
|
|
171
|
-
outcome: "fallback",
|
|
172
|
-
url: params.url,
|
|
173
|
-
format: fmt,
|
|
174
|
-
source: "curl-fallback",
|
|
175
|
-
chars,
|
|
176
|
-
truncated,
|
|
177
|
-
},
|
|
178
|
-
};
|
|
179
|
-
} catch (curlErr: unknown) {
|
|
180
|
-
const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
181
|
-
return {
|
|
182
|
-
content: [
|
|
183
|
-
{
|
|
184
|
-
type: "text",
|
|
185
|
-
text: `Fetch failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
|
|
186
|
-
},
|
|
187
|
-
],
|
|
188
|
-
details: {
|
|
189
|
-
_type: "fetchResult",
|
|
190
|
-
outcome: "error",
|
|
191
|
-
url: params.url,
|
|
192
|
-
format: fmt,
|
|
193
|
-
source: "failed",
|
|
194
|
-
},
|
|
195
|
-
isError: true,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export default function registerFetch(pi: ExtensionAPI): void {
|
|
201
|
-
pi.registerTool({
|
|
202
|
-
name: "fetch",
|
|
203
|
-
label: "Fetch",
|
|
204
|
-
renderShell: "self",
|
|
205
|
-
description: "Fetch a web page as markdown, text, or raw HTML via exa. Returns page content.",
|
|
206
|
-
promptSnippet:
|
|
207
|
-
"fetch(url, format, max_characters?) — format: 'markdown', 'text', or 'html'. Read page content via exa.",
|
|
208
|
-
promptGuidelines: [
|
|
209
|
-
"fetch: format='markdown' for readable content, 'text' for plain extraction, 'html' for raw source. max_characters caps size (default 1000, 0 = unlimited; 5000-10000 for typical pages).",
|
|
210
|
-
],
|
|
211
|
-
renderCall: makeRenderCall<unknown>("fetch", (args) =>
|
|
212
|
-
String((args as Partial<FetchParams>).url ?? ""),
|
|
213
|
-
),
|
|
214
|
-
renderResult: (
|
|
215
|
-
(renderTerminal) => (result, options, theme, context) =>
|
|
216
|
-
renderTerminal(result, options, theme, {
|
|
217
|
-
...context,
|
|
218
|
-
isError: context.isError && options.expanded,
|
|
219
|
-
})
|
|
220
|
-
)(
|
|
221
|
-
makeRenderResult<FetchResultDetails>({
|
|
222
|
-
tool: "fetch",
|
|
223
|
-
target: (details) => details.url,
|
|
224
|
-
meta: (details) => {
|
|
225
|
-
if (details.outcome === "error") return "failed";
|
|
226
|
-
if (details.outcome === "cancelled") return "cancelled";
|
|
227
|
-
const chars = `${details.chars ?? 0} chars`;
|
|
228
|
-
return details.outcome === "fallback"
|
|
229
|
-
? `${chars} · curl fallback`
|
|
230
|
-
: `${chars} · ${details.format}`;
|
|
231
|
-
},
|
|
232
|
-
status: (details) =>
|
|
233
|
-
details.outcome === "error"
|
|
234
|
-
? "error"
|
|
235
|
-
: details.outcome === "fallback" || details.outcome === "cancelled"
|
|
236
|
-
? "warning"
|
|
237
|
-
: "success",
|
|
238
|
-
}),
|
|
239
|
-
),
|
|
240
|
-
parameters: Type.Object({
|
|
241
|
-
url: Type.String({ description: "URL to fetch" }),
|
|
242
|
-
format: Type.Optional(
|
|
243
|
-
StringEnum(["markdown", "text", "html"] as const, {
|
|
244
|
-
description:
|
|
245
|
-
'Optional choice. Enter exactly "markdown" for readable Markdown, "text" for plain text, or "html" for raw HTML. Default is "markdown".',
|
|
246
|
-
}),
|
|
247
|
-
),
|
|
248
|
-
max_characters: Type.Optional(
|
|
249
|
-
Type.Number({
|
|
250
|
-
description: "Max characters (default 1000, 0 = unlimited)",
|
|
251
|
-
default: 1000,
|
|
252
|
-
}),
|
|
253
|
-
),
|
|
254
|
-
}),
|
|
255
|
-
|
|
256
|
-
async execute(_toolCallId, params, signal, onUpdate) {
|
|
257
|
-
return executeFetch(params as FetchParams, signal, onUpdate);
|
|
258
|
-
},
|
|
259
|
-
});
|
|
260
|
-
}
|
package/src/http.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
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 { readFileSync } from "node:fs";
|
|
11
|
-
import { basename } from "node:path";
|
|
12
|
-
import { ioTimeoutMs, ioTimeoutSignal } from "@xynogen/pix-runtime/io";
|
|
13
|
-
import { routerBaseUrl } from "./data.js";
|
|
14
|
-
|
|
15
|
-
export function auth(): string | undefined {
|
|
16
|
-
return process.env.ROUTER_API_KEY;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** POST a JSON body to a router path, returning the raw response text. */
|
|
20
|
-
export async function apiPost(
|
|
21
|
-
path: string,
|
|
22
|
-
body: Record<string, unknown>,
|
|
23
|
-
signal?: AbortSignal,
|
|
24
|
-
timeoutMs: number = ioTimeoutMs(),
|
|
25
|
-
): Promise<string> {
|
|
26
|
-
const url = `${routerBaseUrl()}${path}`;
|
|
27
|
-
const key = auth();
|
|
28
|
-
const timeoutSignal =
|
|
29
|
-
timeoutMs === ioTimeoutMs() ? ioTimeoutSignal() : AbortSignal.timeout(timeoutMs);
|
|
30
|
-
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
31
|
-
const res = await fetch(url, {
|
|
32
|
-
method: "POST",
|
|
33
|
-
headers: {
|
|
34
|
-
"Content-Type": "application/json",
|
|
35
|
-
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
36
|
-
},
|
|
37
|
-
body: JSON.stringify(body),
|
|
38
|
-
signal: requestSignal,
|
|
39
|
-
});
|
|
40
|
-
if (!res.ok) {
|
|
41
|
-
const errText = await res.text().catch(() => "");
|
|
42
|
-
throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
|
|
43
|
-
}
|
|
44
|
-
return res.text();
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface CurlOptions {
|
|
48
|
-
/** Request timeout in ms (default ioTimeoutMs()). */
|
|
49
|
-
timeoutMs?: number;
|
|
50
|
-
/** Optional request body (was piped to curl stdin). */
|
|
51
|
-
stdin?: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ponytail: this is NOT a general curl shim — it parses only the flag forms the
|
|
55
|
-
// three fallback call sites actually pass (-L, -X, -H, -d, -F key=value /
|
|
56
|
-
// file=@path, plus the -sS/--connect-timeout/--max-time defaults added below).
|
|
57
|
-
// New curl flags won't work here; extend the parser or call fetch directly.
|
|
58
|
-
function curlArgsToRequest(args: string[]): { url: string; init: RequestInit } {
|
|
59
|
-
const headers = new Headers();
|
|
60
|
-
let method = "GET";
|
|
61
|
-
let body: string | undefined;
|
|
62
|
-
let form: FormData | undefined;
|
|
63
|
-
let url = "";
|
|
64
|
-
for (let i = 0; i < args.length; i++) {
|
|
65
|
-
const a = args[i] ?? "";
|
|
66
|
-
switch (a) {
|
|
67
|
-
case "-sS":
|
|
68
|
-
case "-L":
|
|
69
|
-
break; // silent / follow-redirects are fetch defaults
|
|
70
|
-
case "--connect-timeout":
|
|
71
|
-
case "--max-time":
|
|
72
|
-
i++; // value handled by AbortSignal below
|
|
73
|
-
break;
|
|
74
|
-
case "-X":
|
|
75
|
-
method = args[++i] ?? method;
|
|
76
|
-
break;
|
|
77
|
-
case "-H": {
|
|
78
|
-
const h = args[++i] ?? "";
|
|
79
|
-
const idx = h.indexOf(":");
|
|
80
|
-
if (idx >= 0) headers.set(h.slice(0, idx).trim(), h.slice(idx + 1).trim());
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
|
-
case "-d":
|
|
84
|
-
body = args[++i] ?? "";
|
|
85
|
-
break;
|
|
86
|
-
case "-F": {
|
|
87
|
-
form ??= new FormData();
|
|
88
|
-
const field = args[++i] ?? "";
|
|
89
|
-
const eq = field.indexOf("=");
|
|
90
|
-
const key = field.slice(0, eq);
|
|
91
|
-
const val = field.slice(eq + 1);
|
|
92
|
-
if (val.startsWith("@")) {
|
|
93
|
-
const path = val.slice(1);
|
|
94
|
-
form.set(key, new Blob([readFileSync(path)]), basename(path));
|
|
95
|
-
} else {
|
|
96
|
-
form.set(key, val);
|
|
97
|
-
}
|
|
98
|
-
break;
|
|
99
|
-
}
|
|
100
|
-
default:
|
|
101
|
-
if (!a.startsWith("-")) url = a; // positional = URL
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
const init: RequestInit = { method };
|
|
105
|
-
if (form)
|
|
106
|
-
init.body = form; // let fetch set the multipart boundary
|
|
107
|
-
else if (body !== undefined) init.body = body;
|
|
108
|
-
if ([...headers.keys()].length > 0) init.headers = headers;
|
|
109
|
-
return { url, init };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Raw fetch fallback (curl-signature compatible), rejecting on non-2xx or timeout. */
|
|
113
|
-
export async function curl(args: string[], opts: CurlOptions = {}): Promise<string> {
|
|
114
|
-
const timeoutMs = opts.timeoutMs ?? ioTimeoutMs();
|
|
115
|
-
const { url, init } = curlArgsToRequest(args);
|
|
116
|
-
if (opts.stdin !== undefined) init.body = opts.stdin;
|
|
117
|
-
init.signal = AbortSignal.timeout(timeoutMs);
|
|
118
|
-
let res: Response;
|
|
119
|
-
try {
|
|
120
|
-
res = await fetch(url, init);
|
|
121
|
-
} catch (err) {
|
|
122
|
-
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
123
|
-
throw new Error("curl timed out");
|
|
124
|
-
}
|
|
125
|
-
throw new Error(`curl failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
126
|
-
}
|
|
127
|
-
if (!res.ok) {
|
|
128
|
-
const errText = await res.text().catch(() => "");
|
|
129
|
-
throw new Error(`curl exit ${res.status}: ${errText.slice(0, 300)}`);
|
|
130
|
-
}
|
|
131
|
-
return res.text();
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function isCancelled(error: unknown, signal: AbortSignal | undefined): boolean {
|
|
135
|
-
return signal?.aborted === true || (error instanceof DOMException && error.name === "AbortError");
|
|
136
|
-
}
|