thincoder 0.12.52 → 0.12.53

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/src/mcp.mjs CHANGED
@@ -7,29 +7,159 @@ import { stdioTransport } from "./mcp/transport-stdio.mjs"
7
7
  import { httpTransport } from "./mcp/transport-http.mjs"
8
8
  import { wsTransport } from "./mcp/transport-ws.mjs"
9
9
 
10
+
11
+ /** Race a pending MCP request against an abort signal — a hung MCP server must not
12
+ * hold the turn hostage. signal absent → passthrough. */
13
+ async function sendWithSignal(promise, signal) {
14
+ if (!signal) return promise
15
+ return new Promise((resolve, reject) => {
16
+ const onAbort = () => {
17
+ const e = new DOMException("The operation was aborted", "AbortError")
18
+ e.reason = signal.reason
19
+ reject(e)
20
+ }
21
+ if (signal.aborted) return onAbort()
22
+ signal.addEventListener("abort", onAbort, { once: true })
23
+ promise.then(
24
+ (v) => { signal.removeEventListener("abort", onAbort); resolve(v) },
25
+ (e) => { signal.removeEventListener("abort", onAbort); reject(e) },
26
+ )
27
+ })
28
+ }
29
+
10
30
  // ---- MCP lifecycle ----
11
31
 
12
- function buildTools(mcpTools, transport, config) {
32
+ /** 2026-08-31 MCP 会诊 P5:CLI session 注册表(serverName → session)。
33
+ * session.state.transport 可变(重连替换);buildTools 的 execute 动态取
34
+ * session.state.transport——server 崩溃后无需重建 agent.tools 即自愈。 */
35
+ const _sessions = new Map()
36
+ /** 退避重连进行中(serverName → promise)——与 vscode 语义对齐;延迟表见 _mcpHooks。 */
37
+ const _reconnecting = new Map()
38
+
39
+ /** 2026-08-31 MCP 会诊 P5:测试钩子(退避延迟可替换,惯例同 rate.mjs _rateHooks)。
40
+ * scheduleReconnect 与 ensureAlive 读这里的 delay/reconnectDelays——测试可注入微秒级延迟。 */
41
+ export const _mcpHooks = {
42
+ delay: (ms) => new Promise((r) => setTimeout(r, ms)),
43
+ reconnectDelays: [1000, 2000, 4000, 8000],
44
+ }
45
+
46
+ /** 按 config 创建并完成握手的 transport(findTransportConfig 与 vscode 对齐)。 */
47
+ async function createConnectedTransport(config, serverName) {
48
+ let transport
49
+ if (config.wsUrl) {
50
+ transport = wsTransport(config.wsUrl, config.headers ?? {})
51
+ await transport.connect()
52
+ } else if (config.url) {
53
+ transport = httpTransport(config.url, config.headers ?? {})
54
+ try {
55
+ await transport.openSSE()
56
+ } catch {
57
+ // Server doesn't support GET SSE — degrade to pure Streamable HTTP POST mode
58
+ }
59
+ } else {
60
+ transport = stdioTransport(config.command, config.args ?? [], config.env)
61
+ }
62
+ const mcpTools = await doInitialize(transport, serverName)
63
+ return { transport, mcpTools }
64
+ }
65
+
66
+ /** onDead → 后台退避重连;成功替换 session.state.transport(tools 闭包动态引用,
67
+ * agent.tools 无需重建);失败静默(下次 execute 前置检查再试)。 */
68
+ function scheduleReconnect(name, config) {
69
+ if (_reconnecting.has(name)) return _reconnecting.get(name)
70
+ const p = (async () => {
71
+ let lastErr
72
+ for (const delayMs of _mcpHooks.reconnectDelays) {
73
+ await _mcpHooks.delay(delayMs)
74
+ try {
75
+ const { transport } = await createConnectedTransport(config, name)
76
+ const session = _sessions.get(name)
77
+ if (!session || session.closed) { transport.close(); return false }
78
+ attachSession(session, transport)
79
+ return true
80
+ } catch (error) {
81
+ lastErr = error
82
+ }
83
+ }
84
+ console.error(`[mcp] ${name} reconnect failed after ${_mcpHooks.reconnectDelays.length} attempts: ${lastErr?.message ?? lastErr}`)
85
+ return false
86
+ })().finally(() => _reconnecting.delete(name))
87
+ _reconnecting.set(name, p)
88
+ return p
89
+ }
90
+
91
+ /** 给 session 绑定一个新 transport(建连 onDead 钩子 → 自愈链)。 */
92
+ function attachSession(session, transport) {
93
+ session.state.transport = transport
94
+ transport.onDead?.(() => {
95
+ if (session.state.transport !== transport) return // 已再替换,旧钩子作废
96
+ scheduleReconnect(session.config.name ?? session.config.command ?? session.config.url ?? session.config.wsUrl, session.config)
97
+ })
98
+ }
99
+
100
+ function buildTools(mcpTools, session, config) {
13
101
  const prefix = config.name ? `${config.name}_` : "mcp_"
14
- return mcpTools.map((t) => ({
15
- name: sanitizeToolName(prefix + t.name),
16
- description: t.description ?? `MCP tool: ${t.name}`,
17
- parameters: t.inputSchema ?? { type: "object", properties: {} },
18
- readonly: false,
19
- async execute(args) {
20
- const resp = await transport.send("tools/call", { name: t.name, arguments: args })
21
- if (resp.error) throw new Error(`MCP tool "${t.name}": ${resp.error.message}`)
22
- const content = resp.result?.content ?? []
23
- return content
24
- .map((c) => (c.type === "text" ? c.text : c.type === "resource" ? `[resource: ${c.resource?.uri}]` : JSON.stringify(c)))
25
- .join("\n") || "(no output)"
26
- },
27
- _mcpTransport: transport,
28
- _mcpName: config.name,
29
- }))
102
+ // 2026-08-31 MCP 会诊 P6:sanitize 碰撞/空名防御 + schema/description 类型守卫 + 输出防御
103
+ const seen = new Set()
104
+ const out = []
105
+ for (const t of mcpTools) {
106
+ const rawName = sanitizeToolName(prefix + t.name)
107
+ if (!rawName || rawName === "mcp_") continue
108
+ let name = rawName
109
+ for (let n = 2; seen.has(name); n++) name = `${rawName}_${n}`
110
+ seen.add(name)
111
+ out.push({
112
+ name,
113
+ description: typeof t.description === "string" ? t.description : `MCP tool: ${t.name}`,
114
+ parameters: (t.inputSchema && typeof t.inputSchema === "object") ? t.inputSchema : { type: "object", properties: {} },
115
+ readonly: false,
116
+ async execute(args, ctx) {
117
+ // 2026-08-31 会诊 #11:上层 signal 中断 + send 第 3 参底层取消(pending 即刻作废)
118
+ // P5:执行前动态取 session.transport——死亡时等重连 promise/触发一次重连再试
119
+ const transport = await ensureAlive(session)
120
+ const send = transport.send("tools/call", { name: t.name, arguments: args }, ctx?.signal)
121
+ const resp = await sendWithSignal(send, ctx?.signal)
122
+ if (resp.error) throw new Error(`MCP tool "${t.name}": ${resp.error.message}`)
123
+ if (resp.result?.isError) throw new Error(`MCP tool "${t.name}": ${extractMcpText(resp.result.content) || "(server reported an error)"}`)
124
+ return truncateMcpOutput(extractMcpText(resp.result?.content ?? [])) || "(no output)"
125
+ },
126
+ _mcpTransport: session.state.transport,
127
+ _mcpName: config.name,
128
+ })
129
+ }
130
+ return out
30
131
  }
31
132
 
32
- async function doInitialize(transport, name) {
133
+ /** P5:拿到活的 transport——死连接等待/触发重连;耗尽后抛错(工具错误透给模型)。 */
134
+ async function ensureAlive(session) {
135
+ const t = session.state.transport
136
+ if (t?.isAlive?.()) return t
137
+ const name = session.config.name ?? session.config.command ?? session.config.url ?? session.config.wsUrl
138
+ let reconnect = _reconnecting.get(name)
139
+ if (!reconnect) reconnect = scheduleReconnect(name, session.config)
140
+ const ok = await reconnect
141
+ if (!ok || !session.state.transport?.isAlive?.()) {
142
+ throw new Error(`MCP server "${name}" is unavailable (reconnect failed)`)
143
+ }
144
+ return session.state.transport
145
+ }
146
+
147
+ /** MCP content 数组 → 文本(非数组/元素非对象防御)。 */
148
+ function extractMcpText(content) {
149
+ if (!Array.isArray(content)) return typeof content === "string" ? content : JSON.stringify(content)
150
+ return content
151
+ .filter((c) => c && typeof c === "object")
152
+ .map((c) => (c.type === "text" ? c.text : c.type === "resource" ? `[resource: ${c.resource?.uri}]` : JSON.stringify(c)))
153
+ .join("\n")
154
+ }
155
+
156
+ /** 输出截断(32KB 上限,防 server 回 10MB 撑爆上下文)。 */
157
+ function truncateMcpOutput(text) {
158
+ if (text.length <= 32_000) return text
159
+ return text.slice(0, 32_000) + "\n[… truncated: " + (text.length - 32_000) + " chars omitted]"
160
+ }
161
+
162
+ async function doInitialize(transport, _name) {
33
163
  const initResp = await withTimeout(
34
164
  transport.send("initialize", {
35
165
  protocolVersion: "2024-11-05",
@@ -41,68 +171,77 @@ async function doInitialize(transport, name) {
41
171
  if (initResp.error) throw new Error(`initialize error: ${initResp.error.message}`)
42
172
  transport.notify?.("notifications/initialized", {})
43
173
 
44
- const toolsResp = await transport.send("tools/list", {})
45
- if (toolsResp.error) throw new Error(`tools/list failed: ${toolsResp.error.message}`)
46
- return toolsResp.result?.tools ?? []
174
+ // 2026-08-31 MCP 会诊 P6:tools/list 分页被忽略(nextCursor 多页工具静默丢失)——
175
+ // 循环跟随 cursor 直到 server 不再返回(上限 20 页防死循环)。
176
+ const tools = []
177
+ let cursor
178
+ for (let page = 0; page < 20; page++) {
179
+ const toolsResp = await transport.send("tools/list", cursor ? { cursor } : {})
180
+ if (toolsResp.error) throw new Error(`tools/list failed: ${toolsResp.error.message}`)
181
+ tools.push(...(toolsResp.result?.tools ?? []))
182
+ cursor = toolsResp.result?.nextCursor
183
+ if (!cursor) break
184
+ }
185
+ return tools
47
186
  }
48
187
 
49
- /** Connect to an MCP server (stdio/http/ws), initialize, and return built tool wrappers */
188
+ /** Connect to an MCP server (stdio/http/ws), initialize, and return built tool wrappers.
189
+ * 2026-08-31 MCP 会诊 P5:session 幂等——同 name 活连接复用(make-agent 每进程一次,
190
+ * cmd-mcp 重复 add 同一 server 不会双实例),崩溃后下次调用/execute 自愈。 */
50
191
  export async function connectMcpServer(config) {
51
- if (config.wsUrl) {
52
- const transport = wsTransport(config.wsUrl, config.headers ?? {})
53
- try {
54
- await transport.connect()
55
- const mcpTools = await doInitialize(transport, config.name ?? config.wsUrl)
56
- return buildTools(mcpTools, transport, config)
57
- } catch (error) {
58
- transport.close()
59
- throw error
60
- }
61
- }
192
+ if (!config || (!config.command && !config.url && !config.wsUrl))
193
+ throw new Error(`MCP server "${config?.name ?? ""}": needs either 'wsUrl' (websocket), 'command' (stdio), or 'url' (http)`)
194
+ const name = config.name ?? config.command ?? config.url ?? config.wsUrl
195
+ const configFingerprint = JSON.stringify([config.command ?? null, config.args ?? null, config.url ?? null, config.wsUrl ?? null, config.env ?? null, config.headers ?? null])
62
196
 
63
- if (config.url) {
64
- const transport = httpTransport(config.url, config.headers ?? {})
65
- try {
66
- await transport.openSSE()
67
- } catch {
68
- // Server doesn't support GET (pure Streamable HTTP POST): degrade to no-SSE mode
197
+ const existing = _sessions.get(name)
198
+ if (existing && !existing.closed) {
199
+ const sameConfig = existing.configFingerprint === configFingerprint
200
+ if (sameConfig && existing.state.transport?.isAlive?.()) {
201
+ return existing.state.tools
69
202
  }
70
- try {
71
- const mcpTools = await doInitialize(transport, config.name ?? config.url)
72
- return buildTools(mcpTools, transport, config)
73
- } catch (error) {
74
- transport.close()
75
- throw error
203
+ if (!sameConfig) {
204
+ // config 变更:主动关闭旧连接(不触发 onDead 重连)并丢弃 session
205
+ existing.closed = true
206
+ try { existing.state.transport?.close() } catch { /* ignore */ }
207
+ _sessions.delete(name)
76
208
  }
77
209
  }
78
210
 
79
- if (config.command) {
80
- const transport = stdioTransport(config.command, config.args ?? [], config.env)
81
- try {
82
- const mcpTools = await doInitialize(transport, config.name ?? config.command)
83
- return buildTools(mcpTools, transport, config)
84
- } catch (error) {
85
- transport.close()
86
- throw error
87
- }
211
+ const { transport, mcpTools } = await createConnectedTransport(config, name)
212
+ const session = {
213
+ config, configFingerprint,
214
+ state: { transport, tools: null },
215
+ closed: false,
88
216
  }
89
-
90
- throw new Error(`MCP server "${config.name}": needs either 'wsUrl' (websocket), 'command' (stdio), or 'url' (http)`)
217
+ session.state.tools = buildTools(mcpTools, session, config)
218
+ attachSession(session, transport)
219
+ _sessions.set(name, session)
220
+ return session.state.tools
91
221
  }
92
222
 
93
223
  /** Close all MCP transport connections on an agent's tools */
94
224
  export function closeAllMcp(agent) {
95
225
  for (const t of agent.tools) {
96
- if (t._mcpTransport) t._mcpTransport.close()
226
+ if (t._mcpTransport) closeSession(t._mcpName)
97
227
  }
98
228
  }
99
229
 
230
+ /** 主动关闭 session(不触发 onDead 重连):transport close + 登记关闭标记 + 清注册表。 */
231
+ function closeSession(name) {
232
+ const session = _sessions.get(name)
233
+ if (!session) return
234
+ session.closed = true
235
+ try { session.state.transport?.close() } catch { /* ignore */ }
236
+ _sessions.delete(name)
237
+ }
238
+
100
239
  /** Remove MCP tools belonging to a specific server from the agent's tool list */
101
240
  export function removeMcpTools(agent, serverName) {
102
241
  const keep = []
103
242
  for (const t of agent.tools) {
104
243
  if (t._mcpName === serverName) {
105
- if (t._mcpTransport) t._mcpTransport.close()
244
+ closeSession(serverName)
106
245
  } else {
107
246
  keep.push(t)
108
247
  }
@@ -19,6 +19,7 @@ UI & interface design:
19
19
  - A value with a FIXED set of choices (enum, level, mode, flag) must be OPTIONS — picker / menu / choices / buttons. Never free-text input.
20
20
  - Free-text for a discrete value forces the user to guess the exact spelling, needs manual validation, and fails silently on typos. This has happened repeatedly (e.g. reasoning-effort levels typed by hand).
21
21
  - Free-text is correct ONLY when the input is genuinely open-ended (a name, a path, a message).
22
+ - **用户约定执行纪律(2026-08-31,两次违约教训)**:用户对交互/行为的约定以用户原话为准——实现时逐字对照,不得用"等效实现"替换约定本身(已发生:滚动→点击翻窗、滚动到头自动加载→PgUp 键触发)。已确认约定的简化/降级必须提前上报,不得包装成"升级路径"交付。注释里的 parity with X / 对齐 X 只描述来源,不代表 X 就是正确语义——以用户约定为唯一判据,实现后真机验证用户原话的每个承诺点。
22
23
 
23
24
  Tool routing — use the dedicated tool, not bash:
24
25
  - **git operations** → `git` tool (action=status/diff/log/show/add/commit/push/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick; `workdir` for sub-repos). Never run git via bash.
@@ -29,6 +30,48 @@ Tool routing — use the dedicated tool, not bash:
29
30
  - Each tool's description carries a "Route to X instead of bash" mapping.
30
31
  - **bash IS correct for**: package-manager/CLI subprocesses (`npm`/`vsce`/`ovsx`, git-CLI-only flags the tool lacks), servers, interactive/TTY programs, and one-off shell pipelines no dedicated tool expresses.
31
32
 
33
+ **Full tool routing table** (one row per tool; "alias" = what bash/pipes people reach for instead):
34
+ | Tool | Use it for | Not (use dedicated tool instead of) |
35
+ |---|---|---|
36
+ | `read` | read a text file (paged / hashes=true for editing) | `cat`, `type`, `node -e fs.readFileSync` |
37
+ | `write` | create/overwrite a file | `echo >`, `printf >`, heredocs |
38
+ | `edit` | exact-string single replacement | `sed -i`, `perl -p` |
39
+ | `hashline_edit` | line-targeted edit by content hash (whitespace/encoding drift proof) | `sed` by line number |
40
+ | `insert_after` | add a block after a known line / regex-anchored | `sed` insertion, line-number surgery |
41
+ | `apply_patch` | multi-file unified diff (all-or-nothing) | `git apply` by hand, patch gymnastics |
42
+ | `delete` | remove a single file (tracked files need force) | `del`, `rm` |
43
+ | `file_ops` | move / copy / rename files or dirs | `mv`, `cp`, `ren` |
44
+ | `ls` | list directory contents (typed, sized) | `dir`, `ls` in bash |
45
+ | `glob` | find files by pattern | `find`, `dir /b /s`, shell globs |
46
+ | `grep` | regex search file contents (context supported) | `findstr`, `grep -rn`, `rg` |
47
+ | `tree` | directory tree overview | `tree`, `find .` |
48
+ | `repo_outline` | module dependency / symbol map | ad-hoc scripts |
49
+ | `code_search` | natural-language code search | grep gymnastics |
50
+ | `doc_search` | search project docs (design/AGENTS) | `findstr` in docs |
51
+ | `read_image` | view an image (vision models) | external viewers |
52
+ | `execute` | run JS inline / scriptFile (+ nodeArgs for `node --test`/`--check`) | `bash node -e`, `node <script>` via bash |
53
+ | `bash` | npm/vsce/CLI subprocess, servers, TTY programs, one-off pipelines no tool expresses | always; see allowed list above |
54
+ | `git` | ALL git ops (status/diff/log/show/add/commit/push/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick/ls-remote) | `git` in bash |
55
+ | `process` | list running processes | `tasklist`, `ps`, `wmic` |
56
+ | `get_current_time` | current date/time | `date` |
57
+ | `timer` | thinking budget / wait reminder | `sleep`, `timeout` (for real waits) |
58
+ | `lint` | lint / syntax check after edits (full=true for cascade) | ad-hoc eslint runs |
59
+ | `verify` | pre-completion self-check (syntax/tests/diff/checklist) | manual diff/test runs |
60
+ | `task` / `checklist` | session-level tasks / persistent requirements tracking | README-style todo lists |
61
+ | `goal` | long-running autonomous goal (machine-checkable criteria) | prose promises |
62
+ | `plan` / `eng` | plan mode / engineering mode entry-exit | none (mode transitions only here) |
63
+ | `skill` | load project skills (.thincoder/skills/) | re-inventing workflows |
64
+ | `question` | ask the user (ambiguity, design decisions) | guessing |
65
+ | `advisor` | independent review of code/design | self-review only |
66
+ | `subagent` | delegate subtasks to isolated contexts | inlining exploration |
67
+ | `consult_start` / `consult_check` / `consult_stop` | parallel multi-model consultation | single-model guessing |
68
+ | `escalate` | fly in a stronger model for hard implementation | burning attempts |
69
+ | `memory_put` / `memory_search` | long-term knowledge save/search | session notes |
70
+ | `checkpoint` | git snapshots / rewind safety | manual branches |
71
+ | `fetch` | fetch a URL (explicit proxy per target; config proxy NOT auto-applied) | `curl` |
72
+ | `websearch` | Bing search (weak for technical; MCP search tool first) | `curl` scraping |
73
+ | `glm-websearch_web_search_prime` | technical lookups (primary when available) | Bing fallback loop |
74
+
32
75
  Review discipline (standard mode only — engineering mode has its own review timing rules):
33
76
  - **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context).
34
77
  - **After each advisor review, reply with a response table** — exact header `| # | Action | Detail |` (the runtime extracts this header; keep it verbatim). One row per issue; `#` = the advisor's issue number (`Orig#` on rounds 2+).
@@ -6,9 +6,21 @@
6
6
 
7
7
  import { specForModel } from "../config.mjs"
8
8
  import { proxyFetch } from "../proxy.mjs"
9
+ import { requestWithRetry } from "./retry.mjs"
9
10
 
10
11
  const ANTHROPIC_VERSION = "2023-06-01"
11
12
 
13
+ /** OpenAI 语义 tool_choice → Anthropic tool_choice(2026-08-31 能力层)。
14
+ * 传入值形态:undefined | "auto" | "required" | "none" | {type:"function",function:{name}} */
15
+ function mapToolChoice(choice) {
16
+ if (choice === "auto") return { type: "auto" }
17
+ if (choice === "required") return { type: "any" }
18
+ if (choice === "none") return { type: "none" }
19
+ if (choice && typeof choice === "object" && choice.function?.name) return { type: "tool", name: choice.function.name }
20
+ throw new Error(`Invalid tool_choice for Anthropic format: ${JSON.stringify(choice).slice(0, 120)}`)
21
+ }
22
+
23
+
12
24
  /** Convert OpenAI-format tools to Anthropic format */
13
25
  export function normalizeTools(tools) {
14
26
  return (tools || []).map((t) => ({
@@ -18,8 +30,11 @@ export function normalizeTools(tools) {
18
30
  }))
19
31
  }
20
32
 
21
- /** Build and send an Anthropic chat request. Returns the same shape as core.mjs chat. */
22
- export async function chat(provider, { messages, tools, onToken, onReasoning, signal }) {
33
+ /** Build and send an Anthropic chat request. Returns the same shape as core.mjs chat.
34
+ * 2026-08-31 会诊 #6:接入 rateGate/recordRate + 429 Retry-After 单次重试
35
+ * (原实现完全绕过 TPM/RPM 闸门与记账——用户配了 tpm 以为受控实际不受控)。
36
+ * 注:5xx/网络退避重试未与 OpenAI 格式对齐(三 transport 共用那步工作量大,见报告)。 */
37
+ export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, parallelToolCalls }) {
23
38
  // Extract system message(s) — Anthropic uses top-level `system` field
24
39
  const systemMessages = []
25
40
  const chatMessages = []
@@ -40,6 +55,9 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
40
55
  }
41
56
  if (systemMessages.length > 0) body.system = systemMessages.join("\n\n")
42
57
  if (tools?.length) body.tools = tools
58
+ // 2026-08-31:tool_choice 能力层——OpenAI 语义映射到 Anthropic tool_choice
59
+ // (auto→{type:"auto"} / required→{type:"any"} / none→{type:"none"} / 具体函数→{type:"tool",name})
60
+ if (toolChoice !== undefined) body.tool_choice = mapToolChoice(toolChoice)
43
61
  if (provider.temperature != null) {
44
62
  let t = provider.temperature
45
63
  // Anthropic API hard limit is 0-1; models without a declared tempRange still get clamped
@@ -59,23 +77,37 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
59
77
  // Active signal check
60
78
  if (signal?.aborted) throw Object.assign(new DOMException("Aborted", "AbortError"), { reason: signal.reason })
61
79
 
62
- const response = await proxyFetch(`${provider.baseURL}/messages`, {
63
- method: "POST",
64
- headers,
65
- body: JSON.stringify(body),
66
- signal: signal
67
- ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
68
- : AbortSignal.timeout(FETCH_TIMEOUT_MS),
69
- }, provider.proxyUri)
70
-
71
- if (!response.ok) {
72
- const text = await response.text().catch(() => "")
73
- throw new Error(`Anthropic API error ${response.status}: ${text}`)
74
- }
80
+ // 会诊 #6:TPM/RPM 闸门 + 记账(rate.mjs 与 OpenAI 格式共用同一窗口)
81
+ const { rateGate, recordRate } = await import("./rate.mjs")
82
+ const estimated = await (async () => {
83
+ const { estimateRequestTokens } = await import("./rate.mjs")
84
+ return estimateRequestTokens(body)
85
+ })()
86
+ await rateGate(provider, estimated, onWait, signal)
87
+
88
+ // 2026-08-31:4xx/5xx/网络与 OpenAI 格式统一退避重试链(原仅 429 Retry-After 单次重试,
89
+ // 5xx 直接抛——DeepSeek/Claude 排队 503 时其他格式可自动恢复,这里语义割裂)
90
+ const response = await requestWithRetry(
91
+ () => proxyFetch(`${provider.baseURL}/messages`, {
92
+ method: "POST",
93
+ headers,
94
+ body: JSON.stringify(body),
95
+ signal: signal
96
+ ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
97
+ : AbortSignal.timeout(FETCH_TIMEOUT_MS),
98
+ _headerTimeoutMs: FETCH_TIMEOUT_MS,
99
+ _bodyIdleMs: 120_000,
100
+ }, provider.proxyUri),
101
+ { signal, onWait, buildMessage: (status, text) => `Anthropic API error ${status}: ${text}` },
102
+ )
75
103
 
76
104
  const result = await parseAnthropicStream(response, { onToken, onReasoning, signal })
105
+ recordRate(provider, estimated, result.usage)
106
+ return finishAnthropic(result)
107
+ }
77
108
 
78
- // Convert Anthropic usage format to OpenAI-compatible
109
+ /** Convert the parsed stream to the core.mjs result shape (usage → OpenAI-compatible). */
110
+ function finishAnthropic(result) {
79
111
  const usage = result.usage
80
112
  if (usage) {
81
113
  return {
@@ -91,7 +123,6 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
91
123
  toolCalls: result.toolCalls,
92
124
  }
93
125
  }
94
-
95
126
  return { content: result.content, reasoning: result.reasoning, toolCalls: result.toolCalls }
96
127
  }
97
128
 
@@ -157,6 +188,8 @@ async function parseAnthropicStream(response, { onToken, onReasoning, signal })
157
188
  throw e
158
189
  }
159
190
  buffer += decoder.decode(chunk, { stream: true })
191
+ // BOM 剥除(会诊 #12):首个 chunk 可能带 \uFEFF,否则 message_start 事件被静默丢失(含 usage)
192
+ if (buffer.charCodeAt(0) === 0xfeff) buffer = buffer.slice(1)
160
193
  const lines = buffer.split("\n")
161
194
  buffer = lines.pop()
162
195
 
@@ -167,7 +200,7 @@ async function parseAnthropicStream(response, { onToken, onReasoning, signal })
167
200
  currentData = ""
168
201
  } else if (line.startsWith("data: ")) {
169
202
  currentData = line.slice(6).trim()
170
- } else if (line === "") {
203
+ } else if (line === "" || line === "\r") { // CRLF 空行是 "\r"(会诊 #13)
171
204
  if (currentEvent) processEvent(currentEvent, currentData)
172
205
  currentEvent = ""
173
206
  currentData = ""