Skip to content

Commit a06720d

Browse files
Share capped response-body reader between web_fetch and go model catalog
1 parent 173e4e1 commit a06720d

3 files changed

Lines changed: 77 additions & 75 deletions

File tree

src/provider/opencode-go-models.ts

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
OPENCODE_GO_MODEL_IDS,
66
} from "../../packages/opencode-go/src/index.js";
77
import { requestModelsEndpoint } from "./models-endpoint.js";
8+
import { readCappedBody } from "../util/capped-body.js";
89

910
const GoModelsResponse = type({
1011
data: type({ id: "string" }).array(),
@@ -49,48 +50,19 @@ async function readCatalogJson(
4950
return { ok: false, message: oversizeMessage("bytes") };
5051
}
5152

52-
const body = response.body;
53-
if (body === null) {
54-
try {
55-
const text = await response.text();
56-
if (new TextEncoder().encode(text).byteLength > MAX_GO_CATALOG_BYTES) {
57-
return { ok: false, message: oversizeMessage("bytes") };
58-
}
59-
const value: unknown = JSON.parse(text);
60-
return { ok: true, value };
61-
} catch (error) {
62-
return { ok: false, message: error instanceof Error ? error.message : String(error) };
63-
}
64-
}
65-
66-
const reader = body.getReader();
67-
const chunks: Uint8Array[] = [];
68-
let total = 0;
53+
let text: string;
54+
let truncated: boolean;
6955
try {
70-
for (;;) {
71-
const { done, value } = await reader.read();
72-
if (done) break;
73-
if (value === undefined) continue;
74-
total += value.byteLength;
75-
if (total > MAX_GO_CATALOG_BYTES) {
76-
await reader.cancel().catch(() => undefined);
77-
return { ok: false, message: oversizeMessage("bytes") };
78-
}
79-
chunks.push(value);
80-
}
56+
({ text, truncated } = await readCappedBody(response, MAX_GO_CATALOG_BYTES));
8157
} catch (error) {
8258
return { ok: false, message: error instanceof Error ? error.message : String(error) };
8359
}
84-
85-
const buffer = new Uint8Array(total);
86-
let offset = 0;
87-
for (const chunk of chunks) {
88-
buffer.set(chunk, offset);
89-
offset += chunk.byteLength;
60+
if (truncated) {
61+
return { ok: false, message: oversizeMessage("bytes") };
9062
}
9163

9264
try {
93-
const value: unknown = JSON.parse(new TextDecoder().decode(buffer));
65+
const value: unknown = JSON.parse(text);
9466
return { ok: true, value };
9567
} catch (error) {
9668
return { ok: false, message: error instanceof Error ? error.message : String(error) };

src/tools/web-fetch.ts

Lines changed: 3 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ToolCall, ToolDefinition, ToolResult } from "@intx/types/runtime";
55

66
import { checkUrlForSsrf } from "./ssrf-guard.js";
77
import { htmlToMarkdown, htmlToText } from "./html-convert.js";
8+
import { readCappedBody } from "../util/capped-body.js";
89
import { COMMAND_NAME } from "../branding.js";
910
import type { MCPClient } from "../mcp/client.js";
1011
import pkg from "../../package.json" with { type: "json" };
@@ -63,44 +64,6 @@ function looksLikeBotBlock(status: number): boolean {
6364
return status === 403 || status === 429 || status === 999;
6465
}
6566

66-
async function readCapped(
67-
response: Response,
68-
capBytes: number,
69-
): Promise<{ text: string; truncated: boolean }> {
70-
const body = response.body;
71-
if (body === null) return { text: await response.text(), truncated: false };
72-
const reader = body.getReader();
73-
const chunks: Uint8Array[] = [];
74-
let total = 0;
75-
let truncated = false;
76-
for (;;) {
77-
const { done, value } = await reader.read();
78-
if (done) break;
79-
if (value === undefined) continue;
80-
const remaining = capBytes - total;
81-
if (remaining <= 0) {
82-
truncated = true;
83-
await reader.cancel().catch(() => undefined);
84-
break;
85-
}
86-
const slice = value.byteLength > remaining ? value.slice(0, remaining) : value;
87-
chunks.push(slice);
88-
total += slice.byteLength;
89-
if (slice.byteLength < value.byteLength) {
90-
truncated = true;
91-
await reader.cancel().catch(() => undefined);
92-
break;
93-
}
94-
}
95-
const buffer = new Uint8Array(total);
96-
let offset = 0;
97-
for (const chunk of chunks) {
98-
buffer.set(chunk, offset);
99-
offset += chunk.byteLength;
100-
}
101-
return { text: new TextDecoder().decode(buffer), truncated };
102-
}
103-
10467
async function fetchOnce(
10568
url: string,
10669
userAgent: string,
@@ -176,14 +139,14 @@ export async function runWebFetch(
176139
}
177140

178141
if (!response.ok) {
179-
const { text } = await readCapped(response, 8192);
142+
const { text } = await readCappedBody(response, 8192);
180143
return {
181144
ok: false,
182145
error: `Fetch of ${currentUrl} failed with status ${response.status}: ${text.slice(0, 500)}`,
183146
};
184147
}
185148

186-
const { text: body, truncated } = await readCapped(response, MAX_FETCH_BYTES);
149+
const { text: body, truncated } = await readCappedBody(response, MAX_FETCH_BYTES);
187150
const contentType = response.headers.get("content-type") ?? "";
188151
const isHtml = contentType.includes("html") || /^\s*<(!doctype|html)/i.test(body);
189152

src/util/capped-body.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Shared byte-capped response-body reader.
2+
//
3+
// Two callers used to carry their own copy of the same subtle byte-accounting
4+
// loop (accumulate stream chunks up to a cap, cancel the reader the moment the
5+
// cap is exceeded so the upstream socket is not drained, concatenate, decode):
6+
// - web_fetch (src/tools/web-fetch.ts) caps page bodies at 5MB and error
7+
// snippets at 8KB, keeping the first `capBytes` bytes and flagging the cut.
8+
// - the OpenCode Go model catalog (src/provider/opencode-go-models.ts) caps
9+
// the live /models response so an oversized or hostile catalog cannot blow
10+
// process memory, rejecting (rather than keeping a prefix) when over.
11+
// Keeping the reader here means a cap-accounting fix (off-by-one, cancel
12+
// discipline, chunk slicing) lands once instead of drifting across copies.
13+
14+
export interface CappedBody {
15+
/** The body's content, decoded as UTF-8 and sliced to at most `capBytes` bytes. */
16+
text: string;
17+
/** True when the body was longer than `capBytes`; reading stopped at the cap. */
18+
truncated: boolean;
19+
}
20+
21+
/**
22+
* Read up to `capBytes` bytes of a Response body. Returns the decoded prefix
23+
* plus whether the body was cut off; the reader is cancelled as soon as the cap
24+
* is exceeded so oversized bodies are not drained. A Response whose body is
25+
* null (synthetic responses, no-content statuses) is read via `text()` and
26+
* truncation is judged from the decoded byte length.
27+
*/
28+
export async function readCappedBody(response: Response, capBytes: number): Promise<CappedBody> {
29+
const body = response.body;
30+
if (body === null) {
31+
const text = await response.text();
32+
return {
33+
text,
34+
truncated: new TextEncoder().encode(text).byteLength > capBytes,
35+
};
36+
}
37+
const reader = body.getReader();
38+
const chunks: Uint8Array[] = [];
39+
let total = 0;
40+
let truncated = false;
41+
for (;;) {
42+
const { done, value } = await reader.read();
43+
if (done) break;
44+
if (value === undefined) continue;
45+
const remaining = capBytes - total;
46+
if (remaining <= 0) {
47+
truncated = true;
48+
await reader.cancel().catch(() => undefined);
49+
break;
50+
}
51+
const slice = value.byteLength > remaining ? value.slice(0, remaining) : value;
52+
chunks.push(slice);
53+
total += slice.byteLength;
54+
if (slice.byteLength < value.byteLength) {
55+
truncated = true;
56+
await reader.cancel().catch(() => undefined);
57+
break;
58+
}
59+
}
60+
const buffer = new Uint8Array(total);
61+
let offset = 0;
62+
for (const chunk of chunks) {
63+
buffer.set(chunk, offset);
64+
offset += chunk.byteLength;
65+
}
66+
return { text: new TextDecoder().decode(buffer), truncated };
67+
}

0 commit comments

Comments
 (0)