@danceiny/gotry 0.2.0-rc.31 → 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.
Files changed (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. 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
+ }