zengate 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/config.json.example +23 -0
- package/index.js +84 -0
- package/package.json +57 -0
- package/scripts/setup.mjs +24 -0
- package/src/bootstrap.js +66 -0
- package/src/cli.js +55 -0
- package/src/config.js +193 -0
- package/src/gateway.js +90 -0
- package/src/logger.js +35 -0
- package/src/openai/chat-request.js +183 -0
- package/src/openai/chat.js +100 -0
- package/src/openai/generate.js +143 -0
- package/src/openai/media.js +104 -0
- package/src/openai/models.js +17 -0
- package/src/openai/prompt.js +101 -0
- package/src/openai/response-builder.js +135 -0
- package/src/openai/responses-request.js +176 -0
- package/src/openai/responses-store.js +78 -0
- package/src/openai/responses.js +65 -0
- package/src/openai/sse-writer.js +37 -0
- package/src/openai/stop.js +41 -0
- package/src/openai/tool-calls.js +187 -0
- package/src/openai/url-guard.js +61 -0
- package/src/opencode/backend.js +201 -0
- package/src/opencode/binary.js +75 -0
- package/src/opencode/catalog.js +83 -0
- package/src/opencode/client.js +84 -0
- package/src/opencode/events.js +168 -0
- package/src/opencode/isolation.js +119 -0
- package/src/opencode/model-errors.js +66 -0
- package/src/opencode/runner.js +224 -0
- package/src/opencode/sse-reader.js +40 -0
- package/src/paths.js +40 -0
- package/src/server/app.js +96 -0
- package/src/server/errors.js +56 -0
- package/src/server/limiter.js +72 -0
- package/src/server/metrics.js +31 -0
- package/src/server/middleware.js +94 -0
- package/src/server/slot.js +32 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { invalidRequest, unsupported } from '../server/errors.js';
|
|
2
|
+
import { parseResponseFormat, parseToolChoice } from './chat-request.js';
|
|
3
|
+
import { audioFromBase64, fileAttachment, imageFromUrl, videoFromUrl } from './media.js';
|
|
4
|
+
|
|
5
|
+
const IGNORED = new Set([
|
|
6
|
+
'temperature', 'top_p', 'max_output_tokens', 'max_tool_calls', 'top_logprobs', 'truncation', 'include',
|
|
7
|
+
'user', 'safety_identifier', 'prompt_cache_key', 'prompt_cache_retention', 'service_tier', 'stream_options',
|
|
8
|
+
]);
|
|
9
|
+
const HANDLED = new Set([
|
|
10
|
+
'model', 'input', 'instructions', 'stream', 'tools', 'tool_choice', 'parallel_tool_calls', 'text',
|
|
11
|
+
'reasoning', 'store', 'previous_response_id', 'metadata', 'background', 'conversation', 'prompt',
|
|
12
|
+
]);
|
|
13
|
+
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
|
|
15
|
+
function parseTools(tools, ignored) {
|
|
16
|
+
if (tools === undefined || tools === null) return [];
|
|
17
|
+
if (!Array.isArray(tools)) throw invalidRequest('tools must be an array.', 'tools');
|
|
18
|
+
const parsed = [];
|
|
19
|
+
tools.forEach((tool, index) => {
|
|
20
|
+
if (tool?.type === 'function' || tool?.type === 'custom') {
|
|
21
|
+
if (typeof tool.name !== 'string' || !/^[\w.-]{1,128}$/.test(tool.name)) {
|
|
22
|
+
throw invalidRequest('Tool names must be letters, digits, _ . or -.', `tools[${index}].name`);
|
|
23
|
+
}
|
|
24
|
+
parsed.push({
|
|
25
|
+
name: tool.name,
|
|
26
|
+
description: typeof tool.description === 'string' ? tool.description : '',
|
|
27
|
+
parameters: tool.type === 'function' ? tool.parameters : undefined,
|
|
28
|
+
kind: tool.type,
|
|
29
|
+
});
|
|
30
|
+
} else {
|
|
31
|
+
ignored.add(`tools.${tool?.type || 'unknown'}`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseContent(content, param, media, role) {
|
|
38
|
+
if (typeof content === 'string') return { text: content, media: [] };
|
|
39
|
+
if (!Array.isArray(content)) throw invalidRequest('content must be a string or an array of parts.', param);
|
|
40
|
+
const texts = [];
|
|
41
|
+
const attachments = [];
|
|
42
|
+
content.forEach((part, index) => {
|
|
43
|
+
const where = `${param}[${index}]`;
|
|
44
|
+
const options = { ...media, param: where };
|
|
45
|
+
switch (part?.type) {
|
|
46
|
+
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;
|
|
49
|
+
case 'input_image':
|
|
50
|
+
if (part.file_id) throw unsupported('input_image.file_id requires the Files API; send image_url instead.', where);
|
|
51
|
+
attachments.push(imageFromUrl(part.image_url, options)); break;
|
|
52
|
+
case 'input_file': attachments.push(fileAttachment(part, options)); break;
|
|
53
|
+
case 'input_audio': attachments.push(audioFromBase64(part.input_audio?.data ?? part.data, part.input_audio?.format ?? part.format, options)); break;
|
|
54
|
+
case 'input_video': attachments.push(videoFromUrl(part.video_url?.url ?? part.video_url ?? part.url, options)); break;
|
|
55
|
+
default: throw unsupported(`Unsupported content part type '${part?.type}'.`, where);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
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`);
|
|
60
|
+
return { text: texts.join(''), media: attachments.filter((a) => a.kind !== 'text') };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function outputText(output) {
|
|
64
|
+
if (typeof output === 'string') return output;
|
|
65
|
+
if (Array.isArray(output)) return output.map((part) => part?.text ?? '').join('');
|
|
66
|
+
return JSON.stringify(output ?? '');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Convert Responses input items into canonical messages. */
|
|
70
|
+
export function parseInputItems(input, media, resolveItem) {
|
|
71
|
+
const items = typeof input === 'string' ? [{ type: 'message', role: 'user', content: input }] : input;
|
|
72
|
+
if (!Array.isArray(items)) throw invalidRequest('input must be a string or an array of items.', 'input');
|
|
73
|
+
const messages = [];
|
|
74
|
+
const pushAssistantCall = (call) => {
|
|
75
|
+
const last = messages[messages.length - 1];
|
|
76
|
+
if (last?.role === 'assistant' && !last.content) last.toolCalls.push(call);
|
|
77
|
+
else messages.push({ role: 'assistant', content: '', media: [], toolCalls: [call] });
|
|
78
|
+
};
|
|
79
|
+
items.forEach((raw, index) => {
|
|
80
|
+
const param = `input[${index}]`;
|
|
81
|
+
const item = raw?.type === 'item_reference' ? resolveItem(raw.id, param) : raw;
|
|
82
|
+
const type = item?.type ?? (item?.role ? 'message' : undefined);
|
|
83
|
+
switch (type) {
|
|
84
|
+
case 'message': {
|
|
85
|
+
const role = item.role === 'developer' ? 'system' : item.role;
|
|
86
|
+
if (!['system', 'user', 'assistant'].includes(role)) throw invalidRequest('role must be user, assistant, system or developer.', `${param}.role`);
|
|
87
|
+
const { text, media: attachments } = parseContent(item.content, `${param}.content`, media, role);
|
|
88
|
+
messages.push({ role, content: text, media: attachments, ...(role === 'assistant' ? { toolCalls: [] } : {}) });
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case 'function_call':
|
|
92
|
+
case 'custom_tool_call':
|
|
93
|
+
if (typeof item.name !== 'string' || typeof item.call_id !== 'string') throw invalidRequest(`${type} needs name and call_id.`, param);
|
|
94
|
+
pushAssistantCall({
|
|
95
|
+
id: item.call_id,
|
|
96
|
+
name: item.name,
|
|
97
|
+
arguments: type === 'custom_tool_call' ? JSON.stringify({ input: String(item.input ?? '') }) : String(item.arguments || '{}'),
|
|
98
|
+
});
|
|
99
|
+
break;
|
|
100
|
+
case 'function_call_output':
|
|
101
|
+
case 'custom_tool_call_output':
|
|
102
|
+
if (typeof item.call_id !== 'string') throw invalidRequest(`${type} needs call_id.`, param);
|
|
103
|
+
messages.push({ role: 'tool', content: outputText(item.output), media: [], toolCallId: item.call_id });
|
|
104
|
+
break;
|
|
105
|
+
case 'reasoning':
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
throw unsupported(`Unsupported input item type '${type}'.`, param);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
return messages;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validate a Responses API request into canonical form (+ Responses extras).
|
|
116
|
+
* @param {unknown} body
|
|
117
|
+
* @param {{ maxBytes: number }} media
|
|
118
|
+
* @param {{ history: (id: string) => object[]|null, item: (id: string) => object|null }} store
|
|
119
|
+
*/
|
|
120
|
+
export function parseResponsesRequest(body, media, store) {
|
|
121
|
+
if (!isObject(body)) throw invalidRequest('Request body must be a JSON object.');
|
|
122
|
+
if (typeof body.model !== 'string' || !body.model.trim()) throw invalidRequest('model is required (see GET /v1/models).', 'model');
|
|
123
|
+
if (body.input === undefined) throw invalidRequest('input is required.', 'input');
|
|
124
|
+
if (body.background === true) throw unsupported('background responses are not supported; stream instead.', 'background');
|
|
125
|
+
if (body.conversation) throw unsupported('The Conversations API is not supported; use previous_response_id.', 'conversation');
|
|
126
|
+
if (body.prompt) throw unsupported('Stored prompt templates are not supported.', 'prompt');
|
|
127
|
+
if (body.instructions !== undefined && body.instructions !== null && typeof body.instructions !== 'string') {
|
|
128
|
+
throw invalidRequest('instructions must be a string.', 'instructions');
|
|
129
|
+
}
|
|
130
|
+
const ignored = new Set(Object.keys(body).filter((key) => IGNORED.has(key) || !HANDLED.has(key)));
|
|
131
|
+
const tools = parseTools(body.tools, ignored);
|
|
132
|
+
let history = [];
|
|
133
|
+
if (body.previous_response_id) {
|
|
134
|
+
history = store.history(body.previous_response_id);
|
|
135
|
+
if (!history) throw invalidRequest(`Previous response '${body.previous_response_id}' was not found (it may have expired or been stored with store=false).`, 'previous_response_id', 'previous_response_not_found');
|
|
136
|
+
}
|
|
137
|
+
const resolveItem = (id, param) => {
|
|
138
|
+
const item = store.item(id);
|
|
139
|
+
if (!item) throw invalidRequest(`Item '${id}' was not found.`, param);
|
|
140
|
+
return item;
|
|
141
|
+
};
|
|
142
|
+
const input = parseInputItems(body.input, media, resolveItem);
|
|
143
|
+
const system = body.instructions ? [{ role: 'system', content: body.instructions, media: [] }] : [];
|
|
144
|
+
return {
|
|
145
|
+
model: body.model.trim(),
|
|
146
|
+
messages: [...system, ...history, ...input],
|
|
147
|
+
inputMessages: input,
|
|
148
|
+
history,
|
|
149
|
+
tools,
|
|
150
|
+
toolChoice: parseToolChoice(body.tool_choice, tools),
|
|
151
|
+
parallelToolCalls: body.parallel_tool_calls !== false,
|
|
152
|
+
format: parseResponseFormat(body.text?.format, 'text.format'),
|
|
153
|
+
reasoningEffort: typeof body.reasoning?.effort === 'string' ? body.reasoning.effort : null,
|
|
154
|
+
stop: [],
|
|
155
|
+
n: 1,
|
|
156
|
+
stream: body.stream === true,
|
|
157
|
+
store: body.store !== false,
|
|
158
|
+
echo: {
|
|
159
|
+
instructions: body.instructions ?? null,
|
|
160
|
+
metadata: isObject(body.metadata) ? body.metadata : {},
|
|
161
|
+
previous_response_id: body.previous_response_id ?? null,
|
|
162
|
+
parallel_tool_calls: body.parallel_tool_calls !== false,
|
|
163
|
+
tool_choice: body.tool_choice ?? 'auto',
|
|
164
|
+
tools: Array.isArray(body.tools) ? body.tools : [],
|
|
165
|
+
text: body.text?.format ? { format: body.text.format } : { format: { type: 'text' } },
|
|
166
|
+
reasoning: { effort: body.reasoning?.effort ?? null, summary: body.reasoning?.summary ?? null },
|
|
167
|
+
temperature: body.temperature ?? null,
|
|
168
|
+
top_p: body.top_p ?? null,
|
|
169
|
+
max_output_tokens: body.max_output_tokens ?? null,
|
|
170
|
+
user: body.user ?? null,
|
|
171
|
+
store: body.store !== false,
|
|
172
|
+
truncation: body.truncation ?? 'disabled',
|
|
173
|
+
},
|
|
174
|
+
ignored: [...ignored],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const TTL_MS = 60 * 60 * 1000;
|
|
2
|
+
const MAX_ENTRY_CHARS = 4 * 1024 * 1024;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-memory store behind previous_response_id and GET/DELETE
|
|
6
|
+
* /v1/responses/{id}. Bounded (LRU + 1h TTL) and never persisted: a gateway
|
|
7
|
+
* restart forgets stored responses, which clients see as "not found".
|
|
8
|
+
* @param {{ maxEntries: number }} options
|
|
9
|
+
*/
|
|
10
|
+
export function createResponsesStore({ maxEntries }) {
|
|
11
|
+
const entries = new Map();
|
|
12
|
+
const items = new Map();
|
|
13
|
+
|
|
14
|
+
const expired = (entry) => Date.now() - entry.at > TTL_MS;
|
|
15
|
+
|
|
16
|
+
function evict(id) {
|
|
17
|
+
const entry = entries.get(id);
|
|
18
|
+
if (!entry) return;
|
|
19
|
+
for (const itemId of entry.itemIds) items.delete(itemId);
|
|
20
|
+
entries.delete(id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function get(id) {
|
|
24
|
+
const entry = entries.get(id);
|
|
25
|
+
if (!entry) return null;
|
|
26
|
+
if (expired(entry)) {
|
|
27
|
+
evict(id);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
entries.delete(id);
|
|
31
|
+
entries.set(id, entry);
|
|
32
|
+
return entry;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const owned = (id, owner) => {
|
|
36
|
+
const entry = get(id);
|
|
37
|
+
return entry && entry.owner === owner ? entry : null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A view limited to one client (API key): other keys' responses are
|
|
42
|
+
* invisible, as if they did not exist.
|
|
43
|
+
* @param {string} owner opaque client id
|
|
44
|
+
*/
|
|
45
|
+
function scope(owner) {
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} response public Response object
|
|
49
|
+
* @param {object[]} history canonical messages up to and including this turn
|
|
50
|
+
*/
|
|
51
|
+
save(response, history) {
|
|
52
|
+
if (maxEntries <= 0) return;
|
|
53
|
+
if (JSON.stringify(history).length > MAX_ENTRY_CHARS) return;
|
|
54
|
+
const itemIds = response.output.map((item) => item.id);
|
|
55
|
+
for (const item of response.output) items.set(item.id, { item, responseId: response.id });
|
|
56
|
+
entries.set(response.id, { response, history, itemIds, owner, at: Date.now() });
|
|
57
|
+
while (entries.size > maxEntries) evict(entries.keys().next().value);
|
|
58
|
+
},
|
|
59
|
+
response: (id) => owned(id, owner)?.response ?? null,
|
|
60
|
+
history: (id) => owned(id, owner)?.history ?? null,
|
|
61
|
+
item(id) {
|
|
62
|
+
const found = items.get(id);
|
|
63
|
+
return found && owned(found.responseId, owner) ? found.item : null;
|
|
64
|
+
},
|
|
65
|
+
delete(id) {
|
|
66
|
+
if (!owned(id, owner)) return false;
|
|
67
|
+
evict(id);
|
|
68
|
+
return true;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
enabled: maxEntries > 0,
|
|
75
|
+
scope,
|
|
76
|
+
size: () => entries.size,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ApiError } from '../server/errors.js';
|
|
2
|
+
import { generate } from './generate.js';
|
|
3
|
+
import { buildPrompt } from './prompt.js';
|
|
4
|
+
import { createResponseBuilder } from './response-builder.js';
|
|
5
|
+
import { parseResponsesRequest } from './responses-request.js';
|
|
6
|
+
import { openSse } from './sse-writer.js';
|
|
7
|
+
import { assertPublicUrls } from './url-guard.js';
|
|
8
|
+
|
|
9
|
+
function assistantHistory(result) {
|
|
10
|
+
return { role: 'assistant', content: result.content, media: [], toolCalls: result.toolCalls.map(({ id, name, arguments: args }) => ({ id, name, arguments: args })) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* POST /v1/responses, GET /v1/responses/:id, DELETE /v1/responses/:id
|
|
15
|
+
* @param {{ runner: object, catalog: object, store: object, limits: { maxBytes: number } }} deps
|
|
16
|
+
*/
|
|
17
|
+
export function responsesHandlers({ runner, catalog, store, limits }) {
|
|
18
|
+
const create = async (req, res) => {
|
|
19
|
+
const scoped = store.scope(req.clientId);
|
|
20
|
+
const request = parseResponsesRequest(req.body, limits, scoped);
|
|
21
|
+
const model = await catalog.resolve(request.model);
|
|
22
|
+
const prompt = buildPrompt(request, model);
|
|
23
|
+
await assertPublicUrls(prompt.parts);
|
|
24
|
+
if (request.ignored.length) res.set('x-gateway-ignored-params', request.ignored.join(','));
|
|
25
|
+
|
|
26
|
+
await req.withSlot(async (signal) => {
|
|
27
|
+
const sse = request.stream ? openSse(res) : null;
|
|
28
|
+
const builder = createResponseBuilder({
|
|
29
|
+
model: model.id,
|
|
30
|
+
echo: request.echo,
|
|
31
|
+
emit: sse ? (event) => sse.send(event, event.type) : undefined,
|
|
32
|
+
});
|
|
33
|
+
builder.start();
|
|
34
|
+
try {
|
|
35
|
+
const result = await generate({
|
|
36
|
+
runner, prompt, request, signal,
|
|
37
|
+
onText: (text) => builder.textDelta(text),
|
|
38
|
+
onReasoning: (text) => builder.reasoningDelta(text),
|
|
39
|
+
});
|
|
40
|
+
const response = builder.finish(result);
|
|
41
|
+
if (request.store) scoped.save(response, [...request.history, ...request.inputMessages, assistantHistory(result)]);
|
|
42
|
+
if (!sse) res.json(response);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (!sse) throw error;
|
|
45
|
+
const apiError = builder.fail(error);
|
|
46
|
+
req.log.warn('Responses stream failed', { status: apiError.status, code: apiError.code, error: apiError.message });
|
|
47
|
+
} finally {
|
|
48
|
+
sse?.end();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const retrieve = (req, res) => {
|
|
54
|
+
const response = store.scope(req.clientId).response(req.params.id);
|
|
55
|
+
if (!response) throw new ApiError(404, `No response with id '${req.params.id}' (responses are kept in memory for one hour).`, { code: 'not_found' });
|
|
56
|
+
res.json(response);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const remove = (req, res) => {
|
|
60
|
+
if (!store.scope(req.clientId).delete(req.params.id)) throw new ApiError(404, `No response with id '${req.params.id}'.`, { code: 'not_found' });
|
|
61
|
+
res.json({ id: req.params.id, object: 'response', deleted: true });
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return { create, retrieve, remove };
|
|
65
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const HEARTBEAT_MS = 15000;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Server-sent events response helper: flushes headers immediately, disables
|
|
5
|
+
* Nagle for low token latency and sends comment heartbeats so idle proxies
|
|
6
|
+
* don't cut long generations.
|
|
7
|
+
*/
|
|
8
|
+
export function openSse(res) {
|
|
9
|
+
res.status(200);
|
|
10
|
+
res.set({
|
|
11
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
12
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
13
|
+
Connection: 'keep-alive',
|
|
14
|
+
'X-Accel-Buffering': 'no',
|
|
15
|
+
});
|
|
16
|
+
res.flushHeaders();
|
|
17
|
+
res.socket?.setNoDelay(true);
|
|
18
|
+
const heartbeat = setInterval(() => write(': keep-alive\n\n'), HEARTBEAT_MS);
|
|
19
|
+
heartbeat.unref();
|
|
20
|
+
|
|
21
|
+
function write(text) {
|
|
22
|
+
if (res.writableEnded || res.destroyed) return;
|
|
23
|
+
res.write(text);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
/** Send one JSON event; `event` adds an SSE event name (Responses API). */
|
|
28
|
+
send(data, event) {
|
|
29
|
+
write(`${event ? `event: ${event}\n` : ''}data: ${JSON.stringify(data)}\n\n`);
|
|
30
|
+
},
|
|
31
|
+
raw: write,
|
|
32
|
+
end() {
|
|
33
|
+
clearInterval(heartbeat);
|
|
34
|
+
if (!res.writableEnded) res.end();
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies OpenAI `stop` sequences to streamed text. Text that could be the
|
|
3
|
+
* beginning of a stop sequence is held back until it is safe to release.
|
|
4
|
+
* @param {string[]} stops
|
|
5
|
+
*/
|
|
6
|
+
export function createStopFilter(stops) {
|
|
7
|
+
const sequences = stops.filter(Boolean);
|
|
8
|
+
const hold = sequences.reduce((max, s) => Math.max(max, s.length - 1), 0);
|
|
9
|
+
let pending = '';
|
|
10
|
+
let stopped = false;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
get stopped() { return stopped; },
|
|
14
|
+
/** @returns {string} text that can be emitted now */
|
|
15
|
+
push(text) {
|
|
16
|
+
if (stopped || !text) return '';
|
|
17
|
+
if (!sequences.length) return text;
|
|
18
|
+
pending += text;
|
|
19
|
+
let cut = -1;
|
|
20
|
+
for (const sequence of sequences) {
|
|
21
|
+
const index = pending.indexOf(sequence);
|
|
22
|
+
if (index >= 0 && (cut < 0 || index < cut)) cut = index;
|
|
23
|
+
}
|
|
24
|
+
if (cut >= 0) {
|
|
25
|
+
stopped = true;
|
|
26
|
+
const out = pending.slice(0, cut);
|
|
27
|
+
pending = '';
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
const safe = Math.max(0, pending.length - hold);
|
|
31
|
+
const out = pending.slice(0, safe);
|
|
32
|
+
pending = pending.slice(safe);
|
|
33
|
+
return out;
|
|
34
|
+
},
|
|
35
|
+
flush() {
|
|
36
|
+
const out = stopped ? '' : pending;
|
|
37
|
+
pending = '';
|
|
38
|
+
return out;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OpenAI function calling, emulated over a plain-text protocol.
|
|
5
|
+
*
|
|
6
|
+
* OpenCode sessions only expose OpenCode's own tools (which this gateway
|
|
7
|
+
* rejects), so client-defined functions are described in the system prompt
|
|
8
|
+
* and the model answers with <tool_call>{json}</tool_call> blocks. The parser
|
|
9
|
+
* turns those back into standard tool_calls; everything else stays content.
|
|
10
|
+
* Models trained on other conventions sometimes write <function_calls> or
|
|
11
|
+
* <tool_calls> instead, so those spellings are accepted too.
|
|
12
|
+
*/
|
|
13
|
+
const OPEN = '<tool_call>';
|
|
14
|
+
const CLOSE = '</tool_call>';
|
|
15
|
+
const TAGS = ['tool_call', 'tool_calls', 'function_call', 'function_calls'];
|
|
16
|
+
const OPENERS = [...TAGS.map((tag) => `<${tag}>`), '<function='];
|
|
17
|
+
// Group 1: wrapper tag name; group 2: function name in the <function=name> form.
|
|
18
|
+
const OPEN_RE = /<(tool_calls?|function_calls?)>|<function=([\w.-]{1,128})>/;
|
|
19
|
+
const NAMED_TAIL_RE = /^<function=[\w.-]{0,128}$/;
|
|
20
|
+
const MAX_HELD = 150;
|
|
21
|
+
|
|
22
|
+
export const newCallId = () => `call_${crypto.randomBytes(12).toString('hex')}`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ name: string, description?: string, parameters?: object, kind?: 'function'|'custom' }[]} tools
|
|
26
|
+
* @param {'auto'|'none'|'required'|{ name: string }} choice
|
|
27
|
+
* @param {boolean} parallel
|
|
28
|
+
*/
|
|
29
|
+
export function toolInstructions(tools, choice, parallel) {
|
|
30
|
+
if (!tools.length || choice === 'none') return '';
|
|
31
|
+
const lines = [
|
|
32
|
+
'# Client functions',
|
|
33
|
+
'The user\'s application provides the functions listed below. They are NOT native tools: invoking them as a tool fails.',
|
|
34
|
+
'The only way to call one is to write this text block in your reply, with the arguments as a JSON object:',
|
|
35
|
+
`${OPEN}{"name": "<function name>", "arguments": {<arguments>}}${CLOSE}`,
|
|
36
|
+
parallel
|
|
37
|
+
? 'Write one block per call; several blocks call several functions at once. Write nothing after the last block.'
|
|
38
|
+
: 'Call at most one function per reply. Write nothing after the block.',
|
|
39
|
+
'The application runs the function and sends the result back as a <tool_result> entry. When no call is needed, reply normally without any block.',
|
|
40
|
+
];
|
|
41
|
+
if (choice === 'required') lines.push('You must call at least one of these functions in this reply by writing a block.');
|
|
42
|
+
if (choice && typeof choice === 'object') lines.push(`You must call the function "${choice.name}" in this reply by writing a block.`);
|
|
43
|
+
lines.push('', 'Functions:');
|
|
44
|
+
for (const tool of tools) {
|
|
45
|
+
lines.push(`- ${tool.name}${tool.description ? `: ${tool.description}` : ''}`);
|
|
46
|
+
if (tool.kind === 'custom') lines.push(' arguments: {"input": "<free-form text input>"}');
|
|
47
|
+
else lines.push(` arguments JSON Schema: ${JSON.stringify(tool.parameters || { type: 'object', properties: {} })}`);
|
|
48
|
+
}
|
|
49
|
+
return lines.join('\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Render a past call in the same syntax, so history teaches the protocol. */
|
|
53
|
+
export function renderToolCall(call) {
|
|
54
|
+
let args = call.arguments;
|
|
55
|
+
try { args = JSON.parse(call.arguments); } catch { /* keep raw string */ }
|
|
56
|
+
return `${OPEN}${JSON.stringify({ name: call.name, arguments: args })}${CLOSE}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Length of a trailing fragment that could still grow into an opening tag. */
|
|
60
|
+
function heldPrefixLength(text) {
|
|
61
|
+
const lt = text.lastIndexOf('<');
|
|
62
|
+
if (lt < 0 || text.length - lt > MAX_HELD) return 0;
|
|
63
|
+
const tail = text.slice(lt);
|
|
64
|
+
return OPENERS.some((opener) => opener.startsWith(tail)) || NAMED_TAIL_RE.test(tail) ? tail.length : 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Arguments of the <function=name> form: a JSON object or <parameter=key>value</parameter> pairs. */
|
|
68
|
+
function namedArguments(body) {
|
|
69
|
+
const params = [...body.matchAll(/<parameter=([\w.-]+)>([\s\S]*?)<\/parameter>/g)];
|
|
70
|
+
if (!params.length) return jsonValues(body)?.[0] ?? (body.trim() ? null : {});
|
|
71
|
+
return Object.fromEntries(params.map(([, key, raw]) => {
|
|
72
|
+
const value = raw.trim();
|
|
73
|
+
try { return [key, JSON.parse(value)]; } catch { return [key, value]; }
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Split a block body into top-level JSON values (objects or arrays). */
|
|
78
|
+
function jsonValues(body) {
|
|
79
|
+
const text = body.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
|
|
80
|
+
const values = [];
|
|
81
|
+
let depth = 0;
|
|
82
|
+
let start = -1;
|
|
83
|
+
let inString = false;
|
|
84
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
85
|
+
const ch = text[i];
|
|
86
|
+
if (inString) {
|
|
87
|
+
if (ch === '\\') i += 1;
|
|
88
|
+
else if (ch === '"') inString = false;
|
|
89
|
+
} else if (ch === '"') {
|
|
90
|
+
inString = true;
|
|
91
|
+
} else if (ch === '{' || ch === '[') {
|
|
92
|
+
if (depth === 0) start = i;
|
|
93
|
+
depth += 1;
|
|
94
|
+
} else if ((ch === '}' || ch === ']') && depth > 0) {
|
|
95
|
+
depth -= 1;
|
|
96
|
+
if (depth === 0) {
|
|
97
|
+
try { values.push(JSON.parse(text.slice(start, i + 1))); } catch { return null; }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return values.length ? values.flat() : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toCall(parsed, toolsByName) {
|
|
105
|
+
const name = typeof parsed?.name === 'string' ? parsed.name : parsed?.function?.name;
|
|
106
|
+
const tool = toolsByName.get(name);
|
|
107
|
+
if (!tool) return null;
|
|
108
|
+
const rawArgs = parsed.arguments ?? parsed.parameters ?? parsed.input ?? parsed.function?.arguments ?? {};
|
|
109
|
+
let args = rawArgs;
|
|
110
|
+
if (typeof rawArgs === 'string') {
|
|
111
|
+
try { args = JSON.parse(rawArgs); } catch { args = tool.kind === 'custom' ? { input: rawArgs } : null; }
|
|
112
|
+
}
|
|
113
|
+
if (tool.kind === 'custom' && (typeof args !== 'object' || args === null || !('input' in args))) args = { input: typeof args === 'string' ? args : JSON.stringify(args) };
|
|
114
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) return null;
|
|
115
|
+
return { id: newCallId(), name, kind: tool.kind || 'function', arguments: JSON.stringify(args) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseBlock(body, toolsByName) {
|
|
119
|
+
const values = jsonValues(body);
|
|
120
|
+
if (!values) return null;
|
|
121
|
+
const calls = values.map((value) => toCall(value, toolsByName));
|
|
122
|
+
return calls.every(Boolean) ? calls : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseNamed(name, body, toolsByName) {
|
|
126
|
+
const args = namedArguments(body);
|
|
127
|
+
if (args === null) return null;
|
|
128
|
+
const call = toCall({ name, arguments: args }, toolsByName);
|
|
129
|
+
return call ? [call] : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Parse all blocks in `text`; unparseable blocks stay as visible text. */
|
|
133
|
+
function extractCalls(text, toolsByName) {
|
|
134
|
+
const calls = [];
|
|
135
|
+
let leftover = '';
|
|
136
|
+
let rest = text;
|
|
137
|
+
while (rest.length) {
|
|
138
|
+
const match = OPEN_RE.exec(rest);
|
|
139
|
+
if (!match) { leftover += rest; break; }
|
|
140
|
+
leftover += rest.slice(0, match.index);
|
|
141
|
+
const named = match[2];
|
|
142
|
+
const close = named ? '</function>' : `</${match[1]}>`;
|
|
143
|
+
const bodyStart = match.index + match[0].length;
|
|
144
|
+
const end = rest.indexOf(close, bodyStart);
|
|
145
|
+
const body = rest.slice(bodyStart, end < 0 ? undefined : end);
|
|
146
|
+
const parsed = named ? parseNamed(named, body, toolsByName) : parseBlock(body, toolsByName);
|
|
147
|
+
if (parsed) calls.push(...parsed);
|
|
148
|
+
else leftover += rest.slice(match.index, end < 0 ? undefined : end + close.length);
|
|
149
|
+
rest = end < 0 ? '' : rest.slice(end + close.length);
|
|
150
|
+
}
|
|
151
|
+
return { text: calls.length ? leftover.trim() : leftover, calls };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Streaming parser. `push(text)` returns content that is safe to show now;
|
|
156
|
+
* once a call block starts, the rest is held until `finish()`.
|
|
157
|
+
* @param {{ name: string, kind?: string }[]} tools
|
|
158
|
+
*/
|
|
159
|
+
export function createToolCallParser(tools) {
|
|
160
|
+
const toolsByName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
161
|
+
let pending = '';
|
|
162
|
+
let capturing = false;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
push(text) {
|
|
166
|
+
pending += text;
|
|
167
|
+
if (capturing) return '';
|
|
168
|
+
const match = OPEN_RE.exec(pending);
|
|
169
|
+
if (match) {
|
|
170
|
+
const visible = pending.slice(0, match.index);
|
|
171
|
+
pending = pending.slice(match.index);
|
|
172
|
+
capturing = true;
|
|
173
|
+
return visible;
|
|
174
|
+
}
|
|
175
|
+
const hold = heldPrefixLength(pending);
|
|
176
|
+
const visible = pending.slice(0, pending.length - hold);
|
|
177
|
+
pending = pending.slice(pending.length - hold);
|
|
178
|
+
return visible;
|
|
179
|
+
},
|
|
180
|
+
/** @returns {{ text: string, calls: object[] }} trailing content and parsed calls */
|
|
181
|
+
finish() {
|
|
182
|
+
const rest = pending;
|
|
183
|
+
pending = '';
|
|
184
|
+
return capturing ? extractCalls(rest, toolsByName) : { text: rest, calls: [] };
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import dns from 'node:dns/promises';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { invalidRequest } from '../server/errors.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Attachment URLs are fetched by OpenCode on this host, so a client could
|
|
7
|
+
* otherwise point them at internal services (SSRF). Only public addresses
|
|
8
|
+
* are allowed; this blocks loopback, private, link-local, CGNAT, multicast
|
|
9
|
+
* and other special-purpose ranges.
|
|
10
|
+
*/
|
|
11
|
+
const BLOCKED = new net.BlockList();
|
|
12
|
+
[
|
|
13
|
+
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
|
|
14
|
+
['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
|
|
15
|
+
['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
16
|
+
].forEach(([address, prefix]) => BLOCKED.addSubnet(address, prefix, 'ipv4'));
|
|
17
|
+
[
|
|
18
|
+
['::', 128], ['::1', 128], ['64:ff9b::', 96], ['100::', 64], ['2001:db8::', 32], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
|
|
19
|
+
].forEach(([address, prefix]) => BLOCKED.addSubnet(address, prefix, 'ipv6'));
|
|
20
|
+
|
|
21
|
+
const LOOKUP_TIMEOUT_MS = 5000;
|
|
22
|
+
|
|
23
|
+
export function isBlockedAddress(address) {
|
|
24
|
+
const family = net.isIP(address);
|
|
25
|
+
if (family === 0) return true;
|
|
26
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
|
|
27
|
+
if (mapped) return BLOCKED.check(mapped[1], 'ipv4');
|
|
28
|
+
return BLOCKED.check(address, family === 4 ? 'ipv4' : 'ipv6');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function resolve(hostname) {
|
|
32
|
+
const literal = hostname.replace(/^\[|\]$/g, '');
|
|
33
|
+
if (net.isIP(literal)) return [literal];
|
|
34
|
+
const lookup = dns.lookup(literal, { all: true, verbatim: true });
|
|
35
|
+
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('DNS lookup timed out')), LOOKUP_TIMEOUT_MS).unref());
|
|
36
|
+
const records = await Promise.race([lookup, timeout]);
|
|
37
|
+
return records.map((record) => record.address);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reject prompt parts whose https URL resolves to a non-public address.
|
|
42
|
+
* @param {{ url?: string }[]} parts OpenCode prompt parts
|
|
43
|
+
*/
|
|
44
|
+
export async function assertPublicUrls(parts) {
|
|
45
|
+
const hosts = new Set();
|
|
46
|
+
for (const part of parts) {
|
|
47
|
+
if (typeof part.url !== 'string' || !part.url.startsWith('https:')) continue;
|
|
48
|
+
hosts.add(new URL(part.url).hostname);
|
|
49
|
+
}
|
|
50
|
+
await Promise.all([...hosts].map(async (hostname) => {
|
|
51
|
+
let addresses;
|
|
52
|
+
try {
|
|
53
|
+
addresses = await resolve(hostname);
|
|
54
|
+
} catch {
|
|
55
|
+
throw invalidRequest(`Could not resolve attachment host '${hostname}'.`, null, 'invalid_attachment_url');
|
|
56
|
+
}
|
|
57
|
+
if (!addresses.length || addresses.some(isBlockedAddress)) {
|
|
58
|
+
throw invalidRequest(`Attachment host '${hostname}' is not a public address.`, null, 'invalid_attachment_url');
|
|
59
|
+
}
|
|
60
|
+
}));
|
|
61
|
+
}
|