@danceiny/gotry 0.2.0-rc.32 → 0.2.0-rc.33
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/extension-bridge.js +140 -46
- package/dist/capabilities/session/gui-flight.js +281 -69
- package/dist/capabilities/session/gui-hotel.js +74 -17
- package/dist/capabilities/session-search.js +6 -5
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
- package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +42 -8
- package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
- package/dist/scripts/tenant-capacity-probe.js +263 -0
- package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
- package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
- package/dist/scripts/tenant-executor-binding-tests.js +203 -0
- package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
- package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
- package/dist/scripts/tenant-seam-quota-tests.js +383 -0
- package/dist/scripts/tenant-seam-test-kit.js +292 -0
- package/dist/scripts/tenant-state-copies-tests.js +607 -0
- package/dist/scripts/tenant-state-drill-tests.js +298 -0
- package/dist/scripts/tenant-state-drill-worker.js +164 -0
- package/dist/scripts/tenant-state-durable-conformance.js +101 -0
- package/dist/scripts/tenant-state-handoff-tests.js +854 -0
- package/dist/scripts/tenant-state-lease-conformance.js +142 -0
- package/dist/scripts/tenant-state-lease-worker.js +28 -0
- package/dist/scripts/tenant-state-router-tests.js +464 -0
- package/dist/scripts/tenant-state-test-kit.js +92 -0
- package/dist/scripts/tenant-state-tests.js +564 -0
- package/dist/src/backend/modules/booking-executor.js +61 -6
- package/dist/src/backend/modules/session-search.js +136 -39
- package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/dist/src/booking-surface/dsh-planner.js +150 -19
- package/dist/src/booking-surface/index.js +1 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
- package/dist/src/booking-surface/server.js +193 -80
- package/dist/src/booking-surface/startup.js +153 -0
- package/dist/src/booking-surface/tenancy.js +188 -0
- package/dist/src/index.js +31 -1
- package/dist/src/llm-price-table.js +148 -0
- package/dist/src/tenant-seam/index.js +8 -0
- package/dist/src/tenant-seam/llm-broker.js +508 -0
- package/dist/src/tenant-seam/process-governor.js +281 -0
- package/dist/src/tenant-seam/quotas.js +360 -0
- package/dist/src/tenant-seam/seam.js +92 -0
- package/dist/src/tenant-state/durable-store.js +158 -0
- package/dist/src/tenant-state/epoch-copies.js +422 -0
- package/dist/src/tenant-state/errors.js +108 -0
- package/dist/src/tenant-state/ids.js +101 -0
- package/dist/src/tenant-state/index.js +11 -0
- package/dist/src/tenant-state/lease-store.js +203 -0
- package/dist/src/tenant-state/tenant-lease.js +150 -0
- package/dist/src/tenant-state/tenant-router.js +650 -0
- package/extension/manifest.json +2 -2
- package/package.json +4 -1
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/extension-bridge.ts +176 -65
- package/ts/capabilities/session/gui-flight.ts +318 -88
- package/ts/capabilities/session/gui-hotel.ts +72 -18
- package/ts/capabilities/session-search.ts +17 -5
- package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/ts/src/booking-surface/dsh-planner.ts +169 -19
- package/ts/src/booking-surface/index.ts +1 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
- package/ts/src/booking-surface/server.ts +215 -84
- package/ts/src/booking-surface/startup.ts +155 -0
- package/ts/src/booking-surface/tenancy.ts +242 -0
- package/ts/src/index.ts +28 -2
- package/ts/src/llm-price-table.ts +180 -0
- package/ts/src/tenant-seam/index.ts +9 -0
- package/ts/src/tenant-seam/llm-broker.ts +520 -0
- package/ts/src/tenant-seam/process-governor.ts +343 -0
- package/ts/src/tenant-seam/quotas.ts +433 -0
- package/ts/src/tenant-seam/seam.ts +142 -0
- package/ts/src/tenant-state/durable-store.ts +171 -0
- package/ts/src/tenant-state/epoch-copies.ts +491 -0
- package/ts/src/tenant-state/errors.ts +128 -0
- package/ts/src/tenant-state/ids.ts +108 -0
- package/ts/src/tenant-state/index.ts +12 -0
- package/ts/src/tenant-state/lease-store.ts +266 -0
- package/ts/src/tenant-state/tenant-lease.ts +175 -0
- package/ts/src/tenant-state/tenant-router.ts +743 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The LLM credential broker (docs/rfc/aaas-rfc.md §4.8, threat S-18).
|
|
3
|
+
*
|
|
4
|
+
* A planner child is the one place on a replica where model output, which is untrusted, steers a process that holds a
|
|
5
|
+
* bearer credential. Today that credential is the deployment-wide gateway key in the child's environment: valid from
|
|
6
|
+
* anywhere, for as long as nobody rotates it, and shared by every tenant. The broker moves the credential out of the
|
|
7
|
+
* child. The upstream key and base URL stay in this process; each planner process tree gets a *ticket* instead, a random
|
|
8
|
+
* bearer that is good only against this broker, on the loopback interface, for a short time, for a bounded number of
|
|
9
|
+
* requests, and that dies with the port it was issued for.
|
|
10
|
+
*
|
|
11
|
+
* What the broker is, and is not:
|
|
12
|
+
* - It is a credential-injecting forward proxy for one upstream. The child can ask it for exactly one thing, a POST to
|
|
13
|
+
* an allow-listed path; the upstream host and path are fixed here, the child's `authorization` header is dropped and
|
|
14
|
+
* the real key is attached, redirects are refused, and a response comes back with a closed set of headers.
|
|
15
|
+
* - It is not isolation from this process. Whoever can run code here can read the key. It narrows what a *compromised
|
|
16
|
+
* child* gets: a ticket that expires, works from nowhere else, and names no key.
|
|
17
|
+
* - It meters. Every completed response is parsed for its `usage`, so a deployment can charge measured spend to the
|
|
18
|
+
* tenant whose ticket asked, instead of the configured worst case (`TenantPlannerContext.reportUsage`).
|
|
19
|
+
*
|
|
20
|
+
* The broker carries the chat-completions route (`DEEPSEEK_PROVIDER=gateway-chat`, `dsh-llm-gateway-chat.js`). Another
|
|
21
|
+
* wire dialect needs its own path in `allowedPaths` and a live check before it is added.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
24
|
+
import { createServer } from 'node:http';
|
|
25
|
+
import { once } from 'node:events';
|
|
26
|
+
const DEFAULT_TTL_MS = 15 * 60_000;
|
|
27
|
+
const DEFAULT_MAX_REQUESTS = 64;
|
|
28
|
+
const DEFAULT_MAX_CONCURRENT = 4;
|
|
29
|
+
const DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024;
|
|
30
|
+
const DEFAULT_UPSTREAM_TIMEOUT_MS = 60_000;
|
|
31
|
+
const DEFAULT_MAX_CONNECTIONS = 256;
|
|
32
|
+
const DEFAULT_HEADERS_TIMEOUT_MS = 10_000;
|
|
33
|
+
const DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS = 5_000;
|
|
34
|
+
const MAX_USAGE_LINE_BYTES = 1024 * 1024;
|
|
35
|
+
const TOKEN_PREFIX = 'gtk-';
|
|
36
|
+
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
37
|
+
function digest(token) {
|
|
38
|
+
return createHash('sha256').update(token).digest('hex');
|
|
39
|
+
}
|
|
40
|
+
function positive(name, value) {
|
|
41
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
42
|
+
throw new RangeError(`${name} must be a positive number`);
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function jsonError(res, status, code, headers = {}) {
|
|
46
|
+
if (res.headersSent) {
|
|
47
|
+
res.destroy();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const body = JSON.stringify({ error: { code } });
|
|
51
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'cache-control': 'no-store', ...headers });
|
|
52
|
+
res.end(body);
|
|
53
|
+
}
|
|
54
|
+
/** The upstream URL for one allowed path, built the way the chat-completions adapter builds its own endpoint. */
|
|
55
|
+
function upstreamUrlFor(base, path) {
|
|
56
|
+
const root = base.href.replace(/\/+$/u, '');
|
|
57
|
+
return base.pathname.replace(/\/+$/u, '').endsWith('/v1') ? `${root}${path}` : `${root}/v1${path}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* What a child is told when the upstream answers with an error. The upstream's own body is never relayed: an upstream
|
|
61
|
+
* that echoes the credential it was sent (a misconfigured gateway does) would hand it to the very process the broker
|
|
62
|
+
* keeps it from. The status and a closed classification are all the adapter needs.
|
|
63
|
+
*/
|
|
64
|
+
function upstreamErrorBody(status) {
|
|
65
|
+
const type = status === 401 || status === 403 ? 'auth'
|
|
66
|
+
: status === 429 ? 'rate_limit'
|
|
67
|
+
: status === 408 || status >= 500 ? 'server'
|
|
68
|
+
: 'invalid_request';
|
|
69
|
+
return JSON.stringify({ error: { code: `upstream_${status}`, type } });
|
|
70
|
+
}
|
|
71
|
+
/** Resolves when the response drains or its connection closes, whichever is first, and leaves no listener behind. */
|
|
72
|
+
function drainOrClose(res) {
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const done = () => {
|
|
75
|
+
res.off('drain', done);
|
|
76
|
+
res.off('close', done);
|
|
77
|
+
resolve();
|
|
78
|
+
};
|
|
79
|
+
res.once('drain', done);
|
|
80
|
+
res.once('close', done);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** Reads `usage` out of a response as it streams past, without holding on to the response. Never throws. */
|
|
84
|
+
class UsageTap {
|
|
85
|
+
sse;
|
|
86
|
+
carry = '';
|
|
87
|
+
carryOverflow = false;
|
|
88
|
+
found;
|
|
89
|
+
done = false;
|
|
90
|
+
decoder = new TextDecoder();
|
|
91
|
+
constructor(contentType) {
|
|
92
|
+
this.sse = contentType.includes('text/event-stream');
|
|
93
|
+
}
|
|
94
|
+
push(chunk) {
|
|
95
|
+
try {
|
|
96
|
+
this.carry += this.decoder.decode(chunk, { stream: true });
|
|
97
|
+
if (this.sse) {
|
|
98
|
+
for (let newline = this.carry.indexOf('\n'); newline >= 0; newline = this.carry.indexOf('\n')) {
|
|
99
|
+
this.line(this.carry.slice(0, newline));
|
|
100
|
+
this.carry = this.carry.slice(newline + 1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (this.carry.length > MAX_USAGE_LINE_BYTES) {
|
|
104
|
+
this.carry = '';
|
|
105
|
+
this.carryOverflow = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch { /* metering must never touch the response */ }
|
|
109
|
+
}
|
|
110
|
+
/** True once the stream's `[DONE]` sentinel has gone past: nothing after it matters to a reader that stops there. */
|
|
111
|
+
get sawDone() {
|
|
112
|
+
return this.done;
|
|
113
|
+
}
|
|
114
|
+
finish() {
|
|
115
|
+
try {
|
|
116
|
+
this.carry += this.decoder.decode();
|
|
117
|
+
if (this.sse)
|
|
118
|
+
this.line(this.carry);
|
|
119
|
+
else if (!this.carryOverflow)
|
|
120
|
+
this.consider(JSON.parse(this.carry));
|
|
121
|
+
}
|
|
122
|
+
catch { /* an unparseable body is simply unmetered */ }
|
|
123
|
+
return this.found;
|
|
124
|
+
}
|
|
125
|
+
line(raw) {
|
|
126
|
+
const line = raw.trim();
|
|
127
|
+
if (!line.startsWith('data:'))
|
|
128
|
+
return;
|
|
129
|
+
const data = line.slice('data:'.length).trim();
|
|
130
|
+
if (data === '[DONE]') {
|
|
131
|
+
this.done = true;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (data === '')
|
|
135
|
+
return;
|
|
136
|
+
try {
|
|
137
|
+
this.consider(JSON.parse(data));
|
|
138
|
+
}
|
|
139
|
+
catch { /* a frame that is not JSON carries no usage */ }
|
|
140
|
+
}
|
|
141
|
+
consider(value) {
|
|
142
|
+
if (typeof value !== 'object' || value === null)
|
|
143
|
+
return;
|
|
144
|
+
const usage = value.usage;
|
|
145
|
+
if (typeof usage === 'object' && usage !== null)
|
|
146
|
+
this.found = usage;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function count(value) {
|
|
150
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
151
|
+
}
|
|
152
|
+
/** The same mapping the chat-completions adapter applies: cache reads are subtracted from the prompt count. */
|
|
153
|
+
function measuredUsage(raw) {
|
|
154
|
+
const prompt = count(raw.prompt_tokens);
|
|
155
|
+
const completion = count(raw.completion_tokens);
|
|
156
|
+
if (prompt === undefined || completion === undefined)
|
|
157
|
+
return undefined;
|
|
158
|
+
const details = typeof raw.prompt_tokens_details === 'object' && raw.prompt_tokens_details !== null ? raw.prompt_tokens_details : {};
|
|
159
|
+
const cacheRead = count(details.cached_tokens) ?? count(raw.prompt_cache_hit_tokens);
|
|
160
|
+
if (cacheRead !== undefined && cacheRead > prompt)
|
|
161
|
+
return undefined;
|
|
162
|
+
return { inputTokens: prompt - (cacheRead ?? 0), outputTokens: completion, ...(cacheRead === undefined ? {} : { cacheReadTokens: cacheRead }) };
|
|
163
|
+
}
|
|
164
|
+
export class LlmCredentialBroker {
|
|
165
|
+
upstream;
|
|
166
|
+
upstreamKey;
|
|
167
|
+
ttlMs;
|
|
168
|
+
maxRequests;
|
|
169
|
+
maxConcurrent;
|
|
170
|
+
maxRequestBytes;
|
|
171
|
+
upstreamTimeoutMs;
|
|
172
|
+
allowedPaths;
|
|
173
|
+
maxConnections;
|
|
174
|
+
headersTimeoutMs;
|
|
175
|
+
connectionsCheckingIntervalMs;
|
|
176
|
+
now;
|
|
177
|
+
transport;
|
|
178
|
+
tickets = new Map();
|
|
179
|
+
refused = new Map();
|
|
180
|
+
server;
|
|
181
|
+
baseUrl = '';
|
|
182
|
+
issued = 0;
|
|
183
|
+
forwarded = 0;
|
|
184
|
+
upstreamFailures = 0;
|
|
185
|
+
closed = false;
|
|
186
|
+
constructor(options) {
|
|
187
|
+
let upstream;
|
|
188
|
+
try {
|
|
189
|
+
upstream = new URL(options.upstreamBaseUrl);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
throw new RangeError('upstreamBaseUrl must be an absolute URL');
|
|
193
|
+
}
|
|
194
|
+
if (upstream.protocol !== 'https:' && upstream.protocol !== 'http:')
|
|
195
|
+
throw new RangeError('upstreamBaseUrl must be http or https');
|
|
196
|
+
if (upstream.username !== '' || upstream.password !== '')
|
|
197
|
+
throw new RangeError('upstreamBaseUrl must not carry credentials');
|
|
198
|
+
if (typeof options.upstreamApiKey !== 'string' || options.upstreamApiKey.length === 0)
|
|
199
|
+
throw new RangeError('upstreamApiKey must be a non-empty string');
|
|
200
|
+
this.upstream = upstream;
|
|
201
|
+
this.upstreamKey = options.upstreamApiKey;
|
|
202
|
+
this.ttlMs = positive('ticketTtlMs', options.ticketTtlMs ?? DEFAULT_TTL_MS);
|
|
203
|
+
this.maxRequests = positive('maxRequestsPerTicket', options.maxRequestsPerTicket ?? DEFAULT_MAX_REQUESTS);
|
|
204
|
+
this.maxConcurrent = positive('maxConcurrentPerTicket', options.maxConcurrentPerTicket ?? DEFAULT_MAX_CONCURRENT);
|
|
205
|
+
this.maxRequestBytes = positive('maxRequestBytes', options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES);
|
|
206
|
+
this.upstreamTimeoutMs = positive('upstreamTimeoutMs', options.upstreamTimeoutMs ?? DEFAULT_UPSTREAM_TIMEOUT_MS);
|
|
207
|
+
const paths = options.allowedPaths ?? ['/chat/completions'];
|
|
208
|
+
for (const path of paths) {
|
|
209
|
+
if (!/^\/[A-Za-z0-9._~/-]*$/u.test(path) || path.includes('..') || path.includes('//'))
|
|
210
|
+
throw new RangeError(`allowed path ${JSON.stringify(path)} is not a plain path`);
|
|
211
|
+
}
|
|
212
|
+
this.allowedPaths = new Set(paths);
|
|
213
|
+
this.maxConnections = positive('maxConnections', options.maxConnections ?? DEFAULT_MAX_CONNECTIONS);
|
|
214
|
+
this.headersTimeoutMs = positive('headersTimeoutMs', options.headersTimeoutMs ?? DEFAULT_HEADERS_TIMEOUT_MS);
|
|
215
|
+
this.connectionsCheckingIntervalMs = positive('connectionsCheckingIntervalMs', options.connectionsCheckingIntervalMs ?? DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS);
|
|
216
|
+
this.now = options.now ?? Date.now;
|
|
217
|
+
this.transport = options.fetch ?? fetch;
|
|
218
|
+
}
|
|
219
|
+
/** Starts listening on a loopback ephemeral port. Tickets can be issued once this resolves. */
|
|
220
|
+
async start() {
|
|
221
|
+
if (this.server || this.closed)
|
|
222
|
+
throw new Error('llm_broker_already_started');
|
|
223
|
+
// This port is handed to a process that model output steers, in the same process as everything else on the replica:
|
|
224
|
+
// it must not be a way to use up its descriptors. Connections are capped, and one that does not send a request is dropped
|
|
225
|
+
// (Node starts the headers clock when a connection is accepted, not at its first byte; the suite checks it on whatever Node runs it).
|
|
226
|
+
const server = createServer({ headersTimeout: this.headersTimeoutMs, requestTimeout: Math.max(60_000, this.headersTimeoutMs), connectionsCheckingInterval: this.connectionsCheckingIntervalMs }, (req, res) => { void this.handle(req, res); });
|
|
227
|
+
server.maxConnections = this.maxConnections;
|
|
228
|
+
// A planner run is long-lived, but nothing here should linger on an idle connection.
|
|
229
|
+
server.keepAliveTimeout = 5_000;
|
|
230
|
+
server.listen(0, '127.0.0.1');
|
|
231
|
+
await once(server, 'listening');
|
|
232
|
+
this.server = server;
|
|
233
|
+
this.baseUrl = `http://127.0.0.1:${server.address().port}/v1`;
|
|
234
|
+
}
|
|
235
|
+
issue(options = {}) {
|
|
236
|
+
if (!this.server || this.closed)
|
|
237
|
+
throw new Error('llm_broker_not_running');
|
|
238
|
+
// Expired tickets leave with the next issue, so a ticket nobody revoked cannot sit in the map for long.
|
|
239
|
+
this.sweep();
|
|
240
|
+
const ttlMs = positive('ttlMs', options.ttlMs ?? this.ttlMs);
|
|
241
|
+
const token = `${TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`;
|
|
242
|
+
const key = digest(token);
|
|
243
|
+
const record = { onUsage: options.onUsage, inflight: new Set(), expiresAtMs: this.now() + ttlMs, requests: 0, inFlight: 0, revoked: false };
|
|
244
|
+
this.tickets.set(key, record);
|
|
245
|
+
this.issued += 1;
|
|
246
|
+
return {
|
|
247
|
+
baseUrl: this.baseUrl,
|
|
248
|
+
token,
|
|
249
|
+
expiresAtMs: record.expiresAtMs,
|
|
250
|
+
revoke: () => {
|
|
251
|
+
record.revoked = true;
|
|
252
|
+
this.tickets.delete(key);
|
|
253
|
+
// A revoked ticket also stops what it has under way: a stream already open would otherwise outlive it.
|
|
254
|
+
for (const controller of [...record.inflight])
|
|
255
|
+
controller.abort('revoked');
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
stats() {
|
|
260
|
+
const now = this.now();
|
|
261
|
+
let active = 0;
|
|
262
|
+
for (const record of this.tickets.values())
|
|
263
|
+
if (!record.revoked && record.expiresAtMs > now)
|
|
264
|
+
active += 1;
|
|
265
|
+
return { issued: this.issued, active, tracked: this.tickets.size, forwarded: this.forwarded, refused: Object.fromEntries(this.refused), upstreamFailures: this.upstreamFailures };
|
|
266
|
+
}
|
|
267
|
+
async close() {
|
|
268
|
+
if (this.closed)
|
|
269
|
+
return;
|
|
270
|
+
this.closed = true;
|
|
271
|
+
this.tickets.clear();
|
|
272
|
+
const server = this.server;
|
|
273
|
+
if (!server)
|
|
274
|
+
return;
|
|
275
|
+
server.closeAllConnections();
|
|
276
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
277
|
+
}
|
|
278
|
+
sweep() {
|
|
279
|
+
const now = this.now();
|
|
280
|
+
for (const [key, record] of this.tickets)
|
|
281
|
+
if (record.expiresAtMs <= now)
|
|
282
|
+
this.tickets.delete(key);
|
|
283
|
+
}
|
|
284
|
+
refuse(res, status, code, headers = {}) {
|
|
285
|
+
this.refused.set(code, (this.refused.get(code) ?? 0) + 1);
|
|
286
|
+
jsonError(res, status, code, headers);
|
|
287
|
+
}
|
|
288
|
+
/** A refusal whose request body is not going to be read: the answer goes out, and only then is the connection cut. */
|
|
289
|
+
refuseAndClose(req, res, status, code) {
|
|
290
|
+
res.once('finish', () => req.destroy());
|
|
291
|
+
this.refuse(res, status, code, { connection: 'close' });
|
|
292
|
+
}
|
|
293
|
+
ticketOf(req) {
|
|
294
|
+
const header = req.headers.authorization;
|
|
295
|
+
if (typeof header !== 'string' || !header.startsWith('Bearer '))
|
|
296
|
+
return undefined;
|
|
297
|
+
const token = header.slice('Bearer '.length).trim();
|
|
298
|
+
if (!token.startsWith(TOKEN_PREFIX) || token.length > 128)
|
|
299
|
+
return undefined;
|
|
300
|
+
const record = this.tickets.get(digest(token));
|
|
301
|
+
if (!record || record.revoked || record.expiresAtMs <= this.now())
|
|
302
|
+
return undefined;
|
|
303
|
+
return record;
|
|
304
|
+
}
|
|
305
|
+
async handle(req, res) {
|
|
306
|
+
const ticket = {};
|
|
307
|
+
try {
|
|
308
|
+
// Defense in depth: the socket is bound to loopback, and a remote peer is refused even so.
|
|
309
|
+
if (!LOOPBACK_ADDRESSES.has(req.socket.remoteAddress ?? '')) {
|
|
310
|
+
this.refuseAndClose(req, res, 403, 'not_loopback');
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const url = new URL(req.url ?? '/', 'http://broker.invalid');
|
|
314
|
+
const prefix = '/v1';
|
|
315
|
+
const path = url.pathname.startsWith(`${prefix}/`) ? url.pathname.slice(prefix.length) : '';
|
|
316
|
+
if (!this.allowedPaths.has(path)) {
|
|
317
|
+
this.refuse(res, 404, 'path_not_allowed');
|
|
318
|
+
req.resume();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (req.method !== 'POST') {
|
|
322
|
+
this.refuse(res, 405, 'method_not_allowed', { allow: 'POST' });
|
|
323
|
+
req.resume();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const record = this.ticketOf(req);
|
|
327
|
+
if (!record) {
|
|
328
|
+
this.refuse(res, 401, 'ticket_invalid');
|
|
329
|
+
req.resume();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (record.requests >= this.maxRequests) {
|
|
333
|
+
this.refuse(res, 429, 'ticket_exhausted');
|
|
334
|
+
req.resume();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (record.inFlight >= this.maxConcurrent) {
|
|
338
|
+
this.refuse(res, 429, 'ticket_busy', { 'retry-after': '1' });
|
|
339
|
+
req.resume();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
record.requests += 1;
|
|
343
|
+
record.inFlight += 1;
|
|
344
|
+
ticket.record = record;
|
|
345
|
+
const declared = Number(req.headers['content-length']);
|
|
346
|
+
if (Number.isFinite(declared) && declared > this.maxRequestBytes) {
|
|
347
|
+
this.refuseAndClose(req, res, 413, 'request_too_large');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const body = await this.readBody(req);
|
|
351
|
+
if (body === null) {
|
|
352
|
+
this.refuseAndClose(req, res, 413, 'request_too_large');
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
await this.forward(req, res, record, path, body);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
if (!res.writableEnded)
|
|
359
|
+
jsonError(res, 500, 'broker_error');
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
if (ticket.record)
|
|
363
|
+
ticket.record.inFlight -= 1;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async readBody(req) {
|
|
367
|
+
const chunks = [];
|
|
368
|
+
let size = 0;
|
|
369
|
+
for await (const chunk of req) {
|
|
370
|
+
size += chunk.length;
|
|
371
|
+
if (size > this.maxRequestBytes)
|
|
372
|
+
return null;
|
|
373
|
+
chunks.push(chunk);
|
|
374
|
+
}
|
|
375
|
+
return Buffer.concat(chunks);
|
|
376
|
+
}
|
|
377
|
+
async forward(req, res, record, path, body) {
|
|
378
|
+
const abort = new AbortController();
|
|
379
|
+
record.inflight.add(abort);
|
|
380
|
+
try {
|
|
381
|
+
await this.relay(req, res, record, path, body, abort);
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
record.inflight.delete(abort);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async relay(req, res, record, path, body, abort) {
|
|
388
|
+
res.on('close', () => { if (!res.writableFinished)
|
|
389
|
+
abort.abort('client'); });
|
|
390
|
+
const headers = {
|
|
391
|
+
authorization: `Bearer ${this.upstreamKey}`,
|
|
392
|
+
'content-type': typeof req.headers['content-type'] === 'string' ? req.headers['content-type'] : 'application/json',
|
|
393
|
+
};
|
|
394
|
+
if (typeof req.headers.accept === 'string')
|
|
395
|
+
headers.accept = req.headers.accept;
|
|
396
|
+
// The only requests the broker carries are JSON; the text form is what goes upstream.
|
|
397
|
+
const text = body.toString('utf8');
|
|
398
|
+
let model = '';
|
|
399
|
+
try {
|
|
400
|
+
const parsed = JSON.parse(text);
|
|
401
|
+
if (typeof parsed.model === 'string')
|
|
402
|
+
model = parsed.model;
|
|
403
|
+
}
|
|
404
|
+
catch { /* an unparseable body is the upstream's to refuse; it is simply unmetered here */ }
|
|
405
|
+
const timer = setTimeout(() => abort.abort('timeout'), this.upstreamTimeoutMs);
|
|
406
|
+
let response;
|
|
407
|
+
try {
|
|
408
|
+
response = await this.transport(upstreamUrlFor(this.upstream, path), { method: 'POST', headers, body: text, redirect: 'manual', signal: abort.signal });
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
this.upstreamFailures += 1;
|
|
412
|
+
if (res.writableEnded || res.destroyed)
|
|
413
|
+
return;
|
|
414
|
+
const reason = abort.signal.reason;
|
|
415
|
+
if (reason === 'revoked')
|
|
416
|
+
jsonError(res, 401, 'ticket_invalid');
|
|
417
|
+
else
|
|
418
|
+
jsonError(res, abort.signal.aborted ? 504 : 502, abort.signal.aborted ? 'upstream_timeout' : 'upstream_unreachable');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
}
|
|
424
|
+
// A redirect would send the injected key wherever the upstream points; it is a failure, not something to follow.
|
|
425
|
+
if (response.status >= 300 && response.status < 400) {
|
|
426
|
+
this.upstreamFailures += 1;
|
|
427
|
+
await response.body?.cancel().catch(() => undefined);
|
|
428
|
+
jsonError(res, 502, 'upstream_redirect_refused');
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const contentType = response.headers.get('content-type') ?? 'application/json';
|
|
432
|
+
const outHeaders = { 'content-type': contentType, 'cache-control': 'no-store' };
|
|
433
|
+
const retryAfter = response.headers.get('retry-after');
|
|
434
|
+
if (retryAfter !== null && /^\d{1,6}$/u.test(retryAfter))
|
|
435
|
+
outHeaders['retry-after'] = retryAfter;
|
|
436
|
+
if (response.status < 200 || response.status >= 300) {
|
|
437
|
+
// Never the upstream's own words (see upstreamErrorBody); its body is not read.
|
|
438
|
+
await response.body?.cancel().catch(() => undefined);
|
|
439
|
+
const mapped = upstreamErrorBody(response.status);
|
|
440
|
+
const errorHeaders = { 'content-type': 'application/json', 'content-length': String(Buffer.byteLength(mapped)), 'cache-control': 'no-store' };
|
|
441
|
+
if (outHeaders['retry-after'] !== undefined)
|
|
442
|
+
errorHeaders['retry-after'] = outHeaders['retry-after'];
|
|
443
|
+
res.writeHead(response.status, errorHeaders);
|
|
444
|
+
res.end(mapped);
|
|
445
|
+
this.forwarded += 1;
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
res.writeHead(response.status, outHeaders);
|
|
449
|
+
const tap = new UsageTap(contentType);
|
|
450
|
+
// Measured spend is reported once, and before the child can see that the stream is over: at the `[DONE]` frame, which
|
|
451
|
+
// is where the chat-completions adapter stops reading (it then cancels the request, whatever the upstream still does),
|
|
452
|
+
// or, for a body with no such frame, when the body has ended.
|
|
453
|
+
let metered = false;
|
|
454
|
+
const meter = () => {
|
|
455
|
+
if (metered)
|
|
456
|
+
return;
|
|
457
|
+
metered = true;
|
|
458
|
+
const usage = tap.finish();
|
|
459
|
+
const measured = usage ? measuredUsage(usage) : undefined;
|
|
460
|
+
if (measured && model !== '' && record.onUsage) {
|
|
461
|
+
try {
|
|
462
|
+
record.onUsage({ model, usage: measured });
|
|
463
|
+
}
|
|
464
|
+
catch { /* the caller's meter never affects the response */ }
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
try {
|
|
468
|
+
if (response.body) {
|
|
469
|
+
const reader = response.body.getReader();
|
|
470
|
+
for (;;) {
|
|
471
|
+
const { done, value } = await reader.read();
|
|
472
|
+
if (done)
|
|
473
|
+
break;
|
|
474
|
+
tap.push(value);
|
|
475
|
+
if (tap.sawDone)
|
|
476
|
+
meter();
|
|
477
|
+
if (!res.write(value)) {
|
|
478
|
+
// A child that went away while the pipe was full never drains it: stop waiting when the connection closes.
|
|
479
|
+
await drainOrClose(res);
|
|
480
|
+
if (res.destroyed) {
|
|
481
|
+
await reader.cancel().catch(() => undefined);
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
meter();
|
|
488
|
+
res.end();
|
|
489
|
+
this.forwarded += 1;
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
// A stream that had already delivered `[DONE]` was complete; the child may simply have stopped reading and cancelled.
|
|
493
|
+
// Anything else is the upstream failing mid-stream, and the child must see a broken connection, never a truncated
|
|
494
|
+
// stream that looks whole.
|
|
495
|
+
if (tap.sawDone)
|
|
496
|
+
this.forwarded += 1;
|
|
497
|
+
else
|
|
498
|
+
this.upstreamFailures += 1;
|
|
499
|
+
res.destroy();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** A broker that is listening. */
|
|
504
|
+
export async function startLlmCredentialBroker(options) {
|
|
505
|
+
const broker = new LlmCredentialBroker(options);
|
|
506
|
+
await broker.start();
|
|
507
|
+
return broker;
|
|
508
|
+
}
|