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