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.
- 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
|
@@ -3,21 +3,37 @@ const MAX_ENTRY_CHARS = 4 * 1024 * 1024;
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* In-memory store behind previous_response_id and GET/DELETE
|
|
6
|
-
* /v1/responses/{id}. Bounded
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* /v1/responses/{id}. Bounded by entry count, by an approximate memory budget
|
|
7
|
+
* (JSON size of each entry's full history, so chained turns that share
|
|
8
|
+
* messages are counted conservatively) and by a 1h TTL, evicting least
|
|
9
|
+
* recently used entries first. Never persisted: a gateway restart forgets
|
|
10
|
+
* stored responses, which clients see as "not found".
|
|
11
|
+
* @param {{ maxEntries: number, maxChars?: number }} options
|
|
9
12
|
*/
|
|
10
|
-
export function createResponsesStore({ maxEntries }) {
|
|
13
|
+
export function createResponsesStore({ maxEntries, maxChars = Infinity }) {
|
|
11
14
|
const entries = new Map();
|
|
12
15
|
const items = new Map();
|
|
16
|
+
// Chained turns reuse the same message objects, so each is sized once.
|
|
17
|
+
const messageSizes = new WeakMap();
|
|
18
|
+
let chars = 0;
|
|
13
19
|
|
|
14
20
|
const expired = (entry) => Date.now() - entry.at > TTL_MS;
|
|
15
21
|
|
|
22
|
+
const messageSize = (message) => {
|
|
23
|
+
let size = messageSizes.get(message);
|
|
24
|
+
if (size === undefined) {
|
|
25
|
+
size = JSON.stringify(message).length;
|
|
26
|
+
messageSizes.set(message, size);
|
|
27
|
+
}
|
|
28
|
+
return size;
|
|
29
|
+
};
|
|
30
|
+
|
|
16
31
|
function evict(id) {
|
|
17
32
|
const entry = entries.get(id);
|
|
18
33
|
if (!entry) return;
|
|
19
34
|
for (const itemId of entry.itemIds) items.delete(itemId);
|
|
20
35
|
entries.delete(id);
|
|
36
|
+
chars -= entry.size;
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
function get(id) {
|
|
@@ -50,11 +66,14 @@ export function createResponsesStore({ maxEntries }) {
|
|
|
50
66
|
*/
|
|
51
67
|
save(response, history) {
|
|
52
68
|
if (maxEntries <= 0) return;
|
|
53
|
-
|
|
69
|
+
const size = history.reduce((sum, message) => sum + messageSize(message), JSON.stringify(response).length);
|
|
70
|
+
if (size > Math.min(MAX_ENTRY_CHARS, maxChars)) return;
|
|
71
|
+
evict(response.id);
|
|
54
72
|
const itemIds = response.output.map((item) => item.id);
|
|
55
73
|
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
|
-
|
|
74
|
+
entries.set(response.id, { response, history, itemIds, owner, at: Date.now(), size });
|
|
75
|
+
chars += size;
|
|
76
|
+
while (entries.size > maxEntries || chars > maxChars) evict(entries.keys().next().value);
|
|
58
77
|
},
|
|
59
78
|
response: (id) => owned(id, owner)?.response ?? null,
|
|
60
79
|
history: (id) => owned(id, owner)?.history ?? null,
|
|
@@ -74,5 +93,6 @@ export function createResponsesStore({ maxEntries }) {
|
|
|
74
93
|
enabled: maxEntries > 0,
|
|
75
94
|
scope,
|
|
76
95
|
size: () => entries.size,
|
|
96
|
+
chars: () => chars,
|
|
77
97
|
});
|
|
78
98
|
}
|
package/src/openai/responses.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ApiError } from '../server/errors.js';
|
|
2
2
|
import { generate } from './generate.js';
|
|
3
|
+
import { setIgnoredParams } from './ignored-params.js';
|
|
3
4
|
import { buildPrompt } from './prompt.js';
|
|
5
|
+
import { inlineRemoteAttachments } from './remote-media.js';
|
|
4
6
|
import { createResponseBuilder } from './response-builder.js';
|
|
5
7
|
import { parseResponsesRequest } from './responses-request.js';
|
|
6
8
|
import { openSse } from './sse-writer.js';
|
|
@@ -21,9 +23,10 @@ export function responsesHandlers({ runner, catalog, store, limits }) {
|
|
|
21
23
|
const model = await catalog.resolve(request.model);
|
|
22
24
|
const prompt = buildPrompt(request, model);
|
|
23
25
|
await assertPublicUrls(prompt.parts);
|
|
24
|
-
|
|
26
|
+
setIgnoredParams(res, request.ignored);
|
|
25
27
|
|
|
26
28
|
await req.withSlot(async (signal) => {
|
|
29
|
+
const ready = await inlineRemoteAttachments(prompt, { ...limits, signal });
|
|
27
30
|
const sse = request.stream ? openSse(res) : null;
|
|
28
31
|
const builder = createResponseBuilder({
|
|
29
32
|
model: model.id,
|
|
@@ -33,7 +36,7 @@ export function responsesHandlers({ runner, catalog, store, limits }) {
|
|
|
33
36
|
builder.start();
|
|
34
37
|
try {
|
|
35
38
|
const result = await generate({
|
|
36
|
-
runner, prompt, request, signal,
|
|
39
|
+
runner, prompt: ready, request, signal, onFirstToken: req.markFirstToken,
|
|
37
40
|
onText: (text) => builder.textDelta(text),
|
|
38
41
|
onReasoning: (text) => builder.reasoningDelta(text),
|
|
39
42
|
});
|
package/src/openai/stop.js
CHANGED
|
@@ -5,10 +5,38 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export function createStopFilter(stops) {
|
|
7
7
|
const sequences = stops.filter(Boolean);
|
|
8
|
-
const hold = sequences.reduce((max, s) => Math.max(max, s.length - 1), 0);
|
|
9
8
|
let pending = '';
|
|
10
9
|
let stopped = false;
|
|
11
10
|
|
|
11
|
+
/** Index of the earliest complete stop sequence in `pending`, or -1. */
|
|
12
|
+
function firstMatch() {
|
|
13
|
+
let cut = -1;
|
|
14
|
+
for (const sequence of sequences) {
|
|
15
|
+
const index = pending.indexOf(sequence);
|
|
16
|
+
if (index >= 0 && (cut < 0 || index < cut)) cut = index;
|
|
17
|
+
}
|
|
18
|
+
return cut;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Index of the earliest position where `pending` ends with the start of a
|
|
23
|
+
* stop sequence that more text could complete, or `pending.length`.
|
|
24
|
+
*/
|
|
25
|
+
function firstPartial() {
|
|
26
|
+
for (let i = 0; i < pending.length; i++) {
|
|
27
|
+
const tail = pending.slice(i);
|
|
28
|
+
if (sequences.some((s) => s.length > tail.length && s.startsWith(tail))) return i;
|
|
29
|
+
}
|
|
30
|
+
return pending.length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stopAt(cut) {
|
|
34
|
+
stopped = true;
|
|
35
|
+
const out = pending.slice(0, cut);
|
|
36
|
+
pending = '';
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
12
40
|
return {
|
|
13
41
|
get stopped() { return stopped; },
|
|
14
42
|
/** @returns {string} text that can be emitted now */
|
|
@@ -16,24 +44,20 @@ export function createStopFilter(stops) {
|
|
|
16
44
|
if (stopped || !text) return '';
|
|
17
45
|
if (!sequences.length) return text;
|
|
18
46
|
pending += text;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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);
|
|
47
|
+
const cut = firstMatch();
|
|
48
|
+
// A longer sequence that starts earlier may still complete, so a
|
|
49
|
+
// match is final only when no partial one begins before it.
|
|
50
|
+
const partial = firstPartial();
|
|
51
|
+
if (cut >= 0 && cut <= partial) return stopAt(cut);
|
|
52
|
+
const safe = cut >= 0 ? Math.min(cut, partial) : partial;
|
|
31
53
|
const out = pending.slice(0, safe);
|
|
32
54
|
pending = pending.slice(safe);
|
|
33
55
|
return out;
|
|
34
56
|
},
|
|
35
57
|
flush() {
|
|
36
|
-
|
|
58
|
+
if (stopped) return '';
|
|
59
|
+
const cut = firstMatch();
|
|
60
|
+
const out = cut >= 0 ? pending.slice(0, cut) : pending;
|
|
37
61
|
pending = '';
|
|
38
62
|
return out;
|
|
39
63
|
},
|
package/src/openai/tool-calls.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import { escapeFrames } from './markup.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* OpenAI function calling, emulated over a plain-text protocol.
|
|
@@ -49,11 +50,14 @@ export function toolInstructions(tools, choice, parallel) {
|
|
|
49
50
|
return lines.join('\n');
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Render a past call in the same syntax, so history teaches the protocol.
|
|
55
|
+
* Its name and arguments come from the client, so frame tags are escaped.
|
|
56
|
+
*/
|
|
53
57
|
export function renderToolCall(call) {
|
|
54
58
|
let args = call.arguments;
|
|
55
59
|
try { args = JSON.parse(call.arguments); } catch { /* keep raw string */ }
|
|
56
|
-
return `${OPEN}${JSON.stringify({ name: call.name, arguments: args })}${CLOSE}`;
|
|
60
|
+
return `${OPEN}${escapeFrames(JSON.stringify({ name: call.name, arguments: args }))}${CLOSE}`;
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
/** Length of a trailing fragment that could still grow into an opening tag. */
|
|
@@ -162,6 +166,8 @@ export function createToolCallParser(tools) {
|
|
|
162
166
|
let capturing = false;
|
|
163
167
|
|
|
164
168
|
return {
|
|
169
|
+
/** True while a call block is open and its text is held back. */
|
|
170
|
+
get capturing() { return capturing; },
|
|
165
171
|
push(text) {
|
|
166
172
|
pending += text;
|
|
167
173
|
if (capturing) return '';
|
package/src/openai/url-guard.js
CHANGED
|
@@ -3,59 +3,127 @@ import net from 'node:net';
|
|
|
3
3
|
import { invalidRequest } from '../server/errors.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Attachment URLs are fetched by
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Attachment URLs are fetched by the gateway, so a client could otherwise
|
|
7
|
+
* point them at internal services (SSRF). Only public addresses are allowed;
|
|
8
|
+
* this blocks loopback, private, link-local, CGNAT, multicast and other
|
|
9
|
+
* special-purpose ranges, including IPv6 forms that embed an IPv4 address.
|
|
10
10
|
*/
|
|
11
|
-
const
|
|
11
|
+
const BLOCKED_V4 = new net.BlockList();
|
|
12
12
|
[
|
|
13
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.
|
|
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]) =>
|
|
14
|
+
['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16],
|
|
15
|
+
['198.18.0.0', 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_V4.addSubnet(address, prefix, 'ipv4'));
|
|
17
|
+
|
|
18
|
+
const BLOCKED_V6 = new net.BlockList();
|
|
17
19
|
[
|
|
18
|
-
|
|
19
|
-
]
|
|
20
|
+
// ::/96 also covers the deprecated IPv4-compatible form (::a.b.c.d).
|
|
21
|
+
['::', 96], ['64:ff9b:1::', 48], ['100::', 64], ['2001::', 23], ['2001:db8::', 32],
|
|
22
|
+
['2002::', 16], ['3fff::', 20], ['5f00::', 16], ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8],
|
|
23
|
+
].forEach(([address, prefix]) => BLOCKED_V6.addSubnet(address, prefix, 'ipv6'));
|
|
20
24
|
|
|
21
25
|
const LOOKUP_TIMEOUT_MS = 5000;
|
|
26
|
+
/** Most attachments one request may send as URLs. */
|
|
27
|
+
export const MAX_REMOTE_PARTS = 16;
|
|
28
|
+
|
|
29
|
+
/** The eight 16-bit groups of a valid IPv6 address (which may end in dotted IPv4). */
|
|
30
|
+
function ipv6Groups(address) {
|
|
31
|
+
let text = address.toLowerCase();
|
|
32
|
+
const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
33
|
+
if (dotted) {
|
|
34
|
+
const [a, b, c, d] = dotted[1].split('.').map(Number);
|
|
35
|
+
text = `${text.slice(0, dotted.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
|
|
36
|
+
}
|
|
37
|
+
const [head, tail] = text.split('::');
|
|
38
|
+
const left = head ? head.split(':') : [];
|
|
39
|
+
const right = tail ? tail.split(':') : [];
|
|
40
|
+
const fill = text.includes('::') ? 8 - left.length - right.length : 0;
|
|
41
|
+
return [...left, ...Array(fill).fill('0'), ...right].map((group) => parseInt(group, 16));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The IPv4 address inside an IPv4-mapped (::ffff:0:0/96), IPv4-translated
|
|
46
|
+
* (::ffff:0:0:0/96) or NAT64 well-known-prefix (64:ff9b::/96) address.
|
|
47
|
+
*/
|
|
48
|
+
function embeddedIpv4(groups) {
|
|
49
|
+
const mapped = groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff;
|
|
50
|
+
const translated = groups.slice(0, 4).every((g) => g === 0) && groups[4] === 0xffff && groups[5] === 0;
|
|
51
|
+
const nat64 = groups[0] === 0x64 && groups[1] === 0xff9b && groups.slice(2, 6).every((g) => g === 0);
|
|
52
|
+
if (!mapped && !translated && !nat64) return null;
|
|
53
|
+
return [groups[6] >> 8, groups[6] & 0xff, groups[7] >> 8, groups[7] & 0xff].join('.');
|
|
54
|
+
}
|
|
22
55
|
|
|
23
56
|
export function isBlockedAddress(address) {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
57
|
+
const plain = String(address).replace(/%.*$/, '');
|
|
58
|
+
const family = net.isIP(plain);
|
|
59
|
+
if (family === 4) return BLOCKED_V4.check(plain, 'ipv4');
|
|
60
|
+
if (family !== 6) return true;
|
|
61
|
+
const v4 = embeddedIpv4(ipv6Groups(plain));
|
|
62
|
+
return v4 ? BLOCKED_V4.check(v4, 'ipv4') : BLOCKED_V6.check(plain, 'ipv6');
|
|
29
63
|
}
|
|
30
64
|
|
|
31
65
|
async function resolve(hostname) {
|
|
32
66
|
const literal = hostname.replace(/^\[|\]$/g, '');
|
|
33
|
-
if (net.isIP(literal)) return [literal];
|
|
34
|
-
|
|
35
|
-
const timeout = new Promise((_, reject) =>
|
|
36
|
-
|
|
37
|
-
|
|
67
|
+
if (net.isIP(literal)) return [{ address: literal, family: net.isIP(literal) }];
|
|
68
|
+
let timer;
|
|
69
|
+
const timeout = new Promise((_, reject) => {
|
|
70
|
+
timer = setTimeout(() => reject(new Error('DNS lookup timed out')), LOOKUP_TIMEOUT_MS);
|
|
71
|
+
timer.unref();
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
return await Promise.race([dns.lookup(literal, { all: true, verbatim: true }), timeout]);
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every address `hostname` resolves to, provided all of them are public.
|
|
82
|
+
* @param {string} hostname
|
|
83
|
+
* @param {(address: string) => boolean} [isBlocked]
|
|
84
|
+
* @returns {Promise<{ address: string, family: number }[]>}
|
|
85
|
+
*/
|
|
86
|
+
export async function resolvePublic(hostname, isBlocked = isBlockedAddress) {
|
|
87
|
+
// One message for both failures, so clients cannot probe which internal names exist.
|
|
88
|
+
const records = await resolve(hostname).catch(() => []);
|
|
89
|
+
if (!records.length || records.some((record) => isBlocked(record.address))) {
|
|
90
|
+
throw invalidRequest(`Attachment host '${hostname}' is not a public address.`, null, 'invalid_attachment_url');
|
|
91
|
+
}
|
|
92
|
+
return records;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A `lookup` for http(s).request that only ever connects to the addresses it
|
|
97
|
+
* has just checked, so DNS rebinding between check and connect cannot reach
|
|
98
|
+
* an internal address.
|
|
99
|
+
* @param {(address: string) => boolean} [isBlocked]
|
|
100
|
+
*/
|
|
101
|
+
export function publicLookup(isBlocked = isBlockedAddress) {
|
|
102
|
+
return (hostname, options, callback) => {
|
|
103
|
+
resolvePublic(hostname, isBlocked).then((records) => {
|
|
104
|
+
const wanted = options?.family ? records.filter((record) => record.family === options.family) : records;
|
|
105
|
+
if (!wanted.length) {
|
|
106
|
+
callback(Object.assign(new Error(`No IPv${options.family} address for ${hostname}`), { code: 'ENOTFOUND' }));
|
|
107
|
+
} else if (options?.all) {
|
|
108
|
+
callback(null, wanted);
|
|
109
|
+
} else {
|
|
110
|
+
callback(null, wanted[0].address, wanted[0].family);
|
|
111
|
+
}
|
|
112
|
+
}, callback);
|
|
113
|
+
};
|
|
38
114
|
}
|
|
39
115
|
|
|
40
116
|
/**
|
|
41
|
-
* Reject prompt parts whose https URL resolves to a non-public address.
|
|
117
|
+
* Reject prompt parts whose https URL resolves to a non-public address. This
|
|
118
|
+
* is a fast early check; the fetch itself re-checks through publicLookup.
|
|
42
119
|
* @param {{ url?: string }[]} parts OpenCode prompt parts
|
|
43
120
|
*/
|
|
44
121
|
export async function assertPublicUrls(parts) {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
122
|
+
const remote = parts.filter((part) => typeof part.url === 'string' && part.url.startsWith('https:'));
|
|
123
|
+
// Checked before resolving anything, so one request cannot queue thousands of DNS lookups.
|
|
124
|
+
if (remote.length > MAX_REMOTE_PARTS) {
|
|
125
|
+
throw invalidRequest(`At most ${MAX_REMOTE_PARTS} attachments may be URLs; send the rest as data URIs.`, null, 'too_many_attachment_urls');
|
|
49
126
|
}
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
}));
|
|
127
|
+
const hosts = new Set(remote.map((part) => new URL(part.url).hostname));
|
|
128
|
+
await Promise.all([...hosts].map((hostname) => resolvePublic(hostname)));
|
|
61
129
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CHARS_PER_TOKEN } from './length-limit.js';
|
|
2
|
+
|
|
3
|
+
export const NO_USAGE = Object.freeze({ input: 0, output: 0, reasoning: 0, cacheRead: 0 });
|
|
4
|
+
|
|
5
|
+
export const estimateTokens = (text) => Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Input tokens OpenCode adds on top of the prompt text (its system prompt and
|
|
9
|
+
* tool list), as last measured for each model. Model ids come from the
|
|
10
|
+
* catalog, so the map stays small.
|
|
11
|
+
*/
|
|
12
|
+
const overheadByModel = new Map();
|
|
13
|
+
let lastOverhead = 0;
|
|
14
|
+
|
|
15
|
+
const modelKey = (prompt) => `${prompt.model?.providerID}/${prompt.model?.modelID}`;
|
|
16
|
+
|
|
17
|
+
function promptTokens(prompt) {
|
|
18
|
+
const texts = [prompt.system || '', ...prompt.parts.filter((part) => part.type === 'text').map((part) => part.text)];
|
|
19
|
+
return texts.reduce((sum, text) => sum + estimateTokens(text), 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Remember how far a finished turn's real input count was above the prompt text alone. */
|
|
23
|
+
export function learnPromptOverhead(prompt, usage) {
|
|
24
|
+
const measured = usage.input + usage.cacheRead;
|
|
25
|
+
if (!(measured > 0)) return;
|
|
26
|
+
lastOverhead = Math.max(0, measured - promptTokens(prompt));
|
|
27
|
+
overheadByModel.set(modelKey(prompt), lastOverhead);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Usage for a turn cut off before OpenCode reported any: the output side
|
|
32
|
+
* from the text that was produced, the input side from the prompt plus the
|
|
33
|
+
* overhead last measured for this model (or any model, before the first).
|
|
34
|
+
*/
|
|
35
|
+
export function estimateUsage(prompt, content, reasoning) {
|
|
36
|
+
return {
|
|
37
|
+
...NO_USAGE,
|
|
38
|
+
input: promptTokens(prompt) + (overheadByModel.get(modelKey(prompt)) ?? lastOverhead),
|
|
39
|
+
output: estimateTokens(content),
|
|
40
|
+
reasoning: estimateTokens(reasoning),
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/opencode/backend.js
CHANGED
|
@@ -1,22 +1,45 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
|
-
import net from 'node:net';
|
|
3
2
|
import { createOpencodeClient } from './client.js';
|
|
4
3
|
import { resolveOpencodeBinary, spawnCommand } from './binary.js';
|
|
5
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
backendEnv, createIsolatedRoot, HEARTBEAT_MS, randomPassword, recordBackendPid, removeIsolatedRoot, sweepStaleRoots, touchIsolatedRoot,
|
|
6
|
+
} from './isolation.js';
|
|
6
7
|
|
|
7
8
|
const START_TIMEOUT_MS = 60000;
|
|
9
|
+
// Sweeping now and then (not only at start) also clears trees that were
|
|
10
|
+
// still too fresh to judge when this gateway started.
|
|
11
|
+
const SWEEP_EVERY_BEATS = 10;
|
|
8
12
|
const MAX_RESTART_DELAY_MS = 30000;
|
|
9
13
|
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
const LISTENING = /listening on http:\/\/127\.0\.0\.1:(\d{1,5})\b/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The port OpenCode reports once it has bound it. OpenCode picks the port
|
|
19
|
+
* itself (--port 0), so no other process can take it between being chosen
|
|
20
|
+
* and being bound and then pose as the backend.
|
|
21
|
+
*/
|
|
22
|
+
function boundPort(proc, timeoutMs) {
|
|
12
23
|
return new Promise((resolve, reject) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
let seen = '';
|
|
25
|
+
const onData = (chunk) => {
|
|
26
|
+
seen = `${seen}${chunk}`.slice(-4096);
|
|
27
|
+
const port = Number(LISTENING.exec(seen.replace(ANSI, ''))?.[1]);
|
|
28
|
+
if (port > 0 && port < 65536) finish(null, port);
|
|
29
|
+
};
|
|
30
|
+
const onExit = () => finish(new Error('OpenCode exited during startup'));
|
|
31
|
+
const timer = setTimeout(() => finish(new Error(`OpenCode did not start listening in ${timeoutMs / 1000}s`)), timeoutMs);
|
|
32
|
+
function finish(error, port) {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
proc.stdout.off('data', onData);
|
|
35
|
+
proc.off('exit', onExit);
|
|
36
|
+
proc.off('error', onExit);
|
|
37
|
+
if (error) reject(error);
|
|
38
|
+
else resolve(port);
|
|
39
|
+
}
|
|
40
|
+
proc.stdout.on('data', onData);
|
|
41
|
+
proc.once('exit', onExit);
|
|
42
|
+
proc.once('error', onExit);
|
|
20
43
|
});
|
|
21
44
|
}
|
|
22
45
|
|
|
@@ -63,7 +86,7 @@ function pipeLogs(stream, logger) {
|
|
|
63
86
|
|
|
64
87
|
/**
|
|
65
88
|
* Starts and supervises a private `opencode serve` bound to 127.0.0.1 on a
|
|
66
|
-
*
|
|
89
|
+
* port it picks itself, with a random password. It restarts with backoff if it crashes.
|
|
67
90
|
*/
|
|
68
91
|
export function createManagedBackend({ opencodePath, logger }) {
|
|
69
92
|
let client = null;
|
|
@@ -73,15 +96,16 @@ export function createManagedBackend({ opencodePath, logger }) {
|
|
|
73
96
|
let stopping = false;
|
|
74
97
|
let restartDelay = 1000;
|
|
75
98
|
let ready = false;
|
|
76
|
-
let
|
|
99
|
+
let heartbeat = null;
|
|
77
100
|
|
|
78
101
|
async function launch() {
|
|
79
102
|
const binary = resolveOpencodeBinary(opencodePath);
|
|
80
103
|
dirs = createIsolatedRoot();
|
|
81
|
-
const
|
|
104
|
+
const { root } = dirs;
|
|
105
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
82
106
|
const password = randomPassword();
|
|
83
|
-
const { command, args, windowsVerbatimArguments } = spawnCommand(binary.path, ['serve', '--hostname', '127.0.0.1', '--port',
|
|
84
|
-
logger.debug('Starting OpenCode backend', { binary: binary.path, source: binary.source
|
|
107
|
+
const { command, args, windowsVerbatimArguments } = spawnCommand(binary.path, ['serve', '--hostname', '127.0.0.1', '--port', '0']);
|
|
108
|
+
logger.debug('Starting OpenCode backend', { binary: binary.path, source: binary.source });
|
|
85
109
|
const proc = spawn(command, args, {
|
|
86
110
|
cwd: dirs.workspace,
|
|
87
111
|
env: backendEnv(dirs, password),
|
|
@@ -91,54 +115,94 @@ export function createManagedBackend({ opencodePath, logger }) {
|
|
|
91
115
|
windowsVerbatimArguments,
|
|
92
116
|
});
|
|
93
117
|
child = proc;
|
|
118
|
+
if (proc.pid) recordBackendPid(root, proc.pid);
|
|
94
119
|
pipeLogs(proc.stdout, logger);
|
|
95
120
|
pipeLogs(proc.stderr, logger);
|
|
96
121
|
let dead = false;
|
|
122
|
+
let served = false;
|
|
97
123
|
proc.once('error', (error) => { dead = true; logger.error('Could not start OpenCode', { error: error.message }); });
|
|
98
124
|
proc.once('exit', (code, signal) => {
|
|
99
125
|
dead = true;
|
|
100
|
-
ready = false;
|
|
101
|
-
|
|
126
|
+
if (child === proc) ready = false;
|
|
127
|
+
// A backend that dies while starting is handled by whoever is
|
|
128
|
+
// waiting for launch(); only a crash after it served is restarted here.
|
|
129
|
+
if (!stopping && served) onCrash(signal || code, root);
|
|
102
130
|
});
|
|
131
|
+
const port = await boundPort(proc, START_TIMEOUT_MS);
|
|
103
132
|
const candidate = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}`, username: 'opencode', password });
|
|
104
|
-
const health = await waitHealthy(candidate,
|
|
133
|
+
const health = await waitHealthy(candidate, deadline, () => dead);
|
|
105
134
|
client = candidate;
|
|
106
135
|
version = health.version || null;
|
|
107
136
|
ready = true;
|
|
108
|
-
|
|
137
|
+
served = true;
|
|
109
138
|
restartDelay = 1000;
|
|
110
139
|
logger.info(`OpenCode ${version || ''} backend ready (managed, isolated, tools disabled)`.replace(' ', ' '));
|
|
111
140
|
}
|
|
112
141
|
|
|
113
|
-
|
|
142
|
+
/** Kill the current backend, if any, and wait (briefly) until it is gone. */
|
|
143
|
+
async function terminate() {
|
|
144
|
+
const proc = child;
|
|
145
|
+
if (!proc?.pid || proc.exitCode !== null || proc.signalCode !== null) return;
|
|
146
|
+
const exited = new Promise((resolve) => proc.once('exit', resolve));
|
|
147
|
+
killTree(proc);
|
|
148
|
+
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 5000).unref())]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Relaunch with exponential backoff until a backend is up or the gateway stops. */
|
|
152
|
+
async function restart(reason) {
|
|
153
|
+
let why = reason;
|
|
154
|
+
while (!stopping) {
|
|
155
|
+
const delay = restartDelay;
|
|
156
|
+
restartDelay = Math.min(MAX_RESTART_DELAY_MS, restartDelay * 2);
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, delay).unref());
|
|
158
|
+
if (stopping) return;
|
|
159
|
+
logger.warn(`OpenCode backend ${why}; restarting after ${delay / 1000}s`);
|
|
160
|
+
try {
|
|
161
|
+
await launch();
|
|
162
|
+
return;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (stopping) {
|
|
165
|
+
removeIsolatedRoot(dirs?.root);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
logger.error('OpenCode restart failed', { error: error.message });
|
|
169
|
+
await terminate();
|
|
170
|
+
removeIsolatedRoot(dirs?.root);
|
|
171
|
+
why = 'failed to restart';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function onCrash(reason, root) {
|
|
114
177
|
// Ctrl+C and service managers signal the whole process group, so OpenCode
|
|
115
178
|
// can exit just before the gateway starts stopping. Only warn if the
|
|
116
179
|
// restart actually goes ahead.
|
|
117
|
-
const reason = signal || code;
|
|
118
180
|
logger.debug(`OpenCode backend exited (${reason})`);
|
|
119
181
|
removeIsolatedRoot(root);
|
|
120
|
-
|
|
121
|
-
restartDelay = Math.min(MAX_RESTART_DELAY_MS, restartDelay * 2);
|
|
122
|
-
setTimeout(() => {
|
|
123
|
-
if (stopping) return;
|
|
124
|
-
logger.warn(`OpenCode backend exited (${reason}); restarting after ${delay / 1000}s`);
|
|
125
|
-
launch().catch((error) => {
|
|
126
|
-
logger.error('OpenCode restart failed', { error: error.message });
|
|
127
|
-
killTree(child);
|
|
128
|
-
});
|
|
129
|
-
}, delay).unref();
|
|
182
|
+
void restart(`exited (${reason})`);
|
|
130
183
|
}
|
|
131
184
|
|
|
132
185
|
return Object.freeze({
|
|
133
186
|
mode: 'managed',
|
|
134
187
|
async start() {
|
|
135
|
-
const
|
|
136
|
-
|
|
188
|
+
const sweep = async () => {
|
|
189
|
+
const swept = await sweepStaleRoots();
|
|
190
|
+
if (swept) logger.debug(`Removed ${swept} stale backend directories`);
|
|
191
|
+
};
|
|
192
|
+
await sweep();
|
|
193
|
+
let beats = 0;
|
|
194
|
+
heartbeat = setInterval(() => {
|
|
195
|
+
if (dirs) touchIsolatedRoot(dirs.root);
|
|
196
|
+
beats += 1;
|
|
197
|
+
if (beats % SWEEP_EVERY_BEATS === 0) void sweep();
|
|
198
|
+
}, HEARTBEAT_MS);
|
|
199
|
+
heartbeat.unref();
|
|
137
200
|
try {
|
|
138
201
|
await launch();
|
|
139
202
|
} catch (error) {
|
|
140
203
|
stopping = true;
|
|
141
|
-
|
|
204
|
+
clearInterval(heartbeat);
|
|
205
|
+
await terminate();
|
|
142
206
|
removeIsolatedRoot(dirs?.root);
|
|
143
207
|
throw error;
|
|
144
208
|
}
|
|
@@ -153,11 +217,8 @@ export function createManagedBackend({ opencodePath, logger }) {
|
|
|
153
217
|
async stop() {
|
|
154
218
|
stopping = true;
|
|
155
219
|
ready = false;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
killTree(child);
|
|
159
|
-
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 5000).unref())]);
|
|
160
|
-
}
|
|
220
|
+
clearInterval(heartbeat);
|
|
221
|
+
await terminate();
|
|
161
222
|
removeIsolatedRoot(dirs?.root);
|
|
162
223
|
},
|
|
163
224
|
});
|
package/src/opencode/client.js
CHANGED
|
@@ -65,6 +65,7 @@ export function createOpencodeClient({ baseUrl, username, password, fetchImpl =
|
|
|
65
65
|
deleteSession: (sessionId, options) => request('DELETE', `/session/${id(sessionId)}`, options),
|
|
66
66
|
abortSession: (sessionId, options) => request('POST', `/session/${id(sessionId)}/abort`, { timeoutMs: 5000, ...options }),
|
|
67
67
|
messages: (sessionId, options) => request('GET', `/session/${id(sessionId)}/message`, options),
|
|
68
|
+
message: (sessionId, messageId, options) => request('GET', `/session/${id(sessionId)}/message/${id(messageId)}`, options),
|
|
68
69
|
/** Blocks until the assistant turn finishes; returns { info, parts }. */
|
|
69
70
|
prompt: (sessionId, body, options) => request('POST', `/session/${id(sessionId)}/message`, { body, timeoutMs: 0, ...options }),
|
|
70
71
|
replyPermission: (requestId, reply, message) =>
|