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
@@ -0,0 +1,853 @@
1
+ /**
2
+ * providers/trae/gateway.js — OpenAI ↔ Trae 翻译网关(Trae 聊天桥)。
3
+ *
4
+ * 与 core/bridge.js 的分工:core 桥是"透传代理"(上游说 OpenAI 方言);本网关是
5
+ * "协议翻译器"——Trae 云端说私有方言,请求/响应都要改写。复用 core 原语:
6
+ * SessionLimiter(会话并发闸)与 usage-meter(计量)。
7
+ *
8
+ * 出站协议(2026-08-24 带凭据实测校准,证据 docs/reverse/trae-cloud-api.md §5;
9
+ * 生产级参照 github.com/autumnsentiment/Trae2api-cn 的 raw client):
10
+ * POST {chatBaseURL}/api/agent/v3/llm_utils_chat
11
+ * 头(IDE 指纹全套——缺设备头曾被间歇拒绝):
12
+ * Authorization / X-Cloudide-Token / x-ide-token 三头同值(JWT)
13
+ * x-app-id(product.json appId `6eefa01c-…`,**不是 OAuth client_id**——
14
+ * 用错报 TCC "record not found");缺省报 4001 "expr_path=app_id"
15
+ * x-ide-version 3.3.67 / x-ide-version-code 20260401(数字串,'0.1.52' 会判
16
+ * missing)/ x-ide-version-type stable
17
+ * x-device-id / x-machine-id / x-device-brand(设备指纹,与登录上报一致)
18
+ * x-request-id(每请求 uuid)/ x-uid(账号 uid)/ User-Agent 置空
19
+ * 体:{messages[native], model, function:"inline_chat", request_id, session_id,
20
+ * stream:true, max_tokens?, tools?[OpenAI 原生], tool_choice?, 生成参数透传}
21
+ * native message:content 为 [{type:"text",text}] 块数组(字符串直发 400/4001
22
+ * "cannot unmarshal string …LLMRawMessageContent");assistant.tool_calls 与
23
+ * tool 角色原生透传。function 必填(缺则 2001 "function is empty, cannot
24
+ * resolve model by usage=")
25
+ * agent 传输(function=solo_work_lite,2026-10-05 探针校准,证据
26
+ * docs/probes/trae-agent-v3-*.jsonl):同一端点的第二面——接受 OpenAI 风格
27
+ * tools(parameters 序列化字符串)+ tool_choice=auto + **并行 tool_calls**
28
+ * (A4 臂单事件双调用),历史 assistant.tool_calls 的出站键必须是
29
+ * **function_call**(function 键被 proto 层拒:「required field Name is not
30
+ * set」,A3 臂一轮/二轮对照);reasoning_effort 字段被服务端忽略(A5 臂与
31
+ * A2 同形态,如实照发不虚标);模型路由被 function 位钉死(A6 臂 kimi-k2.6/
32
+ * DeepSeek-V4-Flash 的 timing_cost.provider_model_name 恒为 glm-5.2——
33
+ * 改派由 SSE 注释行/message.note 诚实披露,同 inline/chat_v3 面纪律)。
34
+ * SSE(事件名在 event: 行或 data.event 字段):
35
+ * error → 抛错(1001 未认证 / 4011 限流 / 2001 模型解析失败)
36
+ * request_wait_in_queue / data.position → 排队提示(位置变化才发)
37
+ * token_usage → usage(data.usage 或 data 顶层计数)
38
+ * 文本:data.response / data.reasoning_content 为**累计快照**——前缀差分出
39
+ * 增量(不是逐段 delta!直接当增量会大面积重复);finish_reason 可出现在
40
+ * 中间快照,只有 event:done 或 data.stop_reason 才真正结束
41
+ * 工具调用:data.tool_calls 数组或 data.tool_call_info{name,params,id}
42
+ *
43
+ * 生命周期纪律(踩坑 #17):listen 失败绝不抛出——降级为 runtime.lastError。
44
+ */
45
+
46
+ import { createServer } from 'node:http'
47
+ import { randomUUID } from 'node:crypto'
48
+ import { appendFileSync } from 'node:fs'
49
+
50
+ import { sanitizeToolPairing } from '../tool-pairing.js'
51
+
52
+ import { SessionLimiter, extractSessionId } from '../../core/bridge.js'
53
+ import { normalizeTraeError, formatTraeErrorMessage, TRAE_CREDENTIAL_UNAVAILABLE_MESSAGE } from './errors.js'
54
+ import {
55
+ createRemoteSession, openRemoteEvents, stopRemoteSession, createRemoteEventParser,
56
+ cumulativeDelta,
57
+ } from './remote.js'
58
+
59
+ // re-export:实现唯一归 remote.js;本模块导出面不变(verify 脚本从此导入)。
60
+ export { cumulativeDelta }
61
+
62
+ /** Trae 云端客户端指纹(product.json appId + Trae2api-cn 生产实测值,2026-08-24 校准)。 */
63
+ export const TRAE_APP_ID = '6eefa01c-1036-4c7e-9ca5-d891f63bfcd8'
64
+ export const TRAE_IDE_VERSION = '3.3.67'
65
+ export const TRAE_IDE_VERSION_CODE = '20260401'
66
+ const CHAT_PATH = '/api/agent/v3/llm_utils_chat'
67
+ const DEFAULT_FUNCTION = 'inline_chat'
68
+
69
+ /**
70
+ * 出站 IDE 头组(凭证三头由调用处合入)。设备指纹取自 oauth.js 生成的设备
71
+ * 身份(device_id/machine_id 与登录时上报的一致)。
72
+ */
73
+ export function traeOutboundHeaders(device, uid, requestId) {
74
+ const h = {
75
+ 'Content-Type': 'application/json',
76
+ Accept: 'text/event-stream',
77
+ Connection: 'keep-alive',
78
+ 'x-app-id': TRAE_APP_ID,
79
+ 'x-ide-version': TRAE_IDE_VERSION,
80
+ 'x-ide-version-code': TRAE_IDE_VERSION_CODE,
81
+ 'x-ide-version-type': 'stable',
82
+ 'x-device-cpu': 'AMD',
83
+ 'x-device-type': 'windows',
84
+ 'x-os-version': 'Windows 10',
85
+ 'x-system-type': 'Windows',
86
+ 'x-request-id': requestId,
87
+ 'request-traffic-type': 'prod', // 官方头组(2026-08-24 网络日志);实测对响应无影响,对齐官方链路
88
+ 'package-type': 'stable_cn',
89
+ 'x-lgw-req-sdk-type': '3',
90
+ // 注意:不发 x-request-pin / x-requested-at——服务端见 pin 头即强制 base64 校验,
91
+ // 外部复刻者无官方密钥无法生成合法 pin,发了必 400 "base64 decode failed"(round 9 实测)。
92
+ 'User-Agent': '',
93
+ }
94
+ if (device?.deviceId) h['x-device-id'] = device.deviceId
95
+ if (device?.machineId) h['x-machine-id'] = device.machineId
96
+ if (device?.deviceBrand) h['x-device-brand'] = device.deviceBrand
97
+ // uid 原样透传(账号 uid 是字符串语义,不必可数字化);只挡 NaN/Infinity 这类
98
+ // 下游 String() 后会变成字面污染头的值。
99
+ if (uid !== undefined && uid !== null && !(typeof uid === 'number' && !Number.isFinite(uid))) h['x-uid'] = String(uid)
100
+ return h
101
+ }
102
+
103
+ /** OpenAI content(字符串或分段数组)→ Trae 原生 text 块数组(非文本段折叠丢弃)。 */
104
+ function toTextBlocks(content) {
105
+ if (typeof content === 'string') return [{ type: 'text', text: content }]
106
+ if (Array.isArray(content)) {
107
+ const texts = content
108
+ .filter((p) => p && typeof p === 'object' && p.type === 'text' && typeof p.text === 'string')
109
+ .map((p) => ({ type: 'text', text: p.text }))
110
+ if (texts.length) return texts
111
+ }
112
+ return [{ type: 'text', text: '' }]
113
+ }
114
+
115
+ /** OpenAI 工具调用 → Trae 原生(arguments 归一为字符串)。
116
+ * fnKey='function'(默认,inline/chat_v3 面线缆形态,2026-08-24 校准);
117
+ * fnKey='function_call'(solo_work_lite 面——2026-10-05 A3 臂实测:OpenAI 风格
118
+ * `function` 键被 proto 层拒绝「ToolCall read field 4 'FunctionCall' error:
119
+ * required field Name is not set」;该面 SSE 出站的 tool_calls[i] 同样以
120
+ * function_call 为键,入站出站同构)。 */
121
+ function nativeToolCalls(toolCalls, { fnKey = 'function' } = {}) {
122
+ if (!Array.isArray(toolCalls)) return undefined
123
+ const out = []
124
+ for (const c of toolCalls) {
125
+ if (!c || typeof c !== 'object') continue
126
+ const fn = c.function && typeof c.function === 'object' ? c.function : null
127
+ out.push({
128
+ id: typeof c.id === 'string' && c.id ? c.id : `trae-call-${out.length}`,
129
+ type: 'function',
130
+ [fnKey]: {
131
+ name: String(fn?.name ?? ''),
132
+ arguments: typeof fn?.arguments === 'string' ? fn.arguments : JSON.stringify(fn?.arguments ?? {}),
133
+ },
134
+ })
135
+ }
136
+ return out.length ? out : undefined
137
+ }
138
+
139
+ /** OpenAI messages → Trae native messages(content 块化;tool_calls/tool 角色原生)。 */
140
+ function toNativeMessages(messages, opts) {
141
+ const out = []
142
+ for (const m of (Array.isArray(messages) ? messages : [])) {
143
+ if (!m || typeof m !== 'object') continue
144
+ const role = String(m.role ?? 'user')
145
+ if (role === 'tool') {
146
+ out.push({
147
+ role: 'tool',
148
+ tool_call_id: String(m.tool_call_id ?? m.toolCallId ?? ''),
149
+ ...(typeof m.name === 'string' && m.name ? { name: m.name } : {}),
150
+ content: toTextBlocks(m.content),
151
+ })
152
+ continue
153
+ }
154
+ const native = { role, content: toTextBlocks(m.content) }
155
+ const calls = nativeToolCalls(m.tool_calls, opts)
156
+ if (role === 'assistant' && calls) native.tool_calls = calls
157
+ out.push(native)
158
+ }
159
+ return out
160
+ }
161
+
162
+ /** 透传白名单:上游声明接受的生成参数(Trae2api-cn RAW_GENERATION_FIELDS 同源)。 */
163
+ const GENERATION_FIELDS = [
164
+ 'temperature', 'top_p', 'stop', 'presence_penalty', 'frequency_penalty', 'seed',
165
+ 'reasoning_effort', 'stream_options', 'response_format', 'service_tier', 'user',
166
+ 'logprobs', 'top_logprobs', 'parallel_tool_calls',
167
+ ]
168
+
169
+ /** OpenAI 工具定义透传。parameters 必须序列化为字符串——服务端 Go 结构体
170
+ * `FunctionDefinition.tools.function.parameters` 是 string 型(内嵌 JSON,
171
+ * 与 scene_params 同套路;对象直发 → 4001 "cannot unmarshal object …
172
+ * parameters of type string",2026-08-24 dsh 主聊天实测)。 */
173
+ function nativeTools(tools) {
174
+ if (!Array.isArray(tools) || !tools.length) return undefined
175
+ const out = tools
176
+ .filter((t) => t && typeof t === 'object' && t.function && typeof t.function === 'object')
177
+ .map((t) => {
178
+ const fn = { ...t.function }
179
+ if (fn.parameters != null && typeof fn.parameters !== 'string') fn.parameters = JSON.stringify(fn.parameters)
180
+ return { type: 'function', function: fn }
181
+ })
182
+ return out.length ? out : undefined
183
+ }
184
+
185
+ /**
186
+ * OpenAI chat payload → Trae llm_utils_chat 请求体(2026-08-24 实测校准形态)。
187
+ * 返回 {body, requestId}(requestId 同时用于 x-request-id 头)。
188
+ */
189
+ export function buildChatRequest(payload, sessionId, { fnKey } = {}) {
190
+ const requestId = randomUUID()
191
+ // 出站 tool 配对体检(踩坑 #39,与 Qoder 网关同一不变量):pi-ai 会删掉
192
+ // stopReason=error/aborted 的 assistant 却留下其 toolResult,孤儿 tool 消息在
193
+ // 严格上游会被拒;trae 侧同样不能假设宿主序列化器输出合法。
194
+ const pair = sanitizeToolPairing(payload.messages)
195
+ const body = {
196
+ messages: toNativeMessages(pair.messages, { fnKey }),
197
+ model: typeof payload.model === 'string' ? payload.model : 'glm-5.3',
198
+ function: DEFAULT_FUNCTION,
199
+ request_id: requestId,
200
+ session_id: sessionId,
201
+ stream: true,
202
+ }
203
+ const maxTokens = payload.max_tokens ?? payload.max_completion_tokens
204
+ if (Number.isFinite(maxTokens) && maxTokens > 0) body.max_tokens = Math.floor(maxTokens)
205
+ const tools = nativeTools(payload.tools)
206
+ if (tools) body.tools = tools
207
+ if (payload.tool_choice !== undefined && payload.tool_choice !== null) {
208
+ const named = payload.tool_choice?.type === 'function' ? payload.tool_choice.function?.name : null
209
+ body.tool_choice = named ? 'required' : String(payload.tool_choice)
210
+ }
211
+ for (const f of GENERATION_FIELDS) {
212
+ if (payload[f] !== undefined && payload[f] !== null) body[f] = payload[f]
213
+ }
214
+ return { body, requestId }
215
+ }
216
+
217
+ /** usage 字段名宽容映射(snake/camel 都收)。 */
218
+ function mapUsage(u) {
219
+ if (!u || typeof u !== 'object') return null
220
+ const num = (...keys) => {
221
+ for (const k of keys) {
222
+ if (typeof u[k] === 'number' && Number.isFinite(u[k])) return u[k]
223
+ }
224
+ return null
225
+ }
226
+ const mapped = {
227
+ prompt_tokens: num('prompt_tokens', 'promptTokens', 'input_tokens'),
228
+ completion_tokens: num('completion_tokens', 'completionTokens', 'output_tokens'),
229
+ total_tokens: num('total_tokens', 'totalTokens'),
230
+ }
231
+ return mapped.total_tokens != null || mapped.prompt_tokens != null || mapped.completion_tokens != null ? mapped : null
232
+ }
233
+
234
+ /**
235
+ * 有状态流解析器:吃 (eventName, dataObj),产出翻译事件
236
+ * {text?, reasoning?, toolCalls?, usage?, queue?, finish?, error?}。
237
+ * 文本累计差分、工具调用按 id 去重累积、done/stop_reason 终结语义均在此。
238
+ * toolCalls 是数组——单事件可携带多个并行调用(parallel_tool_calls),
239
+ * 每个元素 {index, id?, name?, argsDelta}。
240
+ */
241
+ export function createTraeStreamParser() {
242
+ const state = {
243
+ response: '', reasoning: '', usage: null, finish: null, done: false,
244
+ lastQueuePos: null, toolOrder: [], toolSlots: new Map(),
245
+ providerModel: null,
246
+ }
247
+ return {
248
+ isDone: () => state.done,
249
+ usage: () => state.usage,
250
+ finish: () => state.finish,
251
+ /** 服务端实际使用的模型(timing_cost.provider_model_name;模型改派时以此为准)。 */
252
+ providerModel: () => state.providerModel,
253
+ /** 按 index 序组装完整工具调用(非流式聚合用)。 */
254
+ toolCalls: () => state.toolOrder.map((i) => {
255
+ const s = state.toolSlots.get(i)
256
+ return { id: s.id || `trae-call-${i}`, type: 'function', function: { name: s.name, arguments: s.arguments } }
257
+ }),
258
+ handle(eventName, obj) {
259
+ if (!obj || typeof obj !== 'object') return {}
260
+ const event = typeof obj.event === 'string' && obj.event ? obj.event : eventName
261
+ if (event === 'error') {
262
+ state.done = true
263
+ return { error: normalizeTraeError(200, obj) }
264
+ }
265
+ if (event === 'timing_cost') {
266
+ // timing_cost 携带 provider_model_name = 实际派发的模型(2026-08-24
267
+ // 实测:请求模型可能被 function/套餐默认改派,此字段是唯一真值源)。
268
+ if (typeof obj.provider_model_name === 'string' && obj.provider_model_name) {
269
+ state.providerModel = obj.provider_model_name
270
+ }
271
+ return {}
272
+ }
273
+ if (event === 'metadata') return {}
274
+ if (event === 'request_wait_in_queue' || obj.position != null) {
275
+ const pos = obj.position ?? 0
276
+ if (pos !== state.lastQueuePos) {
277
+ state.lastQueuePos = pos
278
+ return { queue: pos }
279
+ }
280
+ return {}
281
+ }
282
+ if (event === 'token_usage') {
283
+ state.usage = mapUsage(obj.usage ?? obj)
284
+ return {}
285
+ }
286
+ const out = {}
287
+ const reasoningSnap = typeof obj.reasoning_content === 'string' ? obj.reasoning_content : ''
288
+ const responseSnap = typeof obj.response === 'string' ? obj.response : ''
289
+ const rd = cumulativeDelta(state.reasoning, reasoningSnap)
290
+ const td = cumulativeDelta(state.response, responseSnap)
291
+ if (reasoningSnap) state.reasoning = reasoningSnap
292
+ if (responseSnap) state.response = responseSnap
293
+ if (rd) out.reasoning = rd
294
+ if (td) out.text = td
295
+ // 工具调用(2026-08-24 真实线缆形态校准):tool_calls[i] 的键是
296
+ // **function_call**(非 OpenAI 的 function);arguments 是**增量片段**,
297
+ // 续片 id/name 为空、按 index 归属(证据 docs/probes/trae-chat-live-tools-*)。
298
+ // 单条 tool_call_info 为一次性全量形态。两形态都进 slot 按 index 累积。
299
+ const rawCalls = Array.isArray(obj.tool_calls) ? obj.tool_calls.slice() : []
300
+ const info = obj.tool_call_info
301
+ if (info && typeof info === 'object') {
302
+ rawCalls.push({
303
+ id: info.tool_call_id ?? info.id,
304
+ function_call: { name: info.name, arguments: typeof info.params === 'string' ? info.params : JSON.stringify(info.params ?? {}) },
305
+ })
306
+ }
307
+ // 单事件可能带多个 tool_calls(parallel_tool_calls 出站白名单允许)——
308
+ // 逐个产出,绝不覆盖(旧实现循环内反复赋 out.toolCall 只下发最后一个)。
309
+ const emitted = []
310
+ for (const c of rawCalls) {
311
+ if (!c || typeof c !== 'object') continue
312
+ const fn = (c.function_call && typeof c.function_call === 'object') ? c.function_call
313
+ : (c.function && typeof c.function === 'object' ? c.function : {})
314
+ const idx = Number.isInteger(c.index) ? c.index : state.toolOrder.length
315
+ if (!state.toolOrder.includes(idx)) state.toolOrder.push(idx)
316
+ const slot = state.toolSlots.get(idx) ?? { id: '', name: '', arguments: '' }
317
+ if (typeof c.id === 'string' && c.id) slot.id = c.id
318
+ if (typeof fn.name === 'string' && fn.name) slot.name = fn.name
319
+ let argsDelta = ''
320
+ const frag = typeof fn.arguments === 'string' ? fn.arguments : (fn.arguments != null ? JSON.stringify(fn.arguments) : '')
321
+ if (frag) {
322
+ // 增量片段/累计快照两形态兼容:以前缀扩展视为快照替换,否则按片段拼接
323
+ if (slot.arguments && frag.startsWith(slot.arguments)) {
324
+ argsDelta = frag.slice(slot.arguments.length)
325
+ slot.arguments = frag
326
+ } else {
327
+ slot.arguments += frag
328
+ argsDelta = frag
329
+ }
330
+ }
331
+ state.toolSlots.set(idx, slot)
332
+ emitted.push({
333
+ index: idx,
334
+ // id/name 只在本事件实际携带时下发(OpenAI 流式约定:续片不重复)
335
+ id: typeof c.id === 'string' && c.id ? slot.id : undefined,
336
+ name: typeof fn.name === 'string' && fn.name ? slot.name : undefined,
337
+ argsDelta,
338
+ })
339
+ }
340
+ if (emitted.length) out.toolCalls = emitted
341
+ if (obj.usage) {
342
+ const u = mapUsage(obj.usage)
343
+ if (u) state.usage = u
344
+ }
345
+ if (obj.finish_reason) state.finish = String(obj.finish_reason)
346
+ if (event === 'done' || obj.stop_reason) {
347
+ state.done = true
348
+ state.finish = String(obj.finish_reason ?? obj.stop_reason ?? state.finish ?? 'stop')
349
+ // 上游带工具调用时 done 仍发 "stop"(2026-08-24 实测);OpenAI 语义需要
350
+ // tool_calls,否则客户端不会触发工具调用循环。
351
+ if (state.finish === 'stop' && state.toolOrder.length) state.finish = 'tool_calls'
352
+ out.finish = state.finish
353
+ }
354
+ return out
355
+ },
356
+ }
357
+ }
358
+
359
+ /** OpenAI chunk 形态工厂。 */
360
+ function oaiChunk(id, model, delta, finishReason = null, usage = null) {
361
+ const chunk = {
362
+ id,
363
+ object: 'chat.completion.chunk',
364
+ created: Math.floor(Date.now() / 1000),
365
+ model,
366
+ choices: [{ index: 0, delta, ...(finishReason ? { finish_reason: finishReason } : {}) }],
367
+ }
368
+ if (usage) chunk.usage = usage
369
+ return chunk
370
+ }
371
+
372
+ /**
373
+ * SSE 逐行扫描骨架(remote/inline 两条事件流共用):跨 chunk 组行,空行重置
374
+ * 事件名,`event:` 记名,`data:` JSON 解析后交 parser.handle 派发;`[DONE]`
375
+ * 与非 JSON data 行吞掉。cb(ev) 返回真值时中止扫描并透传该值(调用方据此
376
+ * 跳出,如 inline 的 'fallback'/'done')。仅供本文件内部使用,不进 core/。
377
+ */
378
+ async function forEachSseEvent(body, parser, cb) {
379
+ const reader = body.getReader()
380
+ const decoder = new TextDecoder()
381
+ let buf = ''
382
+ let lastEventName = null
383
+ // 提前 return(cb 判 'fallback'/'err'/'done')或异常时取消上游 reader——
384
+ // 否则连接残留在 undici 池里泄漏(3003 回退每条一次)。
385
+ try {
386
+ for (;;) {
387
+ const { done, value } = await reader.read()
388
+ if (done) break
389
+ buf += decoder.decode(value, { stream: true })
390
+ let nl
391
+ while ((nl = buf.indexOf('\n')) >= 0) {
392
+ const line = buf.slice(0, nl).trim()
393
+ buf = buf.slice(nl + 1)
394
+ if (!line) { lastEventName = null; continue }
395
+ if (line.startsWith('event:')) { lastEventName = line.slice(6).trim(); continue }
396
+ if (line.startsWith('id:') || line.startsWith(':')) continue
397
+ if (!line.startsWith('data:')) continue
398
+ const data = line.slice(5).trim()
399
+ if (data === '[DONE]') { parser.handle('done', {}); continue }
400
+ let chunk = null
401
+ try { chunk = JSON.parse(data) } catch { continue }
402
+ const ret = await cb(parser.handle(lastEventName, chunk))
403
+ if (ret) return ret
404
+ }
405
+ }
406
+ return undefined
407
+ } finally {
408
+ reader.cancel().catch(() => {})
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Host 门(审计 [9]):网关无认证,唯一防线是回环端口——但回环端口本机任意
414
+ * 进程/页面(含 DNS rebinding 把公网域名解析到 127.0.0.1 的浏览器请求)都能
415
+ * 连上。Host 白名单把表面收紧到回环主机名:Host 头可带端口,用 URL 解析出
416
+ * hostname 再比对(IPv6 经 URL 解析后 hostname 保留方括号,即 [::1]);
417
+ * 解析失败一律拒绝。
418
+ */
419
+ function isLoopbackHost(hostHeader) {
420
+ if (typeof hostHeader !== 'string' || !hostHeader) return false
421
+ let hostname
422
+ try {
423
+ hostname = new URL(`http://${hostHeader}`).hostname
424
+ } catch {
425
+ return false
426
+ }
427
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]'
428
+ }
429
+
430
+ /** Origin 门(index.js localGuardFailure 同口径):浏览器跨站请求(含
431
+ * navigator.sendBeacon 的 text/plain 免预检形态)恒带 Origin——host:port
432
+ * 必须与 Host 完全一致才放行;无 Origin 放行(本机 fetch/curl 与剥 Origin
433
+ * 的壳转发均不带该头,Host 门仍把守回环)。 */
434
+ function isLoopbackOrigin(req) {
435
+ const origin = req.headers.origin
436
+ if (origin === undefined) return true
437
+ try {
438
+ return new URL(origin).host === req.headers.host
439
+ } catch {
440
+ return false
441
+ }
442
+ }
443
+
444
+ /** SSE 单行值清洗(审计 [20]):客户端/上游提供的字符串可能含 \r\n,直插
445
+ * 注释行会在响应流里伪造 SSE 帧——换行统一折叠为空格。 */
446
+ function sseLineValue(value) {
447
+ return String(value).replace(/[\r\n]+/g, ' ')
448
+ }
449
+
450
+ /**
451
+ * @param {{
452
+ * settings: () => object, // 需要 traeChatBaseURL / maxConcurrentPerSession
453
+ * withCredentials: (attempt: (cred) => Promise<Response>) => Promise<{cred,res,err}>,
454
+ * readAuthDevice: () => object|null, // 设备身份(出站设备指纹头)
455
+ * readAuthMeta: () => { uid?: * }, // 账号 uid(x-uid 头)
456
+ * meter: { record: Function },
457
+ * runtime: { running, port, lastError },
458
+ * forensics?: { logPath: () => string|undefined },
459
+ * getCatalogIds: () => string[],
460
+ * getModelPrefs?: () => object, // { [id]: { effort? } } remote 出站补默认档位
461
+ * }} deps
462
+ */
463
+ export function createTraeGateway(deps) {
464
+ const limiter = new SessionLimiter()
465
+ const logPrefix = '[dsh-tap/trae]'
466
+
467
+ function gwLog(record) {
468
+ const path = deps.forensics?.logPath?.()
469
+ if (!path) return
470
+ try {
471
+ appendFileSync(path, JSON.stringify({ gw: 'trae', ...record }) + '\n')
472
+ } catch { /* best-effort */ }
473
+ }
474
+
475
+ /**
476
+ * remote 传输(chat_sessions 协议):唯一真实的模型选择机制(2026-08-24
477
+ * 探测定论,见 remote.js 文件头)。每请求起一个云端沙箱 agent、耗 work
478
+ * 额度池、不支持 OpenAI tools(远端 agent 自持工具,dsh 工具环会断——
479
+ * 带 tools 的请求明确拒绝,不静默降级)。
480
+ */
481
+ async function handleRemoteChat(res, payload, model, wantStream, sessionId, t0) {
482
+ if ((Array.isArray(payload.tools) && payload.tools.length) || payload.tool_choice != null) {
483
+ res.writeHead(400, { 'Content-Type': 'application/json' })
484
+ res.end(JSON.stringify({ error: { message: 'trae remote 通道不支持 tools(模型选择仅对纯文本会话生效;需要 dsh 工具环请用 inline 通道)', code: 'remote-no-tools' } }))
485
+ return
486
+ }
487
+ const s = deps.settings()
488
+ const parser = createRemoteEventParser()
489
+ const id = `trae-remote-${randomUUID().slice(0, 8)}`
490
+ // G3 档位:客户端带 reasoning_effort 优先,否则文件层 prefs 补默认
491
+ //(线缆形态 = initial_message.custom_model.reasoning_effort,见 remote.js)。
492
+ const clientEffort = typeof payload.reasoning_effort === 'string' && payload.reasoning_effort ? payload.reasoning_effort : undefined
493
+ const prefsEffort = deps.getModelPrefs?.()?.[model]?.effort
494
+ const reasoningEffort = clientEffort ?? (typeof prefsEffort === 'string' && prefsEffort ? prefsEffort : undefined)
495
+ let sessionCreated = null
496
+ let tokenForStop = null
497
+ const release = await limiter.acquire(sessionId, s.maxConcurrentPerSession ?? 4)
498
+ try {
499
+ const { cred, res: eventsResp, err } = await deps.withCredentials(async (c) => {
500
+ const token = String(c.authorization).replace(/^Cloud-IDE-JWT\s+/, '')
501
+ tokenForStop = token
502
+ sessionCreated = await createRemoteSession(s.traeChatBaseURL, token, model, payload.messages, { reasoningEffort })
503
+ return openRemoteEvents(s.traeChatBaseURL, token, sessionCreated.sessionId, sessionCreated.messageId)
504
+ })
505
+ if (!cred || err) {
506
+ const isCred = err?.credentialUnavailable === true
507
+ res.writeHead(isCred ? 503 : 502, { 'Content-Type': 'application/json' })
508
+ res.end(JSON.stringify({ error: { message: isCred ? TRAE_CREDENTIAL_UNAVAILABLE_MESSAGE : `trae remote: ${err?.message ?? 'unknown'}`, code: err?.code } }))
509
+ return
510
+ }
511
+
512
+ if (wantStream) {
513
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
514
+ res.write(`data: ${JSON.stringify(oaiChunk(id, model, { role: 'assistant' }))}\n\n`)
515
+ }
516
+ const send = (chunk) => {
517
+ if (wantStream) res.write(`data: ${JSON.stringify(chunk)}\n\n`)
518
+ }
519
+
520
+ const interrupted = await forEachSseEvent(eventsResp.body, parser, (ev) => {
521
+ if (ev.error) {
522
+ const parsed = normalizeTraeError(200, ev.error)
523
+ const msg = formatTraeErrorMessage(parsed.code, parsed.message)
524
+ if (!res.headersSent) {
525
+ res.writeHead(502, { 'Content-Type': 'application/json' })
526
+ res.end(JSON.stringify({ error: { message: msg, code: parsed.code } }))
527
+ } else {
528
+ send({ error: { message: msg, code: parsed.code } })
529
+ res.write('data: [DONE]\n\n')
530
+ res.end()
531
+ }
532
+ gwLog({ dir: 'err', transport: 'remote', model, ms: Date.now() - t0, code: parsed.code })
533
+ return 'err'
534
+ }
535
+ if (ev.queue) send(oaiChunk(id, model, { content: '(Trae remote 排队/沙箱准备中…)\n' }))
536
+ if (ev.reasoning) send(oaiChunk(id, model, { reasoning_content: ev.reasoning }))
537
+ if (ev.text) send(oaiChunk(id, model, { content: ev.text }))
538
+ })
539
+ if (interrupted) return
540
+ const usage = parser.usage()
541
+ const actualModel = parser.actualModel() ?? model
542
+ if (wantStream) {
543
+ send(oaiChunk(id, model, {}, 'stop', usage))
544
+ res.write('data: [DONE]\n\n')
545
+ res.end()
546
+ } else {
547
+ res.writeHead(200, { 'Content-Type': 'application/json' })
548
+ const message = { role: 'assistant', content: parser.finalText() }
549
+ if (parser.actualModel() && parser.actualModel() !== model) {
550
+ message.note = `served by ${parser.actualModel()} (requested ${model})`
551
+ }
552
+ res.end(JSON.stringify({
553
+ id,
554
+ object: 'chat.completion',
555
+ created: Math.floor(Date.now() / 1000),
556
+ model,
557
+ choices: [{ index: 0, message, finish_reason: 'stop' }],
558
+ usage: usage ?? {},
559
+ }))
560
+ }
561
+ if (usage) deps.meter.record({ ts: t0, kind: 'chat', model: actualModel || null, usage })
562
+ gwLog({ dir: 'out', transport: 'remote', model, actualModel, ms: Date.now() - t0, usage })
563
+ } catch (err) {
564
+ if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json' })
565
+ try { res.end(JSON.stringify({ error: { message: `trae remote gateway error: ${err?.message ?? err}` } })) } catch { res.end() }
566
+ } finally {
567
+ release()
568
+ if (sessionCreated && tokenForStop) {
569
+ stopRemoteSession(s.traeChatBaseURL, tokenForStop, sessionCreated.sessionId, sessionCreated.messageId)
570
+ }
571
+ }
572
+ }
573
+
574
+ async function handleChat(req, res, rawBody) {
575
+ const s = deps.settings()
576
+ let payload = null
577
+ try { payload = JSON.parse(rawBody) } catch { payload = null }
578
+ if (!payload || !Array.isArray(payload.messages) || !payload.messages.length) {
579
+ res.writeHead(400, { 'Content-Type': 'application/json' })
580
+ res.end(JSON.stringify({ error: { message: 'invalid chat payload' } }))
581
+ return
582
+ }
583
+ const model = typeof payload.model === 'string' ? payload.model : ''
584
+ const wantStream = payload.stream === true
585
+ const sessionId = extractSessionId(req.headers, payload) ?? randomUUID()
586
+ const t0 = Date.now()
587
+
588
+ // 传输选择:remote(chat_sessions,真模型路由、耗 work 池、无 tools)|
589
+ // agent(llm_utils_chat+solo_work_lite,原生 tools/并行/tool 回传,模型位钉死
590
+ // glm-5.2、reasoning_effort 被忽略——2026-10-05 探针 A1-A6 校准)|
591
+ // inline(默认,llm_utils_chat+inline_chat,模型恒为账户默认、原生 tools)。
592
+ if (s.traeChatTransport === 'remote') {
593
+ await handleRemoteChat(res, payload, model, wantStream, sessionId, t0)
594
+ return
595
+ }
596
+ // agent 面与 inline 面共享同一个 SSE 循环,差别只在:function 值、历史
597
+ // tool_calls 出站键(function_call)、无 3003→chat_v3 回退(agent 面本身
598
+ // 就是出路)。
599
+ const isAgent = s.traeChatTransport === 'agent'
600
+
601
+ const release = await limiter.acquire(sessionId, s.maxConcurrentPerSession ?? 4)
602
+ // inline 面事故回退(2026-08-24 实测:服务端故障期 inline_chat 对一切模型名
603
+ // 返回 3003,而同信封 chat_v3 正常出文本)——首次尝试用 inline_chat;遇 3003
604
+ // 且请求无 tools 时自动降级 chat_v3 重试一次。改派由既有机制诚实披露
605
+ // (SSE 注释行 / message.note / 计量记真实模型),绝不假装请求模型被服务。
606
+ const hasTools = (Array.isArray(payload.tools) && payload.tools.length > 0) || payload.tool_choice != null
607
+ let fallbackUsed = false
608
+
609
+ async function attemptInline(fnValue, streamStarted) {
610
+ const { body, requestId } = buildChatRequest(payload, sessionId, isAgent ? { fnKey: 'function_call' } : undefined)
611
+ body.function = fnValue
612
+ // 首字节护栏(2026-08-24 故障取证:本地代理/边缘对 POST 偶发"收下请求不
613
+ // 回应",无超时会令用户请求无限挂死)。fetch 在响应头到达即 resolve,
614
+ // 计时器随即清除——SSE 长流不受影响;仅约束"连上却不出头"的死态。
615
+ const firstByteMs = Number(s.upstreamFirstByteTimeoutMs) > 0 ? Number(s.upstreamFirstByteTimeoutMs) : 45_000
616
+ const { cred, res: upstream0, err } = await deps.withCredentials((c) => {
617
+ const token = String(c.authorization).replace(/^Cloud-IDE-JWT\s+/, '')
618
+ const headers = {
619
+ ...traeOutboundHeaders(deps.readAuthDevice?.(), deps.readAuthMeta?.()?.uid, requestId),
620
+ Authorization: `Cloud-IDE-JWT ${token}`,
621
+ 'X-Cloudide-Token': token,
622
+ 'x-ide-token': token,
623
+ }
624
+ const inbound = new AbortController()
625
+ const firstByteTimer = setTimeout(
626
+ () => inbound.abort(new Error(`trae 上游 ${firstByteMs}ms 内无响应(首字节超时)——边缘/WAF 拦截或本地代理异常;可稍后重试,需要真实模型选择可切 remote 传输`)),
627
+ firstByteMs,
628
+ )
629
+ return fetch(`${s.traeChatBaseURL}${CHAT_PATH}`, {
630
+ method: 'POST',
631
+ headers,
632
+ body: JSON.stringify(body),
633
+ redirect: 'follow', // 官方 TTNet 对 llm_utils_chat 307→api5-normal(2026-08-24 日志取证);跟随重定向对齐官方链路
634
+ signal: inbound.signal,
635
+ }).finally(() => clearTimeout(firstByteTimer))
636
+ })
637
+ if (!cred || err) {
638
+ const isCred = err?.credentialUnavailable === true
639
+ res.writeHead(isCred ? 503 : 502, { 'Content-Type': 'application/json' })
640
+ res.end(JSON.stringify({ error: { message: isCred ? TRAE_CREDENTIAL_UNAVAILABLE_MESSAGE : `trae upstream unreachable: ${err?.message ?? err?.name ?? 'unknown'}` } }))
641
+ return 'done'
642
+ }
643
+ const upstream = upstream0
644
+ if (!upstream.ok) {
645
+ const parsed = normalizeTraeError(upstream.status, await upstream.json().catch(() => null))
646
+ const status = upstream.status === 401 || upstream.status === 429 ? upstream.status : 502
647
+ res.writeHead(status, { 'Content-Type': 'application/json' })
648
+ res.end(JSON.stringify({ error: { message: formatTraeErrorMessage(parsed.code, parsed.message), code: parsed.code } }))
649
+ gwLog({ dir: 'err', status: upstream.status, code: parsed.code, model, ms: Date.now() - t0 })
650
+ return 'done'
651
+ }
652
+
653
+ const id = `trae-gateway-${randomUUID().slice(0, 8)}`
654
+ let content = ''
655
+ let usage = null
656
+ let finishReason = null
657
+ let rerouteNotified = false
658
+ const parser = createTraeStreamParser()
659
+ // 3003 降级重试发生在同一 HTTP 响应上——但只在**尚未下发任何可见内容**时
660
+ // 才允许(角色 chunk/排队提示/文本/工具帧一旦离手,重试会把它们原样重复
661
+ // 一遍,用户可见答案出现重复段落)。streamStarted 由调用方跨 attempt 维护;
662
+ // 首个可见帧离手后置真,此后 3003 按终局错误下发而不再换 chat_v3。
663
+ // 角色 chunk 不在 attempt 开头无条件预发——那会让 streamStarted 立即为真、
664
+ // 3003 回退永远走不到;延迟到首个可见帧时随头发出(OpenAI 流式允许首帧
665
+ // 即携带内容,role 帧非强制)。
666
+ // 响应头在进入 SSE 循环前就 writeHead(不算可见内容,3003 回退仍可走)——
667
+ // 否则下方 reroute 注释行的 res.write 会先把头发出去,ensureStreamHead
668
+ // 再 writeHead 即 "Cannot write headers after they are sent"。
669
+ if (wantStream && !res.headersSent) {
670
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
671
+ }
672
+ const ensureStreamHead = () => {
673
+ if (!wantStream || streamStarted.v) return
674
+ res.write(`data: ${JSON.stringify(oaiChunk(id, model, { role: 'assistant' }))}\n\n`)
675
+ streamStarted.v = true
676
+ }
677
+ const send = (chunk) => {
678
+ if (wantStream) {
679
+ ensureStreamHead()
680
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`)
681
+ }
682
+ }
683
+
684
+ let queueEmitted = false
685
+ const outcome = await forEachSseEvent(upstream.body, parser, (ev) => {
686
+ if (ev.error) {
687
+ // 3003 且尚未降级且无 tools 且未下发任何可见内容 → 换 chat_v3 重试
688
+ //(仅 inline 面事故回退;agent 面(solo_work_lite)本身就是出路,不回退)
689
+ if (!isAgent && ev.error.code === 3003 && !fallbackUsed && !hasTools && !streamStarted.v) return 'fallback'
690
+ const msg = formatTraeErrorMessage(ev.error.code, ev.error.message)
691
+ if (!res.headersSent) {
692
+ res.writeHead(502, { 'Content-Type': 'application/json' })
693
+ res.end(JSON.stringify({ error: { message: msg, code: ev.error.code } }))
694
+ } else {
695
+ send({ error: { message: msg, code: ev.error.code } })
696
+ res.write('data: [DONE]\n\n')
697
+ res.end()
698
+ }
699
+ gwLog({ dir: 'err', model, ms: Date.now() - t0, code: ev.error.code, fn: fnValue })
700
+ return 'done'
701
+ }
702
+ if (ev.queue != null && !queueEmitted) {
703
+ queueEmitted = true
704
+ send(oaiChunk(id, model, { content: `(Trae 排队中,位置 ${ev.queue})\n` }))
705
+ }
706
+ // 模型改派提示(一次):请求模型 ≠ timing_cost 报告的实际模型时,
707
+ // 以 SSE 注释行告知(OpenAI 解析器忽略、原始流/日志可见——不污染
708
+ // 调用方会话历史),计量/日志用真实模型——绝不假装请求模型被服务。
709
+ const actual = parser.providerModel()
710
+ if (actual && !rerouteNotified && model && actual !== model) {
711
+ rerouteNotified = true
712
+ // 值过 sseLineValue 清洗(审计 [20]):model/actual 任一方含 \r\n
713
+ // 都会在注释行后伪造 SSE 帧。其余 res.write 全部经 JSON.stringify。
714
+ if (wantStream) res.write(`: trae-reroute requested=${sseLineValue(model)} actual=${sseLineValue(actual)}\n\n`)
715
+ }
716
+ if (ev.reasoning) send(oaiChunk(id, model, { reasoning_content: ev.reasoning }))
717
+ if (ev.text) {
718
+ content += ev.text
719
+ send(oaiChunk(id, model, { content: ev.text }))
720
+ }
721
+ // 单事件可带多个 tool_calls——逐个下发各自的增量帧,绝不合并覆盖
722
+ for (const tc of ev.toolCalls ?? []) {
723
+ // OpenAI 流式约定:首片带 id/type/name,续片只带 index+arguments 增量
724
+ const frame = { index: tc.index, function: { arguments: tc.argsDelta } }
725
+ if (tc.id) { frame.id = tc.id; frame.type = 'function' }
726
+ if (tc.name) frame.function.name = tc.name
727
+ send(oaiChunk(id, model, { tool_calls: [frame] }))
728
+ }
729
+ })
730
+ if (outcome) return outcome
731
+ finishReason = parser.finish() ?? 'stop'
732
+ usage = parser.usage()
733
+ const actualModel = parser.providerModel() ?? model
734
+ if (wantStream) {
735
+ send(oaiChunk(id, model, {}, finishReason, usage))
736
+ res.write('data: [DONE]\n\n')
737
+ res.end()
738
+ } else {
739
+ res.writeHead(200, { 'Content-Type': 'application/json' })
740
+ const message = { role: 'assistant', content }
741
+ const calls = parser.toolCalls()
742
+ if (calls.length) message.tool_calls = calls
743
+ if (parser.providerModel() && parser.providerModel() !== model) {
744
+ message.note = `served by ${parser.providerModel()} (requested ${model})`
745
+ }
746
+ res.end(JSON.stringify({
747
+ id,
748
+ object: 'chat.completion',
749
+ created: Math.floor(Date.now() / 1000),
750
+ model,
751
+ choices: [{ index: 0, message, finish_reason: finishReason }],
752
+ usage: usage ?? {},
753
+ }))
754
+ }
755
+ if (usage) deps.meter.record({ ts: t0, kind: 'chat', model: actualModel || null, usage })
756
+ gwLog({ dir: 'out', model, actualModel, rerouted: actualModel !== model, ms: Date.now() - t0, bytes: content.length, usage, finishReason, fn: fnValue })
757
+ return 'done'
758
+ }
759
+
760
+ try {
761
+ const streamStarted = { v: false }
762
+ let fnValue = isAgent ? 'solo_work_lite' : 'inline_chat'
763
+ for (;;) {
764
+ const outcome = await attemptInline(fnValue, streamStarted)
765
+ if (outcome === 'fallback') {
766
+ fallbackUsed = true
767
+ fnValue = 'chat_v3'
768
+ gwLog({ dir: 'fallback', from: 'inline_chat', to: 'chat_v3', model, ms: Date.now() - t0 })
769
+ continue
770
+ }
771
+ break
772
+ }
773
+ } catch (err) {
774
+ if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json' })
775
+ try { res.end(JSON.stringify({ error: { message: `trae gateway error: ${err?.message ?? err}` } })) } catch { res.end() }
776
+ } finally {
777
+ release()
778
+ }
779
+ }
780
+
781
+ function listen(port) {
782
+ const server = createServer((req, res) => {
783
+ // Host 门 + Origin 门:Host 必须回环(防 DNS rebinding / LAN 直连),
784
+ // 带 Origin 时其 host:port 必须与 Host 一致(防跨站借浏览器烧额度)。
785
+ // 先 resume 丢弃未读请求体再应答,保证 403 完整送达后连接正常收尾。
786
+ if (!isLoopbackHost(req.headers.host) || !isLoopbackOrigin(req)) {
787
+ req.resume()
788
+ res.writeHead(403, { 'Content-Type': 'application/json' })
789
+ res.end(JSON.stringify({ error: { message: 'forbidden: loopback host + same-origin required' } }))
790
+ return
791
+ }
792
+ // Buffer 收集 + 一次解码(踩坑 #28,同 core/bridge.js):逐分片隐式
793
+ // utf8 解码会把跨分片多字节字符损坏成 3×U+FFFD,译文上行带乱码。
794
+ // 超 32MB 答 413(同 core/bridge.js),不静默 reset。
795
+ const chunks = []
796
+ let received = 0
797
+ let oversize = false
798
+ req.on('data', (c) => {
799
+ if (oversize) return
800
+ chunks.push(c)
801
+ received += c.length
802
+ if (received > 32 * 1024 * 1024) {
803
+ oversize = true
804
+ chunks.length = 0
805
+ res.writeHead(413, { 'Content-Type': 'application/json', Connection: 'close' })
806
+ res.end(JSON.stringify({ error: { message: 'request body too large (limit 32MiB)' } }))
807
+ }
808
+ })
809
+ req.on('end', () => {
810
+ if (oversize) return
811
+ const rawBody = Buffer.concat(chunks).toString('utf8')
812
+ const path = req.url?.split('?')[0] ?? ''
813
+ try {
814
+ if (req.method === 'POST' && (path === '/v1/chat/completions' || path === '/chat/completions')) {
815
+ handleChat(req, res, rawBody).catch(() => { if (!res.headersSent) { res.writeHead(500); res.end() } })
816
+ return
817
+ }
818
+ if (req.method === 'GET' && (path === '/v1/models' || path === '/models')) {
819
+ res.writeHead(200, { 'Content-Type': 'application/json' })
820
+ res.end(JSON.stringify({
821
+ object: 'list',
822
+ data: (deps.getCatalogIds() ?? []).map((id) => ({ id, object: 'model', created: Math.floor(Date.now() / 1000) })),
823
+ }))
824
+ return
825
+ }
826
+ res.writeHead(404, { 'Content-Type': 'application/json' })
827
+ res.end(JSON.stringify({ error: { message: `no route: ${req.method} ${path}` } }))
828
+ } catch (err) {
829
+ if (!res.headersSent) res.writeHead(500)
830
+ res.end(String(err?.message ?? err))
831
+ }
832
+ })
833
+ })
834
+ server.on('error', (err) => {
835
+ deps.runtime.running = false
836
+ deps.runtime.lastError = err?.code ?? String(err?.message ?? err)
837
+ process.stderr.write(`${logPrefix} gateway :${port} unavailable: ${deps.runtime.lastError}(Trae 分区其余功能不受影响)\n`)
838
+ })
839
+ server.on('listening', () => {
840
+ // port=0(临时端口,测试用)时回填实际端口。
841
+ deps.runtime.port = server.address()?.port ?? port
842
+ deps.runtime.running = true
843
+ deps.runtime.lastError = null
844
+ })
845
+ server.listen(port, '127.0.0.1')
846
+ return () => new Promise((resolve) => {
847
+ server.close(() => resolve())
848
+ server.closeAllConnections?.()
849
+ })
850
+ }
851
+
852
+ return { listen, handleChat }
853
+ }