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.
- package/AGENTS.md +99 -0
- package/CHANGELOG.md +751 -0
- package/LICENSE +21 -0
- package/README.en.md +59 -0
- package/README.md +256 -0
- package/cordis.patch.yml +385 -0
- package/core/bridge.js +698 -0
- package/core/json-store.js +91 -0
- package/core/rotation.js +108 -0
- package/core/usage-meter.js +176 -0
- package/docs/diagnosis-cache-quota.md +181 -0
- package/docs/diagnosis-qoder-flash.md +67 -0
- package/docs/diagnosis-trae-3003.md +302 -0
- package/docs/goals/bridge-port-host-split.md +91 -0
- package/docs/goals/desktop-adaptation.md +106 -0
- package/docs/goals/qoder-cn-provider-design.md +308 -0
- package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
- package/docs/goals/settings-card-ux-redesign.md +162 -0
- package/docs/goals/trae-agent-v3.md +41 -0
- package/docs/goals/trae-work-cn-repair.md +44 -0
- 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
- package/docs/pitfalls.md +105 -0
- package/docs/reverse/trae-cloud-api.md +218 -0
- package/docs/reverse/trae-model-catalog.md +129 -0
- package/docs/reverse/traework-cn.md +520 -0
- package/docs/rules/STATE.md +198 -0
- package/docs/rules/content-moderation.md +54 -0
- package/docs/rules/dev-role-boundary.md +94 -0
- package/docs/rules/extra-providers.md +42 -0
- package/docs/rules/gateway-facts.md +91 -0
- package/docs/rules/oauth-handshake.md +76 -0
- package/docs/rules/prompt-cache.md +93 -0
- package/docs/rules/quota-signals.md +125 -0
- package/docs/rules/routing.md +84 -0
- package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
- package/docs/rules/trae-surface.md +242 -0
- package/docs/rules/ua-validation.md +81 -0
- package/host-config.js +282 -0
- package/index.js +2306 -0
- package/lib/client.js +2893 -0
- package/local-scan.js +104 -0
- package/package.json +82 -0
- package/providers/ark/index.js +11 -0
- package/providers/bailian/index.js +10 -0
- package/providers/bigmodel/index.js +11 -0
- package/providers/codebuddy/agenttool.js +122 -0
- package/providers/codebuddy/catalog.js +227 -0
- package/providers/codebuddy/errors.js +44 -0
- package/providers/codebuddy/headers.js +36 -0
- package/providers/codebuddy/images.js +125 -0
- package/providers/codebuddy/index.js +123 -0
- package/providers/codebuddy/oauth.js +279 -0
- package/providers/deepseek/index.js +11 -0
- package/providers/moonshot/index.js +11 -0
- package/providers/openai-compat.js +177 -0
- package/providers/openrouter/index.js +27 -0
- package/providers/qoder/catalog.js +145 -0
- package/providers/qoder/cosy.js +419 -0
- package/providers/qoder/gateway.js +563 -0
- package/providers/qoder/index.js +116 -0
- package/providers/qoder/oauth.js +364 -0
- package/providers/qoder/qoder_auth.wasm +0 -0
- package/providers/qoder/quota.js +56 -0
- package/providers/qwen/index.js +15 -0
- package/providers/tool-pairing.js +129 -0
- package/providers/trae/catalog.js +103 -0
- package/providers/trae/errors.js +85 -0
- package/providers/trae/gateway.js +853 -0
- package/providers/trae/index.js +126 -0
- package/providers/trae/oauth.js +443 -0
- package/providers/trae/quota.js +75 -0
- package/providers/trae/remote.js +365 -0
- package/scripts/capture-cache.mjs +65 -0
- package/scripts/capture-traffic.mjs +83 -0
- package/scripts/hermes-probe-dev-role.mjs +263 -0
- package/scripts/measure-latency.mjs +253 -0
- package/scripts/probe-ark-thinking.mjs +298 -0
- package/scripts/probe-cache-decline.mjs +292 -0
- package/scripts/probe-cache-ttl.mjs +221 -0
- package/scripts/probe-cache.mjs +156 -0
- package/scripts/probe-codebuddy-efforts.mjs +355 -0
- package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
- package/scripts/probe-effort-gaps.mjs +133 -0
- package/scripts/probe-media.mjs +115 -0
- package/scripts/probe-moderation.mjs +159 -0
- package/scripts/probe-oauth.mjs +617 -0
- package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
- package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
- package/scripts/probe-qoder-attribution.mjs +266 -0
- package/scripts/probe-qoder-flash-confirm.mjs +94 -0
- package/scripts/probe-qoder-live.mjs +344 -0
- package/scripts/probe-qoder-matrix.mjs +274 -0
- package/scripts/probe-qoder-null-content.mjs +153 -0
- package/scripts/probe-qoder-pairing.mjs +308 -0
- package/scripts/probe-qoder-quota.mjs +162 -0
- package/scripts/probe-qoder-thinking-config.mjs +52 -0
- package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
- package/scripts/probe-quota.mjs +136 -0
- package/scripts/probe-quota2.mjs +144 -0
- package/scripts/probe-routing.mjs +226 -0
- package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
- package/scripts/probe-trae-agent-v3.mjs +399 -0
- package/scripts/probe-trae-efforts.mjs +149 -0
- package/scripts/probe-trae-live.mjs +147 -0
- package/scripts/probe-trae-max-effort.mjs +179 -0
- package/scripts/probe-trae-model-routing.mjs +381 -0
- package/scripts/probe-trae-thinking-scene.mjs +238 -0
- package/scripts/probe-trae-transport-outage.mjs +176 -0
- package/scripts/probe-ua.mjs +508 -0
- package/scripts/trae-model-catalog.mjs +632 -0
- package/scripts/verify-agents-md.mjs +45 -0
- package/scripts/verify-bridge.mjs +1041 -0
- package/scripts/verify-core-generic.mjs +302 -0
- package/scripts/verify-desktop-acceptance.mjs +184 -0
- package/scripts/verify-host-config.mjs +265 -0
- package/scripts/verify-models.mjs +390 -0
- package/scripts/verify-providers.mjs +255 -0
- package/scripts/verify-qoder-provider.mjs +1020 -0
- package/scripts/verify-rotation.mjs +308 -0
- package/scripts/verify-trae-model-catalog.mjs +284 -0
- 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
|
+
}
|