zengate 1.0.4 → 1.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.
@@ -1,7 +1,10 @@
1
+ import { CHARS_PER_TOKEN, createLengthLimit } from './length-limit.js';
1
2
  import { createStopFilter } from './stop.js';
2
3
  import { createToolCallParser } from './tool-calls.js';
4
+ import { estimateUsage, learnPromptOverhead, NO_USAGE } from './usage-estimate.js';
3
5
 
4
6
  const STOP_REACHED = Symbol('stop-sequence');
7
+ const LENGTH_REACHED = Symbol('max-tokens');
5
8
  const SEPARATOR = '\n\n';
6
9
 
7
10
  const FORCED_RETRY_NOTE = 'To call a function, write a <tool_call>{"name": ..., "arguments": {...}}</tool_call> block as plain text in your reply. Do not describe the call; write the block.';
@@ -10,19 +13,20 @@ const addUsage = (a, b) => ({ input: a.input + b.input, output: a.output + b.out
10
13
 
11
14
  /**
12
15
  * Run one completion through OpenCode and apply the OpenAI-side semantics:
13
- * stop sequences, emulated function calls and structured output.
16
+ * stop sequences, max_tokens, emulated function calls and structured output.
14
17
  *
15
- * @param {{ runner: object, prompt: object, request: object, signal: AbortSignal,
18
+ * @param {{ runner: object, prompt: object, request: object, signal: AbortSignal, maxChars: number,
16
19
  * onText?: (text: string) => void, onReasoning?: (text: string) => void }} options
17
20
  * @returns {Promise<{ content: string, reasoning: string, toolCalls: object[], finish: string, usage: object }>}
18
21
  */
19
- async function attempt({ runner, prompt, request, signal, onText, onReasoning }) {
22
+ async function attempt({ runner, prompt, request, signal, maxChars, onText, onReasoning }) {
20
23
  const controller = new AbortController();
21
24
  const forward = () => controller.abort(signal.reason);
22
25
  if (signal.aborted) forward();
23
26
  else signal.addEventListener('abort', forward, { once: true });
24
27
 
25
28
  const stopFilter = createStopFilter(request.stop);
29
+ const lengthLimit = createLengthLimit(maxChars);
26
30
  const parser = request.tools.length && request.toolChoice !== 'none' ? createToolCallParser(request.tools) : null;
27
31
  // With native structured output, OpenCode returns the JSON separately
28
32
  // (info.structured) and any streamed text may be prose, so hold it back.
@@ -36,10 +40,25 @@ async function attempt({ runner, prompt, request, signal, onText, onReasoning })
36
40
  content += text;
37
41
  onText(text);
38
42
  };
39
- const acceptText = (text) => {
40
- const allowed = stopFilter.push(text);
43
+ const accept = (text) => {
44
+ const allowed = lengthLimit.push(text);
41
45
  emitText(parser ? parser.push(allowed) : allowed);
42
- if (stopFilter.stopped && !controller.signal.aborted) controller.abort(STOP_REACHED);
46
+ };
47
+ const acceptText = (text) => {
48
+ accept(stopFilter.push(text));
49
+ if (controller.signal.aborted) return;
50
+ if (stopFilter.stopped) controller.abort(STOP_REACHED);
51
+ else if (lengthLimit.reached) controller.abort(LENGTH_REACHED);
52
+ };
53
+ const done = () => stopFilter.stopped || lengthLimit.reached;
54
+ // max_completion_tokens and max_output_tokens include reasoning, as in OpenAI's API; max_tokens does not.
55
+ const acceptReasoning = (text) => {
56
+ const allowed = request.limitsReasoning ? lengthLimit.push(text) : text;
57
+ if (allowed) {
58
+ reasoning += allowed;
59
+ onReasoning(allowed);
60
+ }
61
+ if (lengthLimit.reached && !controller.signal.aborted) controller.abort(LENGTH_REACHED);
43
62
  };
44
63
 
45
64
  let result = null;
@@ -48,44 +67,48 @@ async function attempt({ runner, prompt, request, signal, onText, onReasoning })
48
67
  signal: controller.signal,
49
68
  onDelta(kind, text) {
50
69
  if (kind === 'reasoning') {
51
- reasoning += text;
52
- onReasoning(text);
70
+ if (!done()) acceptReasoning(text);
53
71
  } else if (nativeFormat) {
54
72
  held += text;
55
- } else if (!stopFilter.stopped) {
73
+ } else if (!done()) {
56
74
  acceptText(text);
57
75
  }
58
76
  },
59
77
  });
60
78
  } catch (error) {
61
- if (controller.signal.reason !== STOP_REACHED) throw error;
79
+ const reason = controller.signal.reason;
80
+ if (reason !== STOP_REACHED && reason !== LENGTH_REACHED) throw error;
62
81
  } finally {
63
82
  signal.removeEventListener('abort', forward);
64
83
  }
84
+ if (result?.usage) learnPromptOverhead(prompt, result.usage);
65
85
 
66
86
  if (result?.structured !== undefined) {
67
87
  acceptText(typeof result.structured === 'string' ? result.structured : JSON.stringify(result.structured));
68
88
  } else if (held) {
69
89
  acceptText(held);
70
90
  }
71
- const tail = stopFilter.flush();
72
- emitText(parser ? parser.push(tail) : tail);
91
+ accept(stopFilter.flush());
73
92
  let toolCalls = [];
74
93
  if (parser) {
94
+ // A call cut off by max_tokens cannot be parsed, and its markup is not content.
95
+ const truncatedCall = lengthLimit.reached && parser.capturing;
75
96
  const finished = parser.finish();
76
97
  toolCalls = finished.calls;
77
- emitText(finished.text);
98
+ if (!truncatedCall) emitText(finished.text);
78
99
  }
79
100
  let finish = result?.finish || 'stop';
80
101
  if (toolCalls.length) finish = 'tool_calls';
81
102
  else if (stopFilter.stopped) finish = 'stop';
103
+ else if (lengthLimit.reached) finish = 'length';
82
104
 
83
105
  return {
84
106
  content,
85
107
  reasoning,
86
108
  toolCalls,
87
109
  finish,
88
- usage: result?.usage || { input: 0, output: 0, reasoning: 0, cacheRead: 0 },
110
+ // A cut-off turn is aborted before OpenCode reports usage, so it is estimated.
111
+ usage: result?.usage || (done() ? estimateUsage(prompt, content, reasoning) : NO_USAGE),
89
112
  nativeToolAttempt: result?.nativeToolAttempt || null,
90
113
  };
91
114
  }
@@ -96,33 +119,39 @@ async function attempt({ runner, prompt, request, signal, onText, onReasoning })
96
119
  * demands a call (weaker models occasionally do either).
97
120
  * @param {Parameters<typeof attempt>[0]} options
98
121
  */
99
- export async function generate({ onText = () => {}, onReasoning = () => {}, ...options }) {
100
- const first = await attempt({ ...options, onText, onReasoning });
122
+ export async function generate({ onText: textSink = () => {}, onReasoning: reasoningSink = () => {}, onFirstToken, ...options }) {
101
123
  const { request, prompt, signal } = options;
124
+ const noticing = (sink) => (onFirstToken ? (text) => { if (text) onFirstToken(); sink(text); } : sink);
125
+ const onText = noticing(textSink);
126
+ const onReasoning = noticing(reasoningSink);
127
+ const maxChars = request.maxTokens ? request.maxTokens * CHARS_PER_TOKEN : Infinity;
128
+ const initial = await attempt({ ...options, maxChars, onText, onReasoning });
102
129
  const forced = request.toolChoice === 'required' || (request.toolChoice && typeof request.toolChoice === 'object');
103
- if (first.toolCalls.length || signal.aborted || !(forced || first.nativeToolAttempt)) return first;
130
+ if (initial.toolCalls.length || initial.finish === 'length' || signal.aborted || !(forced || initial.nativeToolAttempt)) return initial;
104
131
  const hasClientTools = request.tools.length > 0 && request.toolChoice !== 'none';
105
132
  let note = FORCED_RETRY_NOTE;
106
- if (first.nativeToolAttempt && hasClientTools) {
107
- note = `Your previous attempt to call "${first.nativeToolAttempt}" as a native tool failed: client functions are not native tools. ${FORCED_RETRY_NOTE}`;
108
- } else if (first.nativeToolAttempt) {
109
- note = `There is no tool named "${first.nativeToolAttempt}". Answer directly without calling tools.`;
133
+ if (initial.nativeToolAttempt && hasClientTools) {
134
+ note = `Your previous attempt to call "${initial.nativeToolAttempt}" as a native tool failed: client functions are not native tools. ${FORCED_RETRY_NOTE}`;
135
+ } else if (initial.nativeToolAttempt) {
136
+ note = `There is no tool named "${initial.nativeToolAttempt}". Answer directly without calling tools.`;
110
137
  }
111
138
  const retryPrompt = { ...prompt, system: [prompt.system, note].filter(Boolean).join(SEPARATOR) };
112
139
  let separated = false;
113
140
  const separate = (emit) => (text) => {
114
- if (first.content && !separated && text) {
141
+ if (initial.content && !separated && text) {
115
142
  separated = true;
116
143
  onText(SEPARATOR);
117
144
  }
118
145
  emit(text);
119
146
  };
120
- const second = await attempt({ ...options, prompt: retryPrompt, onText: separate(onText), onReasoning });
147
+ const spent = initial.content.length + (initial.content ? SEPARATOR.length : 0) + (request.limitsReasoning ? initial.reasoning.length : 0);
148
+ const remaining = Math.max(1, maxChars - spent);
149
+ const second = await attempt({ ...options, maxChars: remaining, prompt: retryPrompt, onText: separate(onText), onReasoning });
121
150
  return {
122
151
  ...second,
123
- content: separated ? `${first.content}${SEPARATOR}${second.content}` : first.content + second.content,
124
- reasoning: first.reasoning + second.reasoning,
125
- usage: addUsage(first.usage, second.usage),
152
+ content: separated ? `${initial.content}${SEPARATOR}${second.content}` : initial.content + second.content,
153
+ reasoning: initial.reasoning + second.reasoning,
154
+ usage: addUsage(initial.usage, second.usage),
126
155
  };
127
156
  }
128
157
 
@@ -0,0 +1,15 @@
1
+ const HEADER = 'x-gateway-ignored-params';
2
+ const MAX_LISTED = 32;
3
+ const LISTABLE = /^[\w.-]{1,64}$/;
4
+
5
+ /**
6
+ * Report accepted-but-ignored request parameters in a response header.
7
+ * The names come from the client's JSON keys, so only short header-safe
8
+ * names are listed, and at most MAX_LISTED of them.
9
+ * @param {import('express').Response} res
10
+ * @param {string[]} ignored
11
+ */
12
+ export function setIgnoredParams(res, ignored) {
13
+ const listed = ignored.filter((name) => LISTABLE.test(name)).slice(0, MAX_LISTED);
14
+ if (listed.length) res.set(HEADER, listed.join(','));
15
+ }
@@ -0,0 +1,49 @@
1
+ import { invalidRequest } from '../server/errors.js';
2
+
3
+ /**
4
+ * OpenCode cannot pass a token limit to the model, so `max_tokens` is applied
5
+ * to the streamed answer instead. Tokens are not counted exactly: a token is
6
+ * taken to be about four characters, which is close for English text.
7
+ */
8
+ export const CHARS_PER_TOKEN = 4;
9
+ const MAX_TOKENS = 1_000_000;
10
+
11
+ /**
12
+ * The first of `names` that `body` sets, as a validated token count, or null.
13
+ * @param {object} body request body
14
+ * @param {string[]} names parameter names in order of precedence
15
+ */
16
+ export function parseMaxTokens(body, names) {
17
+ const name = names.find((key) => body[key] !== undefined && body[key] !== null);
18
+ if (!name) return null;
19
+ const value = body[name];
20
+ if (!Number.isInteger(value) || value < 1 || value > MAX_TOKENS) {
21
+ throw invalidRequest(`${name} must be an integer from 1 to ${MAX_TOKENS}.`, name);
22
+ }
23
+ return value;
24
+ }
25
+
26
+ /**
27
+ * Cuts streamed text once `maxChars` characters have passed.
28
+ * @param {number} maxChars Infinity for no limit
29
+ */
30
+ export function createLengthLimit(maxChars) {
31
+ let used = 0;
32
+ let reached = false;
33
+ return {
34
+ get reached() { return reached; },
35
+ get used() { return used; },
36
+ /** @returns {string} the part of `text` that fits */
37
+ push(text) {
38
+ if (reached || !text) return '';
39
+ const room = maxChars - used;
40
+ if (text.length <= room) {
41
+ used += text.length;
42
+ return text;
43
+ }
44
+ reached = true;
45
+ used = maxChars;
46
+ return text.slice(0, room);
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Map with at most `limit` calls in flight, keeping results in input order.
3
+ * The first failure calls `onFailure` and stops new calls.
4
+ * @template T, R
5
+ * @param {T[]} items
6
+ * @param {number} limit
7
+ * @param {(item: T, index: number) => Promise<R>} fn
8
+ * @param {() => void} [onFailure]
9
+ * @returns {Promise<R[]>}
10
+ */
11
+ export async function mapLimited(items, limit, fn, onFailure = () => {}) {
12
+ const results = new Array(items.length);
13
+ let next = 0;
14
+ let failed = false;
15
+ const worker = async () => {
16
+ while (next < items.length && !failed) {
17
+ const index = next;
18
+ next += 1;
19
+ try {
20
+ results[index] = await fn(items[index], index);
21
+ } catch (error) {
22
+ failed = true;
23
+ onFailure();
24
+ throw error;
25
+ }
26
+ }
27
+ };
28
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker));
29
+ return results;
30
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Conversations, inlined files and emulated function calls are framed with
3
+ * pseudo-XML tags in the prompt. Client text must not be able to open or
4
+ * close those frames, or a message could pass itself off as another turn, a
5
+ * tool result or a function call. Only the frame tags are escaped, so other
6
+ * markup in a message reaches the model unchanged.
7
+ */
8
+ const FRAME_TAG = /<(\/?)((?:user|assistant|tool_result|tool_calls?|function_calls?)(?=[\s/>])|function=|parameter=)/gi;
9
+ const FILE_TAG = /<(\/?)(file)(?=[\s/>])/gi;
10
+
11
+ /** Escape the conversation and function-call frame tags in client text. */
12
+ export function escapeFrames(text) {
13
+ return String(text ?? '').replace(FRAME_TAG, '&lt;$1$2');
14
+ }
15
+
16
+ /** A client-supplied value made safe to place inside a quoted tag attribute. */
17
+ export function attributeValue(value, fallback) {
18
+ const text = String(value ?? '').replace(/[^\p{L}\p{M}\p{N}_ .:@+-]/gu, '_').slice(0, 255);
19
+ return text || fallback;
20
+ }
21
+
22
+ /** Escape file frame tags, so message text cannot pass itself off as an attached file. */
23
+ export function escapeFileTags(text) {
24
+ return String(text ?? '').replace(FILE_TAG, '&lt;$1$2');
25
+ }
26
+
27
+ /** An inlined text attachment; its content cannot close the file frame. */
28
+ export function fileBlock(filename, text) {
29
+ const body = escapeFileTags(escapeFrames(text));
30
+ return `\n<file name="${attributeValue(filename, 'attachment')}">\n${body}\n</file>\n`;
31
+ }
@@ -1,10 +1,11 @@
1
1
  import { invalidRequest } from '../server/errors.js';
2
+ import { attributeValue, escapeFrames } from './markup.js';
2
3
  import { renderToolCall, toolInstructions } from './tool-calls.js';
3
4
 
4
5
  const TRANSCRIPT_INTRO = 'The conversation so far is below, oldest first. Write only the next assistant reply — no role tags, no transcript markup.';
5
6
 
6
7
  function attachmentLabel(media, index) {
7
- return `[attachment ${index + 1}: ${media.kind}${media.filename ? ` "${media.filename}"` : ''}]`;
8
+ return `[attachment ${index + 1}: ${media.kind}${media.filename ? ` "${attributeValue(media.filename)}"` : ''}]`;
8
9
  }
9
10
 
10
11
  function toolNameFor(messages, callId) {
@@ -15,7 +16,10 @@ function toolNameFor(messages, callId) {
15
16
  return null;
16
17
  }
17
18
 
18
- /** Render a multi-turn conversation as one transcript with attachment markers. */
19
+ /**
20
+ * Render a multi-turn conversation as one transcript with attachment markers.
21
+ * Message text is escaped so it cannot close its own frame or open another.
22
+ */
19
23
  function renderTranscript(messages, attachments) {
20
24
  const blocks = [TRANSCRIPT_INTRO];
21
25
  for (const message of messages) {
@@ -23,10 +27,10 @@ function renderTranscript(messages, attachments) {
23
27
  attachments.push(media);
24
28
  return attachmentLabel(media, attachments.length - 1);
25
29
  });
26
- const body = [message.content, ...labels].filter(Boolean).join('\n');
30
+ const body = [escapeFrames(message.content), ...labels].filter(Boolean).join('\n');
27
31
  if (message.role === 'tool') {
28
- const name = message.name || toolNameFor(messages, message.toolCallId) || 'function';
29
- blocks.push(`<tool_result name="${name}" call_id="${message.toolCallId}">\n${body}\n</tool_result>`);
32
+ const name = attributeValue(message.name || toolNameFor(messages, message.toolCallId), 'function');
33
+ blocks.push(`<tool_result name="${name}" call_id="${attributeValue(message.toolCallId, 'unknown')}">\n${body}\n</tool_result>`);
30
34
  } else if (message.role === 'assistant') {
31
35
  const calls = (message.toolCalls || []).map(renderToolCall);
32
36
  blocks.push(`<assistant>\n${[body, ...calls].filter(Boolean).join('\n')}\n</assistant>`);
@@ -0,0 +1,222 @@
1
+ import https from 'node:https';
2
+ import net from 'node:net';
3
+ import { ApiError, invalidRequest, unsupported } from '../server/errors.js';
4
+ import { fileBlock } from './markup.js';
5
+ import { mapLimited } from './map-limited.js';
6
+ import { kindForMime } from './media.js';
7
+ import { isBlockedAddress, MAX_REMOTE_PARTS, publicLookup, resolvePublic } from './url-guard.js';
8
+
9
+ const FETCH_TIMEOUT_MS = 30000;
10
+ const MAX_REDIRECTS = 3;
11
+ const PARALLEL_FETCHES = 4;
12
+ const GENERIC_MIME = new Set(['', 'application/octet-stream', 'binary/octet-stream']);
13
+ // A private agent: the global one follows HTTPS_PROXY when NODE_USE_ENV_PROXY is
14
+ // set, and a proxy would resolve names itself and bypass publicLookup.
15
+ const DIRECT_AGENT = new https.Agent({ keepAlive: false });
16
+ const USER_AGENT = 'zengate (+https://github.com/developingchet/zengate)';
17
+
18
+ const attachmentError = (message) => invalidRequest(message, null, 'invalid_attachment_url');
19
+
20
+ /**
21
+ * An attachment URL as it may appear in errors and logs: signed URLs carry
22
+ * their token in the query string, and some URLs carry credentials, so both
23
+ * are left out.
24
+ */
25
+ export function shown(url) {
26
+ try {
27
+ const parsed = new URL(url);
28
+ return `${parsed.origin}${parsed.pathname}${parsed.search ? '?…' : ''}`;
29
+ } catch {
30
+ return '(invalid URL)';
31
+ }
32
+ }
33
+
34
+ function normalizeMime(header) {
35
+ const mime = String(header || '').split(';')[0].trim().toLowerCase();
36
+ return mime === 'image/jpg' ? 'image/jpeg' : mime;
37
+ }
38
+
39
+ const SIGNATURES = [
40
+ { mime: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47] },
41
+ { mime: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
42
+ { mime: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] },
43
+ { mime: 'application/pdf', bytes: [0x25, 0x50, 0x44, 0x46] },
44
+ { mime: 'video/webm', bytes: [0x1a, 0x45, 0xdf, 0xa3] },
45
+ ];
46
+
47
+ /** The type of a common image, video or PDF file from its first bytes. */
48
+ function sniffMime(body) {
49
+ const found = SIGNATURES.find(({ bytes }) => bytes.every((byte, index) => body[index] === byte));
50
+ if (found) return found.mime;
51
+ if (body.toString('latin1', 0, 4) === 'RIFF' && body.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
52
+ if (body.toString('latin1', 4, 8) === 'ftyp') return 'video/mp4';
53
+ return '';
54
+ }
55
+
56
+ /**
57
+ * The attachment's real type: the server's Content-Type or, when the server
58
+ * only says "binary", the file's signature or the type guessed from the URL.
59
+ * It must be the kind the URL was sent as (image_url must be an image),
60
+ * except that a file URL may turn out to be text, which is inlined.
61
+ */
62
+ function checkedMime(header, guessed, url, body) {
63
+ let mime = normalizeMime(header);
64
+ if (GENERIC_MIME.has(mime)) mime = sniffMime(body) || (guessed.includes('*') ? '' : guessed);
65
+ const kind = mime ? kindForMime(mime) : null;
66
+ const expected = kindForMime(guessed.replaceAll('*', 'x'));
67
+ if (!kind) throw unsupported(`Attachment ${shown(url)} has unsupported type '${mime || 'unknown'}'.`);
68
+ if (mime === 'image/svg+xml') throw unsupported('SVG images are not supported; send PNG, JPEG, GIF or WebP.');
69
+ const textFile = kind === 'text' && (expected === 'pdf' || expected === 'text');
70
+ if (kind !== expected && !textFile) throw attachmentError(`Attachment ${shown(url)} is ${mime}, not ${expected}.`);
71
+ return { mime, kind };
72
+ }
73
+
74
+ /**
75
+ * Byte budget for one request's attachments: each is capped at MAX_MEDIA_MB
76
+ * and together they are capped at MAX_BODY_MB, the same bounds that apply to
77
+ * inline data URIs. Returns a per-attachment counter.
78
+ */
79
+ function createBudget({ maxBytes, maxTotalBytes }) {
80
+ let total = 0;
81
+ const mb = (bytes) => (bytes / 1048576).toFixed(0);
82
+ return (url) => {
83
+ let used = 0;
84
+ const check = (bytes) => {
85
+ if (used + bytes > maxBytes) {
86
+ throw invalidRequest(`Attachment ${shown(url)} is larger than ${mb(maxBytes)} MB (MAX_MEDIA_MB).`, null, 'attachment_too_large');
87
+ }
88
+ if (total + bytes > maxTotalBytes) {
89
+ throw invalidRequest(`Attachments add up to more than ${mb(maxTotalBytes)} MB (MAX_BODY_MB).`, null, 'attachment_too_large');
90
+ }
91
+ };
92
+ return {
93
+ check,
94
+ take(bytes) {
95
+ check(bytes);
96
+ used += bytes;
97
+ total += bytes;
98
+ },
99
+ };
100
+ };
101
+ }
102
+
103
+ function readBody(response, limit) {
104
+ return new Promise((resolve, reject) => {
105
+ const fail = (error) => {
106
+ response.destroy();
107
+ reject(error);
108
+ };
109
+ const declared = Number(response.headers['content-length']);
110
+ try {
111
+ if (declared > 0) limit.check(declared);
112
+ } catch (error) {
113
+ fail(error);
114
+ return;
115
+ }
116
+ const chunks = [];
117
+ let ended = false;
118
+ response.on('data', (chunk) => {
119
+ try {
120
+ limit.take(chunk.length);
121
+ chunks.push(chunk);
122
+ } catch (error) {
123
+ fail(error);
124
+ }
125
+ });
126
+ response.once('end', () => {
127
+ ended = true;
128
+ resolve(Buffer.concat(chunks));
129
+ });
130
+ response.once('error', reject);
131
+ response.once('close', () => {
132
+ if (!ended) reject(new Error('connection closed before the attachment was complete'));
133
+ });
134
+ });
135
+ }
136
+
137
+ function requestOnce(url, { transport, lookup, signal }) {
138
+ return new Promise((resolve, reject) => {
139
+ const request = transport.request(url, {
140
+ method: 'GET',
141
+ agent: DIRECT_AGENT,
142
+ lookup,
143
+ signal,
144
+ headers: { accept: '*/*', 'accept-encoding': 'identity', 'user-agent': USER_AGENT },
145
+ });
146
+ request.once('response', resolve);
147
+ request.once('error', reject);
148
+ request.end();
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Download one https attachment. Each connection, including every redirect
154
+ * hop, goes through publicLookup, so only public addresses are ever reached.
155
+ * Node does not call `lookup` for an IP literal, so literals are checked here.
156
+ */
157
+ async function download(url, { transport, lookup, isBlocked, signal, limit }) {
158
+ let current = new URL(url);
159
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
160
+ if (net.isIP(current.hostname.replace(/^\[|\]$/g, ''))) await resolvePublic(current.hostname, isBlocked);
161
+ const response = await requestOnce(current, { transport, lookup, signal });
162
+ const status = response.statusCode || 0;
163
+ if (status >= 300 && status < 400 && response.headers.location) {
164
+ response.resume();
165
+ const next = new URL(response.headers.location, current);
166
+ if (next.protocol !== 'https:') throw attachmentError(`Attachment ${shown(url)} redirects to a non-https URL.`);
167
+ current = next;
168
+ continue;
169
+ }
170
+ if (status < 200 || status >= 300) {
171
+ response.resume();
172
+ throw attachmentError(`Attachment ${shown(url)} returned HTTP ${status}.`);
173
+ }
174
+ return { body: await readBody(response, limit), contentType: response.headers['content-type'] };
175
+ }
176
+ throw attachmentError(`Attachment ${shown(url)} redirects more than ${MAX_REDIRECTS} times.`);
177
+ }
178
+
179
+ async function inlinePart(part, options) {
180
+ const { signal, budget } = options;
181
+ const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
182
+ let result;
183
+ try {
184
+ result = await download(part.url, { ...options, signal: AbortSignal.any([signal, timeout]), limit: budget(part.url) });
185
+ } catch (error) {
186
+ if (signal.aborted) throw signal.reason ?? error;
187
+ if (error instanceof ApiError) throw error;
188
+ if (timeout.aborted) throw attachmentError(`Fetching attachment ${shown(part.url)} took longer than ${FETCH_TIMEOUT_MS / 1000}s.`);
189
+ throw attachmentError(`Could not fetch attachment ${shown(part.url)}: ${error.code || error.message}.`);
190
+ }
191
+ const { mime, kind } = checkedMime(result.contentType, part.mime, part.url, result.body);
192
+ if (kind === 'text') return { type: 'text', text: fileBlock(part.filename, result.body.toString('utf8')) };
193
+ return { ...part, mime, url: `data:${mime};base64,${result.body.toString('base64')}` };
194
+ }
195
+
196
+ /**
197
+ * Replace https attachment URLs in an OpenCode prompt with data URIs (or
198
+ * inlined text) fetched by the gateway, so OpenCode never fetches a
199
+ * client-chosen URL itself: no redirects to internal hosts, no DNS
200
+ * rebinding, and remote files obey the same size limits as inline ones.
201
+ * @param {{ parts: object[] }} prompt from buildPrompt
202
+ * @param {{ maxBytes: number, maxTotalBytes: number, signal: AbortSignal,
203
+ * transport?: { request: typeof https.request }, isBlocked?: (address: string) => boolean }} options
204
+ * @returns {Promise<object>} the prompt with every https part inlined
205
+ */
206
+ export async function inlineRemoteAttachments(prompt, { maxBytes, maxTotalBytes, signal, transport = https, isBlocked = isBlockedAddress }) {
207
+ const remote = prompt.parts.filter((part) => typeof part.url === 'string' && part.url.startsWith('https:'));
208
+ if (remote.length === 0) return prompt;
209
+ if (remote.length > MAX_REMOTE_PARTS) {
210
+ throw invalidRequest(`At most ${MAX_REMOTE_PARTS} attachments may be URLs; send the rest as data URIs.`, null, 'too_many_attachment_urls');
211
+ }
212
+ // One failed attachment fails the request, so the other downloads are cancelled too.
213
+ const siblings = new AbortController();
214
+ const options = {
215
+ transport, lookup: publicLookup(isBlocked), isBlocked,
216
+ signal: AbortSignal.any([signal, siblings.signal]),
217
+ budget: createBudget({ maxBytes, maxTotalBytes }),
218
+ };
219
+ const fetched = await mapLimited(remote, PARALLEL_FETCHES, (part) => inlinePart(part, options), () => siblings.abort());
220
+ const replacements = new Map(remote.map((part, index) => [part, fetched[index]]));
221
+ return { ...prompt, parts: prompt.parts.map((part) => replacements.get(part) ?? part) };
222
+ }
@@ -1,20 +1,23 @@
1
1
  import { invalidRequest, unsupported } from '../server/errors.js';
2
- import { parseResponseFormat, parseToolChoice } from './chat-request.js';
2
+ import { MAX_TOOLS, parseResponseFormat, parseToolChoice } from './chat-request.js';
3
3
  import { audioFromBase64, fileAttachment, imageFromUrl, videoFromUrl } from './media.js';
4
+ import { parseMaxTokens } from './length-limit.js';
5
+ import { escapeFileTags, fileBlock } from './markup.js';
4
6
 
5
7
  const IGNORED = new Set([
6
- 'temperature', 'top_p', 'max_output_tokens', 'max_tool_calls', 'top_logprobs', 'truncation', 'include',
8
+ 'temperature', 'top_p', 'max_tool_calls', 'top_logprobs', 'truncation', 'include',
7
9
  'user', 'safety_identifier', 'prompt_cache_key', 'prompt_cache_retention', 'service_tier', 'stream_options',
8
10
  ]);
9
11
  const HANDLED = new Set([
10
12
  'model', 'input', 'instructions', 'stream', 'tools', 'tool_choice', 'parallel_tool_calls', 'text',
11
- 'reasoning', 'store', 'previous_response_id', 'metadata', 'background', 'conversation', 'prompt',
13
+ 'reasoning', 'store', 'max_output_tokens', 'previous_response_id', 'metadata', 'background', 'conversation', 'prompt',
12
14
  ]);
13
15
  const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
14
16
 
15
17
  function parseTools(tools, ignored) {
16
18
  if (tools === undefined || tools === null) return [];
17
19
  if (!Array.isArray(tools)) throw invalidRequest('tools must be an array.', 'tools');
20
+ if (tools.length > MAX_TOOLS) throw invalidRequest(`tools may contain at most ${MAX_TOOLS} entries.`, 'tools');
18
21
  const parsed = [];
19
22
  tools.forEach((tool, index) => {
20
23
  if (tool?.type === 'function' || tool?.type === 'custom') {
@@ -35,7 +38,7 @@ function parseTools(tools, ignored) {
35
38
  }
36
39
 
37
40
  function parseContent(content, param, media, role) {
38
- if (typeof content === 'string') return { text: content, media: [] };
41
+ if (typeof content === 'string') return { text: escapeFileTags(content), media: [] };
39
42
  if (!Array.isArray(content)) throw invalidRequest('content must be a string or an array of parts.', param);
40
43
  const texts = [];
41
44
  const attachments = [];
@@ -44,8 +47,8 @@ function parseContent(content, param, media, role) {
44
47
  const options = { ...media, param: where };
45
48
  switch (part?.type) {
46
49
  case 'input_text': case 'output_text': case 'text': case 'summary_text':
47
- texts.push(String(part.text ?? '')); break;
48
- case 'refusal': texts.push(String(part.refusal ?? '')); break;
50
+ texts.push(escapeFileTags(part.text)); break;
51
+ case 'refusal': texts.push(escapeFileTags(part.refusal)); break;
49
52
  case 'input_image':
50
53
  if (part.file_id) throw unsupported('input_image.file_id requires the Files API; send image_url instead.', where);
51
54
  attachments.push(imageFromUrl(part.image_url, options)); break;
@@ -56,7 +59,7 @@ function parseContent(content, param, media, role) {
56
59
  }
57
60
  });
58
61
  if (role !== 'user' && attachments.length) throw invalidRequest('Only user messages may carry attachments.', param);
59
- for (const file of attachments.filter((a) => a.kind === 'text')) texts.push(`\n<file name="${file.filename || 'attachment'}">\n${file.text}\n</file>\n`);
62
+ for (const file of attachments.filter((a) => a.kind === 'text')) texts.push(fileBlock(file.filename, file.text));
60
63
  return { text: texts.join(''), media: attachments.filter((a) => a.kind !== 'text') };
61
64
  }
62
65
 
@@ -153,6 +156,8 @@ export function parseResponsesRequest(body, media, store) {
153
156
  reasoningEffort: typeof body.reasoning?.effort === 'string' ? body.reasoning.effort : null,
154
157
  stop: [],
155
158
  n: 1,
159
+ maxTokens: parseMaxTokens(body, ['max_output_tokens']),
160
+ limitsReasoning: true,
156
161
  stream: body.stream === true,
157
162
  store: body.store !== false,
158
163
  echo: {