pi-quiver 4.1.0 → 4.2.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.
@@ -10,448 +10,11 @@
10
10
  * are capped at 1 MB; binary downloads at 50 MB.
11
11
  */
12
12
 
13
- import { mkdirSync, writeFileSync, createWriteStream } from "node:fs";
14
- import { rm } from "node:fs/promises";
15
- import { tmpdir } from "node:os";
16
- import { join } from "node:path";
17
- import { createHash } from "node:crypto";
18
- import { execFile } from "node:child_process";
19
- import { promisify } from "node:util";
20
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
14
  import { formatSize, keyHint } from "@earendil-works/pi-coding-agent";
22
15
  import { Text } from "@earendil-works/pi-tui";
23
16
  import { Type } from "@sinclair/typebox";
24
- import { JSDOM } from "jsdom";
25
- import { Readability } from "@mozilla/readability";
26
- import TurndownService from "turndown";
27
- import { gfm } from "turndown-plugin-gfm";
28
-
29
- interface FetchToolDetails {
30
- url?: string;
31
- status?: number;
32
- contentType?: string;
33
- charset?: string;
34
- bytes?: number;
35
- truncated?: boolean;
36
- category?: "binary" | "markdown" | "json" | "text";
37
- spilled?: boolean;
38
- file?: string;
39
- lines?: number;
40
- via?: "gh";
41
- ghCommand?: string;
42
- }
43
-
44
- type GhTarget =
45
- | { kind: "issue"; url: string }
46
- | { kind: "pr"; url: string }
47
- | { kind: "repo"; slug: string }
48
- | { kind: "run"; slug: string; runId: string; url: string };
49
-
50
- const RESERVED_OWNERS = new Set([
51
- "orgs", "users", "sponsors", "topics", "marketplace", "apps",
52
- "collections", "stars", "settings", "notifications", "codespaces",
53
- "features", "trending", "security", "customer-stories",
54
- ]);
55
- const GH_NAME = /^[A-Za-z0-9._-]+$/;
56
-
57
- export function classifyGitHubTarget(url: URL): GhTarget | null {
58
- const host = url.hostname.toLowerCase();
59
- if (host !== "github.com" && host !== "www.github.com") return null;
60
- const segs = url.pathname.split("/").filter((s) => s.length > 0);
61
- if (segs.length < 2) return null;
62
- const [owner, repo] = segs;
63
- if (!GH_NAME.test(owner) || !GH_NAME.test(repo)) return null;
64
- if (RESERVED_OWNERS.has(owner.toLowerCase())) return null;
65
- if (segs.length === 4 && segs[2] === "issues" && /^\d+$/.test(segs[3])) {
66
- return { kind: "issue", url: `https://github.com/${owner}/${repo}/issues/${segs[3]}` };
67
- }
68
- if (segs.length === 4 && segs[2] === "pull" && /^\d+$/.test(segs[3])) {
69
- return { kind: "pr", url: `https://github.com/${owner}/${repo}/pull/${segs[3]}` };
70
- }
71
- if (segs.length === 5 && segs[2] === "actions" && segs[3] === "runs" && /^\d+$/.test(segs[4])) {
72
- return { kind: "run", slug: `${owner}/${repo}`, runId: segs[4], url: `https://github.com/${owner}/${repo}/actions/runs/${segs[4]}` };
73
- }
74
- if (segs.length === 2) {
75
- return { kind: "repo", slug: `${owner}/${repo}` };
76
- }
77
- return null;
78
- }
79
-
80
- export function buildGhArgs(target: GhTarget): string[] {
81
- if (target.kind === "issue") return ["issue", "view", target.url, "--comments"];
82
- if (target.kind === "pr") return ["pr", "view", target.url, "--comments"];
83
- if (target.kind === "run") return ["run", "view", target.runId, "--repo", target.slug];
84
- return ["repo", "view", target.slug];
85
- }
86
-
87
- const GH_MAX_BUFFER = 10_000_000; // 10 MB — an order above PARSABLE_MAX_BYTES
88
-
89
- type GhResult = { ok: true; stdout: string } | { ok: false };
90
- export type GhRunner = (args: string[], timeoutMs: number, signal?: AbortSignal) => Promise<GhResult>;
91
-
92
- const execFileAsync = promisify(execFile);
93
-
94
- export const runGh: GhRunner = async (args, timeoutMs, signal) => {
95
- try {
96
- const { stdout } = await execFileAsync("gh", args, {
97
- timeout: timeoutMs,
98
- signal,
99
- maxBuffer: GH_MAX_BUFFER,
100
- encoding: "utf8",
101
- });
102
- if (!stdout.trim()) return { ok: false };
103
- return { ok: true, stdout };
104
- } catch {
105
- return { ok: false };
106
- }
107
- };
108
-
109
- interface GhRoutingParams {
110
- raw?: boolean;
111
- method?: string;
112
- body?: string;
113
- headers?: Record<string, string>;
114
- timeoutMs?: number;
115
- }
116
-
117
- export function planGhRouting(params: GhRoutingParams, url: URL): GhTarget | null {
118
- if (params.raw) return null;
119
- if ((params.method ?? "GET") !== "GET") return null;
120
- if (params.body) return null;
121
- if (params.headers && Object.keys(params.headers).length > 0) return null;
122
- return classifyGitHubTarget(url);
123
- }
124
-
125
- function ghCommandLabel(target: GhTarget): string {
126
- if (target.kind === "issue") return "issue view --comments";
127
- if (target.kind === "pr") return "pr view --comments";
128
- if (target.kind === "run") return "run view";
129
- return "repo view";
130
- }
131
-
132
- function ghSourceLine(target: GhTarget, ref: string): string {
133
- if (target.kind === "issue") return `gh issue view ${ref} --comments`;
134
- if (target.kind === "pr") return `gh pr view ${ref} --comments`;
135
- if (target.kind === "run") return `gh run view ${target.runId} --repo ${target.slug}`;
136
- return `gh repo view ${ref}`;
137
- }
138
-
139
- function renderGhResult(target: GhTarget, stdout: string): { content: { type: "text"; text: string }[]; details: FetchToolDetails } {
140
- const body = stdout.trimEnd();
141
- const ref = target.kind === "repo" ? target.slug : target.url;
142
- const { spill, bytes, lines } = applyGate(body);
143
- const baseDetails: FetchToolDetails = {
144
- url: ref,
145
- bytes,
146
- lines,
147
- category: "markdown",
148
- via: "gh",
149
- ghCommand: ghCommandLabel(target),
150
- };
151
- const source = `Source: ${ghSourceLine(target, ref)}`;
152
- if (!spill) {
153
- return {
154
- content: [{ type: "text", text: [source, "", body].join("\n") }],
155
- details: { ...baseDetails, spilled: false },
156
- };
157
- }
158
- const spillUrl = target.kind === "repo" ? `https://github.com/${target.slug}` : target.url;
159
- const file = spillToFile(spillUrl, body, "md");
160
- return {
161
- content: [{
162
- type: "text",
163
- text: [
164
- source,
165
- `Body: ${formatSize(bytes)} across ${lines} lines — written to file (too large to inline)`,
166
- `Saved-To: ${file}`,
167
- "",
168
- "Read slices of this file with the read tool (offset/limit) or grep it; do not read the whole file unless you must. Markdown is grep-able by heading (^#).",
169
- "",
170
- `----- preview (first ${PREVIEW_LINES} lines) -----`,
171
- buildPreview(body),
172
- ].join("\n"),
173
- }],
174
- details: { ...baseDetails, spilled: true, file },
175
- };
176
- }
177
-
178
- export async function executeGhRouting(
179
- params: GhRoutingParams,
180
- url: URL,
181
- signal: AbortSignal | undefined,
182
- runner: GhRunner = runGh,
183
- ): Promise<{ content: { type: "text"; text: string }[]; details: FetchToolDetails } | null> {
184
- const target = planGhRouting(params, url);
185
- if (!target) return null;
186
- const gh = await runner(buildGhArgs(target), params.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal);
187
- if (!gh.ok) return null;
188
- return renderGhResult(target, gh.stdout);
189
- }
190
-
191
- const PARSABLE_MAX_BYTES = 1_000_000; // text/markdown/json download ceiling
192
- const BINARY_MAX_BYTES = 50_000_000; // file-destined download ceiling
193
- const SNIFF_MAX_BYTES = 64_000; // classification window
194
- const DEFAULT_TIMEOUT_MS = 20_000;
195
- const INLINE_MAX_BYTES = 32_000;
196
- const INLINE_MAX_LINES = 1_000;
197
- const PREVIEW_LINES = 60;
198
- const PREVIEW_MAX_BYTES = 4_000;
199
- const FIREFOX_UA =
200
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:135.0) Gecko/20100101 Firefox/135.0";
201
- const DEFAULT_ACCEPT =
202
- "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
203
-
204
- function parseCharset(contentType: string): string {
205
- const m = /charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
206
- return (m?.[1] ?? "utf-8").trim().toLowerCase();
207
- }
208
-
209
- function decodeBuffer(buf: Buffer, charset: string): string {
210
- try {
211
- return new TextDecoder(charset, { fatal: false }).decode(buf);
212
- } catch {
213
- return new TextDecoder("utf-8", { fatal: false }).decode(buf);
214
- }
215
- }
216
-
217
- function buildPreview(body: string): string {
218
- let preview = body.split("\n").slice(0, PREVIEW_LINES).join("\n");
219
- if (preview.length > PREVIEW_MAX_BYTES) {
220
- preview = `${preview.slice(0, PREVIEW_MAX_BYTES)}\n…[preview truncated]`;
221
- }
222
- return preview;
223
- }
224
-
225
- // --- Turndown singleton ---
226
-
227
- const turndownService = new TurndownService({
228
- headingStyle: "atx",
229
- codeBlockStyle: "fenced",
230
- bulletListMarker: "-",
231
- });
232
- turndownService.use(gfm);
233
-
234
- // --- Content classification ---
235
-
236
- function mimeType(contentType: string): string {
237
- return contentType.split(";")[0].trim().toLowerCase();
238
- }
239
-
240
- const TEXT_ALLOWLIST: RegExp[] = [
241
- /^text\//,
242
- /^application\/(json|xml|xhtml\+xml|javascript)$/,
243
- /\+json$/,
244
- /\+xml$/,
245
- ];
246
- // octet-stream is intentionally absent — it falls to the NUL-sniff branch.
247
- const KNOWN_BINARY: RegExp[] = [
248
- /^audio\//,
249
- /^video\//,
250
- /^font\//,
251
- /^application\/(pdf|zip|gzip|x-tar|x-7z-compressed|x-rar-compressed|wasm)$/,
252
- ];
253
-
254
- export function categorize(contentType: string, sniff: Buffer, raw: boolean): "binary" | "markdown" | "json" | "text" {
255
- const mime = mimeType(contentType);
256
- if (/^image\//.test(mime)) return "binary"; // includes image/svg+xml
257
- const isText = TEXT_ALLOWLIST.some((re) => re.test(mime));
258
- const isBinary = KNOWN_BINARY.some((re) => re.test(mime));
259
- if (!isText && !isBinary) return sniff.includes(0) ? "binary" : "text";
260
- if (isBinary && !isText) return "binary";
261
- if (sniff.includes(0)) return "binary"; // NUL downgrade of a text candidate
262
- if (raw) return "text"; // raw=true skips all transformations (markdown + JSON pretty-print)
263
- if (mime === "text/html" || mime === "application/xhtml+xml") return "markdown";
264
- if (mime === "application/json" || /\+json$/.test(mime)) return "json";
265
- return "text";
266
- }
267
-
268
- export function htmlToMarkdown(html: string, url: string): string | null {
269
- let doc: Document;
270
- try {
271
- doc = new JSDOM(html, { url }).window.document;
272
- } catch {
273
- return null;
274
- }
275
- let article: { title?: string | null; content?: string | null } | null = null;
276
- try {
277
- article = new Readability(doc).parse();
278
- } catch {
279
- return null;
280
- }
281
- if (!article?.content) return null;
282
- let md: string;
283
- try {
284
- md = turndownService.turndown(article.content).trim();
285
- } catch {
286
- return null;
287
- }
288
- if (!md) return null;
289
- if (article.title) md = `# ${article.title}\n\n${md}`;
290
- return md;
291
- }
292
-
293
- export function prettyJson(text: string): string {
294
- try {
295
- return JSON.stringify(JSON.parse(text), null, 2);
296
- } catch {
297
- return text;
298
- }
299
- }
300
-
301
- export function applyGate(body: string): { spill: boolean; bytes: number; lines: number } {
302
- const bytes = Buffer.byteLength(body, "utf8");
303
- const lines = body.length ? body.split("\n").length : 0;
304
- const spill = body.length > 0 && (bytes > INLINE_MAX_BYTES || lines > INLINE_MAX_LINES);
305
- return { spill, bytes, lines };
306
- }
307
-
308
- // --- Temp file helpers ---
309
-
310
- function tempFilePath(url: string, ext: string): string {
311
- const dir = join(tmpdir(), "pi-fetch");
312
- mkdirSync(dir, { recursive: true });
313
- let host = "page";
314
- try {
315
- host = new URL(url).hostname.replace(/[^a-z0-9.-]/gi, "_") || "page";
316
- } catch {
317
- // keep default
318
- }
319
- const hash = createHash("sha1").update(url).digest("hex").slice(0, 8);
320
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
321
- return join(dir, `${stamp}-${host}-${hash}.${ext}`);
322
- }
323
-
324
- function spillToFile(url: string, body: string, ext: string): string {
325
- const file = tempFilePath(url, ext);
326
- writeFileSync(file, body, "utf8");
327
- return file;
328
- }
329
-
330
- function textExtension(category: "markdown" | "json" | "text", contentType: string): string {
331
- if (category === "markdown") return "md";
332
- if (category === "json") return "json";
333
- return mimeType(contentType).includes("xml") ? "xml" : "txt";
334
- }
335
-
336
- const BINARY_EXT: Record<string, string> = {
337
- "application/pdf": "pdf",
338
- "application/zip": "zip",
339
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
340
- "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
341
- "application/gzip": "gz",
342
- "image/png": "png",
343
- "image/jpeg": "jpg",
344
- "image/gif": "gif",
345
- "image/webp": "webp",
346
- "image/svg+xml": "svg",
347
- };
348
-
349
- export function binaryExtension(contentType: string): string {
350
- const mime = mimeType(contentType);
351
- if (BINARY_EXT[mime]) return BINARY_EXT[mime];
352
- const sub = (mime.split("/")[1] ?? "").replace(/^x-/, "").replace(/[^a-z0-9]+/g, "").slice(0, 8);
353
- return sub || "bin";
354
- }
355
-
356
- // --- Streaming body collection ---
357
-
358
- type Category = "binary" | "markdown" | "json" | "text";
359
-
360
- interface CollectedBody {
361
- category: Category;
362
- buffer?: Buffer; // text/markdown/json (raw, pre-transform)
363
- file?: string; // binary
364
- bytes: number; // bytes kept (post-cap)
365
- truncated: boolean;
366
- }
367
-
368
- function writeChunk(stream: ReturnType<typeof createWriteStream>, b: Buffer): Promise<void> {
369
- return new Promise((resolve, reject) => {
370
- stream.write(b, (err) => (err ? reject(err) : resolve()));
371
- });
372
- }
373
-
374
- async function pumpToFile(
375
- stream: ReturnType<typeof createWriteStream>,
376
- reader: ReadableStreamDefaultReader<Uint8Array>,
377
- prefix: Buffer,
378
- exhausted: boolean,
379
- ): Promise<{ bytes: number; truncated: boolean }> {
380
- let bytes = 0;
381
- let truncated = false;
382
- let head = prefix;
383
- if (head.length > BINARY_MAX_BYTES) {
384
- head = head.subarray(0, BINARY_MAX_BYTES);
385
- truncated = true;
386
- }
387
- await writeChunk(stream, head);
388
- bytes += head.length;
389
- while (!exhausted && !truncated) {
390
- const { done, value } = await reader.read();
391
- if (done) break;
392
- let chunk = Buffer.from(value);
393
- if (bytes + chunk.length > BINARY_MAX_BYTES) {
394
- chunk = chunk.subarray(0, BINARY_MAX_BYTES - bytes);
395
- truncated = true;
396
- }
397
- await writeChunk(stream, chunk);
398
- bytes += chunk.length;
399
- }
400
- await new Promise<void>((resolve, reject) => stream.end((err?: Error | null) => (err ? reject(err) : resolve())));
401
- return { bytes, truncated };
402
- }
403
-
404
- export async function collectBody(res: Response, contentType: string, raw: boolean): Promise<CollectedBody> {
405
- const reader = res.body!.getReader();
406
- const prefixParts: Buffer[] = [];
407
- let prefixLen = 0;
408
- let exhausted = false;
409
- while (prefixLen < SNIFF_MAX_BYTES) {
410
- const { done, value } = await reader.read();
411
- if (done) {
412
- exhausted = true;
413
- break;
414
- }
415
- const chunk = Buffer.from(value);
416
- prefixParts.push(chunk);
417
- prefixLen += chunk.length;
418
- }
419
- const prefix = Buffer.concat(prefixParts);
420
- const category = categorize(contentType, prefix.subarray(0, SNIFF_MAX_BYTES), raw);
421
-
422
- if (category === "binary") {
423
- const file = tempFilePath(res.url, binaryExtension(contentType));
424
- const stream = createWriteStream(file);
425
- try {
426
- const { bytes, truncated } = await pumpToFile(stream, reader, prefix, exhausted);
427
- if (truncated) await reader.cancel().catch(() => {});
428
- return { category, file, bytes, truncated };
429
- } catch (err) {
430
- stream.destroy();
431
- await rm(file, { force: true });
432
- await reader.cancel().catch(() => {});
433
- throw err;
434
- }
435
- }
436
-
437
- const parts = [prefix];
438
- let bytes = prefix.length;
439
- let streamDone = exhausted;
440
- while (!streamDone && bytes < PARSABLE_MAX_BYTES) {
441
- const { done, value } = await reader.read();
442
- if (done) { streamDone = true; break; }
443
- const chunk = Buffer.from(value);
444
- parts.push(chunk);
445
- bytes += chunk.length;
446
- }
447
- let buffer = Buffer.concat(parts);
448
- if (buffer.length > PARSABLE_MAX_BYTES) {
449
- buffer = buffer.subarray(0, PARSABLE_MAX_BYTES);
450
- }
451
- const truncated = !streamDone;
452
- if (truncated) await reader.cancel().catch(() => {});
453
- return { category, buffer, bytes: buffer.length, truncated };
454
- }
17
+ import { fetchUrl, DEFAULT_TIMEOUT_MS, type FetchToolDetails } from "../lib/fetch-core.ts";
455
18
 
456
19
  export default function fetchExtension(pi: ExtensionAPI) {
457
20
  pi.registerTool({
@@ -480,138 +43,11 @@ export default function fetchExtension(pi: ExtensionAPI) {
480
43
  timeoutMs: Type.Optional(Type.Number({ default: DEFAULT_TIMEOUT_MS })),
481
44
  }),
482
45
  async execute(_toolCallId, params, signal) {
483
- const url = new URL(params.url);
484
- if (url.protocol !== "http:" && url.protocol !== "https:") {
485
- throw new Error(`Unsupported protocol: ${url.protocol}`);
486
- }
487
-
488
- const ghResult = await executeGhRouting(params, url, signal ?? undefined);
489
- if (ghResult) return ghResult;
490
-
491
- const headers = new Headers(params.headers ?? {});
492
- if (!headers.has("user-agent")) headers.set("user-agent", FIREFOX_UA);
493
- if (!headers.has("accept")) headers.set("accept", DEFAULT_ACCEPT);
494
- if (!headers.has("accept-language"))
495
- headers.set("accept-language", "en-US,en;q=0.5");
496
-
497
- const controller = new AbortController();
498
- const onAbort = () => controller.abort();
499
- signal?.addEventListener("abort", onAbort);
500
- const timer = setTimeout(
501
- () => controller.abort(new Error("fetch timeout")),
502
- params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
503
- );
504
-
505
- try {
506
- const res = await fetch(url, {
507
- method: params.method ?? "GET",
508
- headers,
509
- body: params.body,
510
- signal: controller.signal,
511
- redirect: "follow",
512
- });
513
-
514
- const ct = res.headers.get("content-type") ?? "";
515
- const charset = parseCharset(ct);
516
- const header = [
517
- `HTTP ${res.status} ${res.statusText}`,
518
- `Content-Type: ${ct}`,
519
- `Charset: ${charset}`,
520
- ];
521
-
522
- // HEAD or bodyless response: headers only.
523
- if (!res.body || (params.method ?? "GET") === "HEAD") {
524
- return {
525
- content: [{ type: "text", text: [...header, "Length: 0 (no body)"].join("\n") }],
526
- details: { url: res.url, status: res.status, contentType: ct, charset, bytes: 0 } as FetchToolDetails,
527
- };
528
- }
529
-
530
- const collected = await collectBody(res, ct, params.raw ?? false);
531
- const baseDetails: FetchToolDetails = {
532
- url: res.url,
533
- status: res.status,
534
- contentType: ct,
535
- charset,
536
- bytes: collected.bytes,
537
- truncated: collected.truncated,
538
- category: collected.category,
539
- };
540
-
541
- if (collected.category === "binary") {
542
- const note = collected.truncated ? " (truncated to 50MB)" : "";
543
- return {
544
- content: [{
545
- type: "text",
546
- text: [
547
- ...header,
548
- `Body: ${formatSize(collected.bytes)}${note} binary (${mimeType(ct) || "unknown"}) — saved untouched for processing`,
549
- `Saved-To: ${collected.file}`,
550
- "",
551
- "Binary content is not decoded. Use the appropriate tool to process the file at the path above.",
552
- ].join("\n"),
553
- }],
554
- details: { ...baseDetails, spilled: true, file: collected.file },
555
- };
556
- }
557
-
558
- const decoded = decodeBuffer(collected.buffer!, charset);
559
- let body: string;
560
- let effectiveCategory: "markdown" | "json" | "text" = collected.category;
561
- if (collected.category === "markdown") {
562
- const md = htmlToMarkdown(decoded, res.url);
563
- if (md !== null) {
564
- body = md;
565
- } else {
566
- body = decoded; // raw HTML text fallback
567
- effectiveCategory = "text";
568
- }
569
- } else if (collected.category === "json") {
570
- body = prettyJson(decoded);
571
- } else {
572
- body = decoded;
573
- }
574
-
575
- baseDetails.category = effectiveCategory;
576
- const truncNote = collected.truncated ? "\n[Note: source truncated at 1MB — content may be partial]" : "";
577
- const lengthLine = `Length: ${collected.bytes}${collected.truncated ? " (truncated to 1MB)" : ""}`;
578
- const { spill, bytes: bodyBytes, lines: lineCount } = applyGate(body);
579
- baseDetails.lines = lineCount;
580
-
581
- if (!spill) {
582
- return {
583
- content: [{ type: "text", text: [...header, lengthLine, "", body + truncNote].join("\n") }],
584
- details: { ...baseDetails, spilled: false },
585
- };
586
- }
587
-
588
- const ext = textExtension(effectiveCategory, ct);
589
- const file = spillToFile(res.url, body, ext);
590
- const grepHint = effectiveCategory === "markdown"
591
- ? "Read slices of this file with the read tool (offset/limit) or grep it; do not read the whole file unless you must. Markdown is grep-able by heading (^#)."
592
- : "Read slices of this file with the read tool (offset/limit) or grep it; do not read the whole file unless you must.";
593
- return {
594
- content: [{
595
- type: "text",
596
- text: [
597
- ...header,
598
- lengthLine,
599
- `Body: ${formatSize(bodyBytes)} across ${lineCount} lines — written to file (too large to inline)`,
600
- `Saved-To: ${file}`,
601
- ...(collected.truncated ? ["[Note: source truncated at 1MB — content may be partial]"] : []),
602
- "",
603
- grepHint,
604
- "",
605
- `----- preview (first ${PREVIEW_LINES} lines) -----`,
606
- buildPreview(body),
607
- ].join("\n"),
608
- }],
609
- details: { ...baseDetails, spilled: true, file },
610
- };
611
- } finally {
612
- clearTimeout(timer);
613
- signal?.removeEventListener("abort", onAbort);
614
- }
46
+ const result = await fetchUrl({ ...params, signal: signal ?? undefined });
47
+ return {
48
+ content: [{ type: "text" as const, text: result.output }],
49
+ details: result.details,
50
+ };
615
51
  },
616
52
 
617
53
  renderCall(args, theme, _context) {
@@ -347,6 +347,18 @@ export function buildNamingPrompt(conversation: string, opts: PromptOptions = {}
347
347
  return lines.join("\n");
348
348
  }
349
349
 
350
+ // Copilot business/enterprise credentials pin requests to an account-specific
351
+ // endpoint reported by getApiKeyAndHeaders as auth.baseUrl; the catalog model
352
+ // still carries the individual endpoint, and hitting it with such a token
353
+ // fails with 421 Misdirected Request. Mirror pi's own request path: prefer the
354
+ // credential's endpoint when present.
355
+ export function withAuthBaseUrl<M extends { baseUrl: string }>(
356
+ model: M,
357
+ auth: { baseUrl?: string },
358
+ ): M {
359
+ return auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
360
+ }
361
+
350
362
  async function generateName(
351
363
  ctx: ExtensionContext,
352
364
  opts: PromptOptions = {},
@@ -368,7 +380,7 @@ async function generateName(
368
380
 
369
381
  const complete = await loadComplete();
370
382
  const response = await complete(
371
- model,
383
+ withAuthBaseUrl(model, auth),
372
384
  {
373
385
  messages: [
374
386
  { role: "user" as const, content: [{ type: "text" as const, text: prompt }], timestamp: Date.now() },