zengate 1.0.4 → 1.1.1
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 +31 -14
- package/config.json.example +3 -0
- package/index.js +1 -0
- package/package.json +2 -2
- package/src/bootstrap.js +4 -1
- package/src/config.js +4 -0
- package/src/gateway.js +32 -10
- package/src/node-version.js +11 -0
- package/src/openai/chat-request.js +17 -7
- package/src/openai/chat.js +17 -10
- package/src/openai/completions.js +140 -0
- package/src/openai/generate.js +55 -26
- package/src/openai/ignored-params.js +15 -0
- package/src/openai/length-limit.js +49 -0
- package/src/openai/map-limited.js +30 -0
- package/src/openai/markup.js +31 -0
- package/src/openai/prompt.js +9 -5
- package/src/openai/remote-media.js +222 -0
- package/src/openai/responses-request.js +12 -7
- package/src/openai/responses-store.js +27 -7
- package/src/openai/responses.js +5 -2
- package/src/openai/stop.js +38 -14
- package/src/openai/tool-calls.js +8 -2
- package/src/openai/url-guard.js +104 -36
- package/src/openai/usage-estimate.js +42 -0
- package/src/opencode/backend.js +100 -39
- package/src/opencode/client.js +1 -0
- package/src/opencode/events.js +22 -3
- package/src/opencode/isolation.js +139 -9
- package/src/opencode/runner.js +25 -6
- package/src/server/app.js +59 -10
- package/src/server/client-key.js +29 -0
- package/src/server/limiter.js +63 -9
- package/src/server/metrics.js +119 -11
- package/src/server/middleware.js +30 -1
- package/src/server/slot.js +13 -7
package/src/openai/generate.js
CHANGED
|
@@ -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
|
|
40
|
-
const allowed =
|
|
43
|
+
const accept = (text) => {
|
|
44
|
+
const allowed = lengthLimit.push(text);
|
|
41
45
|
emitText(parser ? parser.push(allowed) : allowed);
|
|
42
|
-
|
|
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
|
-
|
|
52
|
-
onReasoning(text);
|
|
70
|
+
if (!done()) acceptReasoning(text);
|
|
53
71
|
} else if (nativeFormat) {
|
|
54
72
|
held += text;
|
|
55
|
-
} else if (!
|
|
73
|
+
} else if (!done()) {
|
|
56
74
|
acceptText(text);
|
|
57
75
|
}
|
|
58
76
|
},
|
|
59
77
|
});
|
|
60
78
|
} catch (error) {
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
107
|
-
note = `Your previous attempt to call "${
|
|
108
|
-
} else if (
|
|
109
|
-
note = `There is no tool named "${
|
|
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 (
|
|
141
|
+
if (initial.content && !separated && text) {
|
|
115
142
|
separated = true;
|
|
116
143
|
onText(SEPARATOR);
|
|
117
144
|
}
|
|
118
145
|
emit(text);
|
|
119
146
|
};
|
|
120
|
-
const
|
|
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 ? `${
|
|
124
|
-
reasoning:
|
|
125
|
-
usage: addUsage(
|
|
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, '<$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, '<$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
|
+
}
|
package/src/openai/prompt.js
CHANGED
|
@@ -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
|
-
/**
|
|
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)
|
|
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', '
|
|
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(
|
|
48
|
-
case 'refusal': texts.push(
|
|
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(
|
|
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: {
|