dsh-tap 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/AGENTS.md +99 -0
  2. package/CHANGELOG.md +751 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +59 -0
  5. package/README.md +256 -0
  6. package/cordis.patch.yml +385 -0
  7. package/core/bridge.js +698 -0
  8. package/core/json-store.js +91 -0
  9. package/core/rotation.js +108 -0
  10. package/core/usage-meter.js +176 -0
  11. package/docs/diagnosis-cache-quota.md +181 -0
  12. package/docs/diagnosis-qoder-flash.md +67 -0
  13. package/docs/diagnosis-trae-3003.md +302 -0
  14. package/docs/goals/bridge-port-host-split.md +91 -0
  15. package/docs/goals/desktop-adaptation.md +106 -0
  16. package/docs/goals/qoder-cn-provider-design.md +308 -0
  17. package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
  18. package/docs/goals/settings-card-ux-redesign.md +162 -0
  19. package/docs/goals/trae-agent-v3.md +41 -0
  20. package/docs/goals/trae-work-cn-repair.md +44 -0
  21. package/docs/goals/v0.8-/351/242/235/345/272/246/345/217/257/350/247/201-/346/250/241/345/236/213/345/212/250/346/200/201/345/214/226-/345/244/232/346/234/215/345/212/241/345/225/206.md +85 -0
  22. package/docs/pitfalls.md +105 -0
  23. package/docs/reverse/trae-cloud-api.md +218 -0
  24. package/docs/reverse/trae-model-catalog.md +129 -0
  25. package/docs/reverse/traework-cn.md +520 -0
  26. package/docs/rules/STATE.md +198 -0
  27. package/docs/rules/content-moderation.md +54 -0
  28. package/docs/rules/dev-role-boundary.md +94 -0
  29. package/docs/rules/extra-providers.md +42 -0
  30. package/docs/rules/gateway-facts.md +91 -0
  31. package/docs/rules/oauth-handshake.md +76 -0
  32. package/docs/rules/prompt-cache.md +93 -0
  33. package/docs/rules/quota-signals.md +125 -0
  34. package/docs/rules/routing.md +84 -0
  35. package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
  36. package/docs/rules/trae-surface.md +242 -0
  37. package/docs/rules/ua-validation.md +81 -0
  38. package/host-config.js +282 -0
  39. package/index.js +2306 -0
  40. package/lib/client.js +2893 -0
  41. package/local-scan.js +104 -0
  42. package/package.json +82 -0
  43. package/providers/ark/index.js +11 -0
  44. package/providers/bailian/index.js +10 -0
  45. package/providers/bigmodel/index.js +11 -0
  46. package/providers/codebuddy/agenttool.js +122 -0
  47. package/providers/codebuddy/catalog.js +227 -0
  48. package/providers/codebuddy/errors.js +44 -0
  49. package/providers/codebuddy/headers.js +36 -0
  50. package/providers/codebuddy/images.js +125 -0
  51. package/providers/codebuddy/index.js +123 -0
  52. package/providers/codebuddy/oauth.js +279 -0
  53. package/providers/deepseek/index.js +11 -0
  54. package/providers/moonshot/index.js +11 -0
  55. package/providers/openai-compat.js +177 -0
  56. package/providers/openrouter/index.js +27 -0
  57. package/providers/qoder/catalog.js +145 -0
  58. package/providers/qoder/cosy.js +419 -0
  59. package/providers/qoder/gateway.js +563 -0
  60. package/providers/qoder/index.js +116 -0
  61. package/providers/qoder/oauth.js +364 -0
  62. package/providers/qoder/qoder_auth.wasm +0 -0
  63. package/providers/qoder/quota.js +56 -0
  64. package/providers/qwen/index.js +15 -0
  65. package/providers/tool-pairing.js +129 -0
  66. package/providers/trae/catalog.js +103 -0
  67. package/providers/trae/errors.js +85 -0
  68. package/providers/trae/gateway.js +853 -0
  69. package/providers/trae/index.js +126 -0
  70. package/providers/trae/oauth.js +443 -0
  71. package/providers/trae/quota.js +75 -0
  72. package/providers/trae/remote.js +365 -0
  73. package/scripts/capture-cache.mjs +65 -0
  74. package/scripts/capture-traffic.mjs +83 -0
  75. package/scripts/hermes-probe-dev-role.mjs +263 -0
  76. package/scripts/measure-latency.mjs +253 -0
  77. package/scripts/probe-ark-thinking.mjs +298 -0
  78. package/scripts/probe-cache-decline.mjs +292 -0
  79. package/scripts/probe-cache-ttl.mjs +221 -0
  80. package/scripts/probe-cache.mjs +156 -0
  81. package/scripts/probe-codebuddy-efforts.mjs +355 -0
  82. package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
  83. package/scripts/probe-effort-gaps.mjs +133 -0
  84. package/scripts/probe-media.mjs +115 -0
  85. package/scripts/probe-moderation.mjs +159 -0
  86. package/scripts/probe-oauth.mjs +617 -0
  87. package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
  88. package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
  89. package/scripts/probe-qoder-attribution.mjs +266 -0
  90. package/scripts/probe-qoder-flash-confirm.mjs +94 -0
  91. package/scripts/probe-qoder-live.mjs +344 -0
  92. package/scripts/probe-qoder-matrix.mjs +274 -0
  93. package/scripts/probe-qoder-null-content.mjs +153 -0
  94. package/scripts/probe-qoder-pairing.mjs +308 -0
  95. package/scripts/probe-qoder-quota.mjs +162 -0
  96. package/scripts/probe-qoder-thinking-config.mjs +52 -0
  97. package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
  98. package/scripts/probe-quota.mjs +136 -0
  99. package/scripts/probe-quota2.mjs +144 -0
  100. package/scripts/probe-routing.mjs +226 -0
  101. package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
  102. package/scripts/probe-trae-agent-v3.mjs +399 -0
  103. package/scripts/probe-trae-efforts.mjs +149 -0
  104. package/scripts/probe-trae-live.mjs +147 -0
  105. package/scripts/probe-trae-max-effort.mjs +179 -0
  106. package/scripts/probe-trae-model-routing.mjs +381 -0
  107. package/scripts/probe-trae-thinking-scene.mjs +238 -0
  108. package/scripts/probe-trae-transport-outage.mjs +176 -0
  109. package/scripts/probe-ua.mjs +508 -0
  110. package/scripts/trae-model-catalog.mjs +632 -0
  111. package/scripts/verify-agents-md.mjs +45 -0
  112. package/scripts/verify-bridge.mjs +1041 -0
  113. package/scripts/verify-core-generic.mjs +302 -0
  114. package/scripts/verify-desktop-acceptance.mjs +184 -0
  115. package/scripts/verify-host-config.mjs +265 -0
  116. package/scripts/verify-models.mjs +390 -0
  117. package/scripts/verify-providers.mjs +255 -0
  118. package/scripts/verify-qoder-provider.mjs +1020 -0
  119. package/scripts/verify-rotation.mjs +308 -0
  120. package/scripts/verify-trae-model-catalog.mjs +284 -0
  121. package/scripts/verify-trae-provider.mjs +1451 -0
package/core/bridge.js ADDED
@@ -0,0 +1,698 @@
1
+ /**
2
+ * core/bridge.js — provider-agnostic stream bridge: a smart loopback proxy
3
+ * and the single credential owner.
4
+ *
5
+ * The host's LLM route points at this bridge, so the main chat path crosses
6
+ * it too. It passes any request path through to the upstream, and on POST
7
+ * /chat/completions it can (a) inject session-attribution headers from the
8
+ * incoming session id, (b) cap concurrent in-flight requests per session id
9
+ * (excess queue, FIFO), and (c) aggregate the upstream SSE stream into
10
+ * classic non-streaming OpenAI JSON for callers that need it.
11
+ *
12
+ * Generic transport guardrails (no provider specifics involved):
13
+ * - client hangup (res 'close' before completion) aborts the queued wait,
14
+ * the upstream fetch and the SSE relay, and frees the limiter slot;
15
+ * - every upstream attempt carries a first-byte timer
16
+ * (`settings.upstreamFirstByteTimeoutMs`, default 45s) that fails fast
17
+ * when the peer connects but never sends response headers — the timer
18
+ * clears on headers, so long SSE streams are unaffected;
19
+ * - an inbound body past 32MB is answered 413, not silently destroyed;
20
+ * - aggregated chat.completion JSON carries the captured usage object.
21
+ *
22
+ * Everything upstream-specific is injected through the `provider` adapter:
23
+ * bridgeHeaders() static outbound headers for proxied calls
24
+ * transformChatPayload(p) in-place rewrite of a parsed chat payload
25
+ * extractUsage(chunk) usage object from a parsed SSE chunk, or null
26
+ * extractStreamError(chunk) truthy when a parsed SSE chunk is an error
27
+ * bridgeResponseId `id` for aggregated chat.completion JSON
28
+ * logHeaderNames header names the forensic log may record
29
+ * sentinelAuth the static sentinel Authorization value callers
30
+ * use (classified in logs, never logged verbatim)
31
+ * texts.credentialUnavailable error text for the no-credential response
32
+ * logPrefix stderr prefix for bridge lifecycle warnings
33
+ *
34
+ * Credentials are always resolved host-side through the injected
35
+ * `withCredentials(attempt)` runner (the composition root's rotation);
36
+ * the caller's Authorization is never forwarded.
37
+ *
38
+ * Extracted from index.js during the core/providers split. This file must
39
+ * not mention any concrete upstream by name — that is the falsifiable
40
+ * generality contract (scripts/verify-core-generic.mjs enforces it).
41
+ */
42
+
43
+ import { createServer } from 'node:http'
44
+ import { createHash } from 'node:crypto'
45
+ import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
46
+ import { join } from 'node:path'
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Loopback Host gate ([8]+[26] hardening): the bridge carries the user's live
50
+ // credential, so it must only answer callers that reached it the intended way
51
+ // — a same-machine client addressing the loopback bind directly. A non-loopback
52
+ // Host header (DNS-rebinding name, LAN-spoofed Host) is refused before any
53
+ // body byte is read or proxied.
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /** Hostnames a same-machine caller may present in Host (::1 bare for raw
57
+ * Host values; URL-parsed IPv6 keeps its brackets). */
58
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '[::1]'])
59
+
60
+ /** Inbound request-body ceiling; past it the caller gets a real 413. */
61
+ const MAX_BODY_BYTES = 32 * 1024 * 1024
62
+
63
+ /** True when the Host header (may carry a port) names the loopback. */
64
+ export function hostIsLoopback(host) {
65
+ if (typeof host !== 'string' || !host) return false
66
+ try {
67
+ return LOOPBACK_HOSTNAMES.has(new URL(`http://${host}`).hostname)
68
+ } catch {
69
+ return false
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Origin gate: a browser cross-site request (including a no-preflight
75
+ * `navigator.sendBeacon` with a text/plain body) always carries Origin — its
76
+ * host:port must equal the Host header or the request is a drive-by CSRF that
77
+ * would burn the user's upstream quota. A missing Origin passes: same-machine
78
+ * fetch/curl and shell-forwarded in-app requests carry no Origin; the Host
79
+ * gate above still confines those to loopback. Non-browser clients can forge
80
+ * any Origin anyway, so this gate adds no constraint for them.
81
+ */
82
+ export function originMatchesHost(req) {
83
+ const origin = req.headers.origin
84
+ if (origin === undefined) return true
85
+ try {
86
+ return new URL(origin).host === req.headers.host
87
+ } catch {
88
+ return false
89
+ }
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Session attribution
94
+ // ---------------------------------------------------------------------------
95
+
96
+ const SESSION_HEADER_SETS = {
97
+ openai: ['session_id', 'x-client-request-id', 'x-session-affinity'],
98
+ openrouter: ['x-session-id'],
99
+ }
100
+
101
+ /** Extract the session id from headers or a body hint, in precedence order. */
102
+ export function extractSessionId(headers, payload) {
103
+ const candidates = [
104
+ headers['x-conversation-id'],
105
+ headers['x-session-id'],
106
+ headers['session_id'],
107
+ headers['x-client-request-id'],
108
+ headers['x-session-affinity'],
109
+ payload?.conversation_id,
110
+ payload?.session_id,
111
+ ]
112
+ for (const c of candidates) {
113
+ if (typeof c === 'string' && c.trim().length > 0) return c.trim()
114
+ }
115
+ return null
116
+ }
117
+
118
+ /**
119
+ * Per-session concurrency governor: per-id in-flight counters with FIFO
120
+ * waiting queues. acquire() resolves once this call may proceed.
121
+ *
122
+ * An optional AbortSignal covers the queued wait: when it fires while the
123
+ * call is still waiting, the waiter is removed from the queue and the
124
+ * promise rejects with signal.reason — a caller that went away must never
125
+ * be woken to fire upstream. A call that already holds its slot is
126
+ * unaffected (its release still comes from the caller's finally).
127
+ */
128
+ export class SessionLimiter {
129
+ constructor() {
130
+ this.inflight = new Map()
131
+ this.queues = new Map()
132
+ }
133
+ acquire(id, limit, signal) {
134
+ if (id === null) return Promise.resolve(() => {})
135
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error('aborted'))
136
+ const running = this.inflight.get(id) ?? 0
137
+ if (running < limit) {
138
+ this.inflight.set(id, running + 1)
139
+ return Promise.resolve(() => this.release(id))
140
+ }
141
+ return new Promise((resolve, reject) => {
142
+ const q = this.queues.get(id) ?? []
143
+ const waiter = () => {
144
+ signal?.removeEventListener('abort', onAbort)
145
+ resolve(() => this.release(id))
146
+ }
147
+ const onAbort = () => {
148
+ const idx = q.indexOf(waiter)
149
+ if (idx >= 0) q.splice(idx, 1)
150
+ if (q.length === 0) this.queues.delete(id)
151
+ reject(signal.reason ?? new Error('aborted'))
152
+ }
153
+ q.push(waiter)
154
+ this.queues.set(id, q)
155
+ signal?.addEventListener('abort', onAbort, { once: true })
156
+ })
157
+ }
158
+ release(id) {
159
+ const running = (this.inflight.get(id) ?? 0) - 1
160
+ const q = this.queues.get(id) ?? []
161
+ // One release frees exactly one slot; hand it to the oldest waiter.
162
+ // The woken call inherits the slot, so the in-flight count is restored
163
+ // BEFORE its callback runs (it will release again when done).
164
+ const next = q.shift()
165
+ if (next !== undefined) {
166
+ this.inflight.set(id, running + 1)
167
+ next()
168
+ } else if (running <= 0) {
169
+ this.inflight.delete(id)
170
+ } else {
171
+ this.inflight.set(id, running)
172
+ }
173
+ if (q.length === 0) this.queues.delete(id)
174
+ }
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Request forensics (opt-in) + payload classification helpers
179
+ // ---------------------------------------------------------------------------
180
+
181
+ const sha16 = (text) => createHash('sha256').update(text).digest('hex').slice(0, 16)
182
+
183
+ /** Pick the whitelisted headers worth recording; authorization is classified. */
184
+ function pickLogHeaders(headers, logHeaderNames, sentinelAuth) {
185
+ const out = {}
186
+ for (const name of logHeaderNames) {
187
+ const value = headers[name]
188
+ if (typeof value === 'string' && value.length > 0) out[name] = value
189
+ }
190
+ const auth = headers.authorization
191
+ if (typeof auth === 'string' && auth.length > 0) {
192
+ out.authorization = auth === sentinelAuth ? 'sentinel' : 'caller-set'
193
+ }
194
+ return out
195
+ }
196
+
197
+ /** Text of one chat message, whether content is a string or typed parts. */
198
+ function messageText(message) {
199
+ if (!message || typeof message !== 'object') return ''
200
+ const content = message.content
201
+ if (typeof content === 'string') return content
202
+ if (!Array.isArray(content)) return ''
203
+ return content
204
+ .filter((p) => p?.type === 'text' && typeof p.text === 'string')
205
+ .map((p) => p.text)
206
+ .join('\n')
207
+ }
208
+
209
+ /**
210
+ * Classify a chat payload by its prompt shape — the host's auxiliary LLM
211
+ * calls (session title, compaction) reuse the main route and are
212
+ * recognizable only by their prompt text (verified against the host's
213
+ * session-title / compaction sources). Host-specific, upstream-agnostic.
214
+ */
215
+ function detectPayloadMarker(messages) {
216
+ const first = messages[0]
217
+ if (first?.role === 'system'
218
+ && messageText(first).startsWith('Create a concise title for an AI coding-assistant session')) {
219
+ return 'session-title'
220
+ }
221
+ for (let i = messages.length - 1; i >= 0; i--) {
222
+ if (messages[i]?.role !== 'user') continue
223
+ if (messageText(messages[i]).startsWith('You are now acting as a compaction engine')) {
224
+ return 'compaction'
225
+ }
226
+ break
227
+ }
228
+ return null
229
+ }
230
+
231
+ /** Metering kind for a parsed chat payload: the host's auxiliary calls
232
+ * (title, compaction) get their own kinds so the usage view can tell them
233
+ * apart. */
234
+ function chatUsageKind(payload) {
235
+ const marker = detectPayloadMarker(Array.isArray(payload?.messages) ? payload.messages : [])
236
+ return marker === 'session-title' ? 'title' : marker === 'compaction' ? 'compaction' : 'chat'
237
+ }
238
+
239
+ /** Hash-and-shape summary of a parsed chat payload (no message text logged). */
240
+ function summarizeChatPayload(rawBody, payload) {
241
+ const messages = Array.isArray(payload.messages) ? payload.messages : []
242
+ const sysText = messages[0]?.role === 'system' ? messageText(messages[0]) : ''
243
+ let lastUserText = ''
244
+ for (let i = messages.length - 1; i >= 0; i--) {
245
+ if (messages[i]?.role === 'user') {
246
+ lastUserText = messageText(messages[i])
247
+ break
248
+ }
249
+ }
250
+ return {
251
+ model: typeof payload.model === 'string' ? payload.model : null,
252
+ stream: payload.stream === true,
253
+ msgs: messages.length,
254
+ bytes: rawBody.length,
255
+ bodySha: sha16(rawBody),
256
+ msgsSha: sha16(JSON.stringify(payload.messages ?? null)),
257
+ sysSha: sysText.length > 0 ? sha16(sysText) : null,
258
+ sysBytes: sysText.length,
259
+ lastUserSha: lastUserText.length > 0 ? sha16(lastUserText) : null,
260
+ lastUserPreview: lastUserText.replace(/\s+/g, ' ').slice(0, 60),
261
+ marker: detectPayloadMarker(messages),
262
+ maxTokens: payload.max_tokens ?? payload.max_completion_tokens ?? null,
263
+ reasoningEffort: payload.reasoning_effort ?? null,
264
+ tools: Array.isArray(payload.tools) ? payload.tools.length : 0,
265
+ promptCacheKey: typeof payload.prompt_cache_key === 'string' ? payload.prompt_cache_key : null,
266
+ }
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // The bridge factory
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /**
274
+ * @param {() => object} settings live-resolved settings (needs baseURL,
275
+ * sessionHeadersEnabled, sessionHeaderFormat, maxConcurrentPerSession;
276
+ * upstreamFirstByteTimeoutMs optional, default 45000)
277
+ * @param {object} provider upstream adapter (hooks listed at the top)
278
+ * @param {(attempt: (cred: object) => Promise<Response>) => Promise<{cred: object|null, res: Response|null, err: Error|null}>} withCredentials
279
+ * credential-owning runner from the composition root
280
+ * @param {{ record: Function }} meter usage meter (core/usage-meter)
281
+ * @param {{ logPath: () => string|undefined, dumpDir: () => string|undefined }} forensics
282
+ * opt-in forensic sinks; payload text is never logged — only hashes and a
283
+ * short preview. The dump sink writes inbound chat bodies verbatim
284
+ * (local-only, opt-in). Diagnostics must never break the bridge.
285
+ * @param {{ running: boolean, port: number|null, lastError: string|null }} runtime
286
+ * shared runtime-state object mutated by listen() (owned by the
287
+ * composition root so several apply() generations report one reality).
288
+ */
289
+ export function createBridge({ settings, provider, withCredentials, meter, forensics, runtime }) {
290
+ const limiter = new SessionLimiter()
291
+ let logSeq = 0
292
+ let dumpSeq = 0
293
+
294
+ function bridgeLog(record) {
295
+ const path = forensics.logPath()
296
+ if (!path) return
297
+ try {
298
+ appendFileSync(path, JSON.stringify(record) + '\n')
299
+ } catch {
300
+ // logging is best-effort
301
+ }
302
+ }
303
+
304
+ /** Full-body dump for cache/prompt forensics: plaintext by design —
305
+ * local-only, opt-in. */
306
+ function bridgeDump(rawBody) {
307
+ const dir = forensics.dumpDir()
308
+ if (!dir) return
309
+ try {
310
+ mkdirSync(dir, { recursive: true })
311
+ writeFileSync(join(dir, `req-${String(++dumpSeq).padStart(4, '0')}-${Date.now()}.json`), rawBody)
312
+ } catch {
313
+ // dump is best-effort
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Aggregate one streamed upstream chat completion into a classic
319
+ * chat.completion JSON for non-streaming callers. The bridge always
320
+ * speaks SSE upstream and translates back here.
321
+ */
322
+ async function aggregateChatCompletion(upstream, res, model, tap = null) {
323
+ if (!upstream.ok) {
324
+ const errBody = await upstream.text().catch(() => '')
325
+ res.writeHead(upstream.status, { 'Content-Type': 'application/json' })
326
+ res.end(errBody)
327
+ return
328
+ }
329
+ let content = ''
330
+ let finishReason = null
331
+ let buf = ''
332
+ const reader = upstream.body.getReader()
333
+ const decoder = new TextDecoder()
334
+ for (;;) {
335
+ const { done, value } = await reader.read()
336
+ if (done) break
337
+ buf += decoder.decode(value, { stream: true })
338
+ let nl
339
+ while ((nl = buf.indexOf('\n')) >= 0) {
340
+ const line = buf.slice(0, nl).trim()
341
+ buf = buf.slice(nl + 1)
342
+ if (!line.startsWith('data:')) continue
343
+ const data = line.slice(5).trim()
344
+ if (data === '[DONE]') continue
345
+ try {
346
+ const chunk = JSON.parse(data)
347
+ const usage = provider.extractUsage(chunk)
348
+ if (tap && usage) tap.usage = usage
349
+ if (provider.extractStreamError(chunk)) {
350
+ res.writeHead(502, { 'Content-Type': 'application/json' })
351
+ res.end(data)
352
+ return
353
+ }
354
+ const choice = chunk.choices?.[0]
355
+ if (choice?.delta?.content) content += choice.delta.content
356
+ if (choice?.finish_reason) finishReason = choice.finish_reason
357
+ } catch {
358
+ // incomplete JSON inside a complete SSE line — skip
359
+ }
360
+ }
361
+ }
362
+ res.writeHead(200, { 'Content-Type': 'application/json' })
363
+ res.end(
364
+ JSON.stringify({
365
+ id: provider.bridgeResponseId,
366
+ object: 'chat.completion',
367
+ created: Math.floor(Date.now() / 1000),
368
+ model,
369
+ choices: [
370
+ {
371
+ index: 0,
372
+ message: { role: 'assistant', content },
373
+ finish_reason: finishReason ?? 'stop',
374
+ },
375
+ ],
376
+ // The caller asked for classic JSON — give it the usage the stream
377
+ // carried ({} only when the upstream truly sent none).
378
+ usage: tap?.usage ?? {},
379
+ }),
380
+ )
381
+ }
382
+
383
+ /**
384
+ * Proxy one request upstream. Passes through non-chat paths verbatim; on
385
+ * POST /chat/completions injects session headers (from the incoming
386
+ * session id, unless the caller already set one) and gates per-session
387
+ * concurrency. Inbound stream:true gets the SSE passed through; anything
388
+ * else (classic non-streaming callers) gets the stream aggregated into a
389
+ * chat.completion JSON.
390
+ */
391
+ async function proxyUpstream(req, res, rawBody) {
392
+ const s = settings()
393
+ const isChat = req.url?.endsWith('/chat/completions') === true
394
+ const logId = forensics.logPath() ? ++logSeq : 0
395
+ const t0 = Date.now()
396
+
397
+ // Client-disconnect propagation: when the caller goes away before the
398
+ // response completed, abort everything downstream — the queued wait, the
399
+ // upstream fetch, and the SSE relay — and free the limiter slot (finally
400
+ // below). The signal is res 'close' with writableEnded === false: since
401
+ // Node 16, req 'close' fires on EVERY fully-received request body, so it
402
+ // cannot tell a hangup from a normal request; res 'close' can.
403
+ // Abort reasons carry name 'AbortError' so the rotation engine treats
404
+ // them as caller-side (no cooldown, no failover to the next key).
405
+ const clientAbort = new AbortController()
406
+ const onClientClose = () => {
407
+ if (res.writableEnded) return
408
+ const err = new Error('client disconnected')
409
+ err.name = 'AbortError'
410
+ err.clientDisconnected = true
411
+ clientAbort.abort(err)
412
+ }
413
+ res.on('close', onClientClose)
414
+
415
+ // Parse the body only for chat (the session hint may live there); other
416
+ // paths pass the bytes through untouched.
417
+ let payload = null
418
+ if (isChat && rawBody.length > 0) {
419
+ try { payload = JSON.parse(rawBody) } catch { payload = null }
420
+ }
421
+
422
+ const sessionId = isChat ? extractSessionId(req.headers, payload) : null
423
+ if (logId) {
424
+ bridgeLog({
425
+ seq: logId,
426
+ dir: 'in',
427
+ ts: t0,
428
+ method: req.method,
429
+ path: req.url,
430
+ hdr: pickLogHeaders(req.headers, provider.logHeaderNames, provider.sentinelAuth),
431
+ chat: payload ? summarizeChatPayload(rawBody, payload) : null,
432
+ bytes: payload ? undefined : rawBody.length,
433
+ bodySha: payload ? undefined : sha16(rawBody),
434
+ sessionIn: sessionId,
435
+ })
436
+ }
437
+ if (isChat && payload !== null) bridgeDump(rawBody)
438
+ const outHeaders = {
439
+ 'Content-Type': 'application/json',
440
+ ...provider.bridgeHeaders(),
441
+ }
442
+ // Session attribution: per header, a value the caller already set wins;
443
+ // missing headers are filled with the extracted session id. (The bridge
444
+ // rebuilds the header set from scratch — an all-or-nothing "preserve"
445
+ // would silently DROP the caller's headers instead of forwarding them.)
446
+ const sessionOut = {}
447
+ if (isChat && s.sessionHeadersEnabled === true && sessionId !== null) {
448
+ const names = SESSION_HEADER_SETS[s.sessionHeaderFormat] ?? SESSION_HEADER_SETS.openai
449
+ for (const name of names) {
450
+ const existing = req.headers[name]
451
+ outHeaders[name] = typeof existing === 'string' && existing.length > 0 ? existing : sessionId
452
+ sessionOut[name] = outHeaders[name]
453
+ }
454
+ }
455
+
456
+ let body = rawBody
457
+ // Only an explicit stream:true passes SSE through; classic non-streaming
458
+ // callers (stream:false or absent — the OpenAI default) get aggregation,
459
+ // which requires speaking SSE upstream regardless of the inbound flag.
460
+ const aggregate = isChat && payload !== null && payload.stream !== true
461
+ if (isChat && payload !== null) {
462
+ payload.stream = true
463
+ // Adapter hook: upstream-specific payload normalization (e.g. role
464
+ // rewrites the upstream's entry layer demands). Runs on the way out,
465
+ // after the stream flag is forced, before re-serialization.
466
+ provider.transformChatPayload(payload)
467
+ body = JSON.stringify(payload)
468
+ }
469
+
470
+ /** Outcome record shared by every exit path below. */
471
+ const logOut = (extra) => {
472
+ if (!logId) return
473
+ bridgeLog({
474
+ seq: logId,
475
+ dir: 'out',
476
+ ts: Date.now(),
477
+ ms: Date.now() - t0,
478
+ ...(Object.keys(sessionOut).length > 0 ? { sessionOut } : {}),
479
+ ...extra,
480
+ })
481
+ }
482
+
483
+ // Concurrency gate only applies to chat (LLM calls). A caller that hangs
484
+ // up while queued rejects out of the wait and never reaches the upstream.
485
+ let release
486
+ try {
487
+ release = isChat ? await limiter.acquire(sessionId, s.maxConcurrentPerSession, clientAbort.signal) : () => {}
488
+ } catch {
489
+ logOut({ waitMs: Date.now() - t0, clientGone: true })
490
+ return
491
+ }
492
+ if (clientAbort.signal.aborted) {
493
+ // Woken in the same tick the caller disconnected: hand the slot back.
494
+ release()
495
+ logOut({ waitMs: Date.now() - t0, clientGone: true })
496
+ return
497
+ }
498
+ const waitMs = Date.now() - t0
499
+ // Header names the current credential candidate contributed; removed
500
+ // before the next attempt so a stale identity header from a prior
501
+ // candidate never leaks across failovers.
502
+ let credHeaderNames = []
503
+ try {
504
+ // First-byte guardrail: if the upstream connects but never sends
505
+ // response headers, fail fast instead of hanging the caller forever.
506
+ // The timer clears the moment headers arrive (fetch resolves), so a
507
+ // long SSE stream is unaffected. Per attempt, so a failover retry gets
508
+ // its own window.
509
+ const firstByteMs = Number(s.upstreamFirstByteTimeoutMs) > 0 ? Number(s.upstreamFirstByteTimeoutMs) : 45_000
510
+ const { cred, res: upstream0, err } = await withCredentials((c) => {
511
+ outHeaders.Authorization = c.authorization
512
+ for (const name of credHeaderNames) delete outHeaders[name]
513
+ credHeaderNames = Object.keys(c.headers ?? {})
514
+ Object.assign(outHeaders, c.headers)
515
+ const attemptAbort = new AbortController()
516
+ const firstByteTimer = setTimeout(() => {
517
+ const err = new Error(`upstream sent no response headers within ${firstByteMs}ms (first-byte timeout)`)
518
+ err.name = 'AbortError'
519
+ err.firstByteTimeout = true
520
+ attemptAbort.abort(err)
521
+ }, firstByteMs)
522
+ return fetch(`${s.baseURL}${req.url}`, {
523
+ method: req.method,
524
+ headers: outHeaders,
525
+ body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
526
+ signal: AbortSignal.any([clientAbort.signal, attemptAbort.signal]),
527
+ }).finally(() => clearTimeout(firstByteTimer))
528
+ })
529
+ if (!cred || err) {
530
+ if (err?.clientDisconnected === true) {
531
+ // The caller hung up mid-handshake — nobody left to answer.
532
+ logOut({ waitMs, clientGone: true })
533
+ return
534
+ }
535
+ // No credential at all (503) or every candidate failed at the network
536
+ // layer (502) — the caller gets a classic JSON error either way. The
537
+ // composition root flags its empty-candidate error with
538
+ // `credentialUnavailable` so the bridge needs no provider strings.
539
+ const isCred = err?.credentialUnavailable === true
540
+ const status = isCred ? 503 : 502
541
+ res.writeHead(status, { 'Content-Type': 'application/json' })
542
+ res.end(JSON.stringify({ error: { message: isCred ? provider.texts.credentialUnavailable : `upstream unreachable: ${err?.message ?? 'unknown'}` } }))
543
+ logOut({ status, waitMs, err: err?.message ?? 'credential unavailable' })
544
+ return
545
+ }
546
+ const upstream = upstream0
547
+ const ttfbMs = Date.now() - t0
548
+ if (aggregate) {
549
+ const tap = { usage: null }
550
+ await aggregateChatCompletion(upstream, res, payload.model, tap)
551
+ if (tap.usage) meter.record({ ts: t0, kind: chatUsageKind(payload), model: payload.model ?? null, usage: tap.usage })
552
+ logOut({ status: upstream.status, waitMs, ttfbMs, aggregated: true, usage: tap?.usage ?? null, keyName: cred.keyName ?? undefined })
553
+ return
554
+ }
555
+ res.writeHead(upstream.status, { 'Content-Type': upstream.headers.get('content-type') ?? 'application/json' })
556
+ if (upstream.body === null) {
557
+ res.end()
558
+ logOut({ status: upstream.status, waitMs, ttfbMs })
559
+ return
560
+ }
561
+ // Tee chat SSE bytes through a line scanner that keeps the last usage
562
+ // object (the upstream may repeat usage on every chunk). Always on for
563
+ // chat: the usage meter feeds the settings card; the forensic log
564
+ // reuses the same scan when enabled.
565
+ const reader = upstream.body.getReader()
566
+ const decoder = isChat ? new TextDecoder() : null
567
+ let scanBuf = ''
568
+ let usage = null
569
+ for (;;) {
570
+ const { done, value } = await reader.read()
571
+ if (done) break
572
+ if (decoder) {
573
+ scanBuf += decoder.decode(value, { stream: true })
574
+ let nl
575
+ while ((nl = scanBuf.indexOf('\n')) >= 0) {
576
+ const line = scanBuf.slice(0, nl).trim()
577
+ scanBuf = scanBuf.slice(nl + 1)
578
+ if (!line.startsWith('data:') || !line.includes('"usage"')) continue
579
+ try {
580
+ const chunk = JSON.parse(line.slice(5).trim())
581
+ const u = provider.extractUsage(chunk)
582
+ if (u) usage = u
583
+ } catch {
584
+ // incomplete JSON inside a complete SSE line — skip
585
+ }
586
+ }
587
+ }
588
+ if (!res.write(value)) {
589
+ // Backpressure wait — but a hangup never drains; close wins the
590
+ // race and the aborted check below unwinds the loop instead of
591
+ // hanging on a dead socket (and writing to it).
592
+ await new Promise((resolve) => {
593
+ res.once('drain', resolve)
594
+ res.once('close', resolve)
595
+ })
596
+ }
597
+ if (clientAbort.signal.aborted) throw clientAbort.signal.reason
598
+ }
599
+ res.end()
600
+ if (usage && isChat && payload) {
601
+ meter.record({ ts: t0, kind: chatUsageKind(payload), model: payload.model ?? null, usage })
602
+ }
603
+ logOut({ status: upstream.status, waitMs, ttfbMs, usage })
604
+ } catch (err) {
605
+ if (err?.clientDisconnected === true) {
606
+ // Caller hung up mid-stream: upstream is aborted, the slot frees in
607
+ // the finally, and res is already gone — nothing left to write.
608
+ logOut({ waitMs, clientGone: true })
609
+ return
610
+ }
611
+ logOut({ err: String(err?.message ?? err) })
612
+ throw err
613
+ } finally {
614
+ release()
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Listen on 127.0.0.1 only. A listen failure must NEVER crash the host:
620
+ * an unhandled 'error' event on the server used to take the whole process
621
+ * down — failures land in runtime.lastError instead (EADDRINUSE typically
622
+ * means another instance already holds the port and still serves traffic,
623
+ * since routes point at the port, not the process).
624
+ *
625
+ * @param {number} port
626
+ * @returns {() => Promise<void>} stop function
627
+ */
628
+ function listen(port) {
629
+ const server = createServer((req, res) => {
630
+ // Host gate ([8]+[26]): the bind is loopback-only, but the Host header
631
+ // is still caller-controlled — a rebinding DNS name or a spoofed Host
632
+ // must not reach the credential-bearing proxy. Refuse before reading
633
+ // the body or dispatching anywhere. The Origin gate complements it: a
634
+ // browser cross-site drive-by (sendBeacon/text-plain, no preflight)
635
+ // reaches the loopback bind with a browser-correct Host, and would
636
+ // silently burn upstream quota unless its Origin is rejected here.
637
+ if (!hostIsLoopback(req.headers.host)) {
638
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
639
+ res.end('bridge: loopback-only (Host must be 127.0.0.1/localhost/::1)')
640
+ return
641
+ }
642
+ if (!originMatchesHost(req)) {
643
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
644
+ res.end('bridge: cross-origin refused (Origin must match Host)')
645
+ return
646
+ }
647
+ // Bytes must be collected and decoded ONCE (踩坑 #28): implicit
648
+ // per-chunk utf8 decoding (`rawBody += c`) corrupts any multibyte
649
+ // character straddling a TCP chunk boundary into 3×U+FFFD at a
650
+ // per-request random position — the outbound prefix drifts and the
651
+ // gateway's content-addressed prompt cache can only hit up to the
652
+ // corruption point.
653
+ const chunks = []
654
+ let received = 0
655
+ let oversize = false
656
+ req.on('data', (c) => {
657
+ if (oversize) return // already answered 413; drain and drop the rest
658
+ chunks.push(c)
659
+ received += c.length
660
+ if (received > MAX_BODY_BYTES) {
661
+ oversize = true
662
+ chunks.length = 0
663
+ // Answer, don't destroy: a bare req.destroy() left the caller with
664
+ // a reset connection and no status. Connection: close because the
665
+ // unframed remainder of the upload must not poison keep-alive.
666
+ res.writeHead(413, { 'Content-Type': 'application/json', Connection: 'close' })
667
+ res.end(JSON.stringify({ error: { message: `request body too large (limit ${MAX_BODY_BYTES} bytes)` } }))
668
+ }
669
+ })
670
+ req.on('end', () => {
671
+ if (oversize) return
672
+ const rawBody = Buffer.concat(chunks).toString('utf8')
673
+ proxyUpstream(req, res, rawBody).catch((err) => {
674
+ if (res.destroyed || res.writableEnded) return // caller already gone
675
+ if (!res.headersSent) res.writeHead(500)
676
+ res.end(`stream bridge error: ${err.message}`)
677
+ })
678
+ })
679
+ })
680
+ server.on('error', (err) => {
681
+ runtime.running = false
682
+ runtime.lastError = err?.code ?? String(err?.message ?? err)
683
+ process.stderr.write(`${provider.logPrefix} bridge :${port} unavailable: ${runtime.lastError}(插件其余功能不受影响;若占用者是另一个 dsh 实例,其桥仍会代管本实例流量)\n`)
684
+ })
685
+ server.on('listening', () => {
686
+ runtime.running = true
687
+ runtime.lastError = null
688
+ })
689
+ server.listen(port, '127.0.0.1')
690
+ return () =>
691
+ new Promise((resolve) => {
692
+ server.close(() => resolve())
693
+ server.closeAllConnections?.()
694
+ })
695
+ }
696
+
697
+ return { listen }
698
+ }