thincoder 0.12.52 → 0.12.54

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 (58) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +1 -1
  3. package/src/acp.mjs +60 -18
  4. package/src/advisor/run.mjs +9 -11
  5. package/src/agent/dispatch.mjs +38 -13
  6. package/src/agent/setup.mjs +2 -1
  7. package/src/agent.mjs +34 -0
  8. package/src/cli/make-agent.mjs +11 -5
  9. package/src/escape.mjs +43 -8
  10. package/src/git/checkpoint.mjs +32 -6
  11. package/src/mcp/helpers.mjs +14 -5
  12. package/src/mcp/transport-http.mjs +79 -27
  13. package/src/mcp/transport-stdio.mjs +57 -3
  14. package/src/mcp/transport-ws.mjs +46 -12
  15. package/src/mcp.mjs +197 -58
  16. package/src/prompts/discipline.md +44 -1
  17. package/src/provider/anthropic.mjs +51 -18
  18. package/src/provider/core.mjs +163 -36
  19. package/src/provider/google.mjs +41 -15
  20. package/src/provider/rate.mjs +5 -0
  21. package/src/provider/responses.mjs +498 -0
  22. package/src/provider/retry.mjs +125 -0
  23. package/src/provider/sse.mjs +58 -24
  24. package/src/proxy.mjs +36 -6
  25. package/src/session-migrate.mjs +6 -0
  26. package/src/session-slots.mjs +361 -0
  27. package/src/session.mjs +267 -306
  28. package/src/tools/bash.md +2 -2
  29. package/src/tools/execute.md +1 -1
  30. package/src/tools/execute.mjs +3 -3
  31. package/src/tools/fetch.md +1 -0
  32. package/src/tools/file.mjs +136 -11
  33. package/src/tools/git-checkpoint.mjs +143 -0
  34. package/src/tools/git-ext.mjs +173 -0
  35. package/src/tools/git.md +21 -6
  36. package/src/tools/git.mjs +68 -155
  37. package/src/tools/shared.mjs +5 -3
  38. package/src/tools/system.mjs +19 -1
  39. package/src/tools/web.mjs +44 -14
  40. package/src/tools/websearch.md +3 -1
  41. package/src/tui/ansi.mjs +2 -0
  42. package/src/tui/cmd-new.mjs +6 -6
  43. package/src/tui/cmd-restore.mjs +27 -6
  44. package/src/tui/cmd-session.mjs +17 -4
  45. package/src/tui/fold-block.mjs +59 -11
  46. package/src/tui/index.mjs +59 -67
  47. package/src/tui/key-handler.mjs +3 -1
  48. package/src/tui/layout.mjs +81 -25
  49. package/src/tui/mouse.mjs +86 -8
  50. package/src/tui/render-conversation.mjs +260 -214
  51. package/src/tui/render-frame.mjs +22 -6
  52. package/src/tui/render-loop.mjs +11 -1
  53. package/src/tui/startup.mjs +1 -1
  54. package/src/tui/subagent-blocks.mjs +5 -1
  55. package/src/tui/subagent-panel.mjs +81 -0
  56. package/src/tui/tool-args.mjs +4 -0
  57. package/src/tui/tool-events.mjs +1 -1
  58. package/src/tui/tui-lifecycle.mjs +45 -0
@@ -19,9 +19,10 @@ 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
- - **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.
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/ls-remote/clone/init/rebase/remote/clean/switch/apply/worktree/archive/blame/mv; `workdir` for sub-repos). Never run git via bash.
25
26
  - **JavaScript** → `execute` (inline code; or `scriptFile`+`nodeArgs` for `node <file>` / `node --test` / `node --check`). Never `bash node -e`.
26
27
  - **File reads/searches** → `read` / `grep` / `ls` / `glob` — never `cat` / `type` / `findstr` / `dir` / shell-grep.
27
28
  - **File mutations** → `write` / `edit` / `apply_patch` / `hashline_edit` / `insert_after` / `file_ops` (move/copy/rename) / `delete`.
@@ -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/clone/init/rebase/remote/clean/switch/apply/worktree/archive/blame/mv) | `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 = ""
@@ -17,6 +17,27 @@ import {
17
17
 
18
18
  const FETCH_TIMEOUT_MS = 600_000
19
19
 
20
+ /** 可中断 sleep(2026-08-31 会诊 #5):退避/Retry-After/overload 等待期间 Ctrl+C 应
21
+ * 立即生效——原来最长睡 60s 无响应。内部走 _rateHooks.sleep(测试替换点)。 */
22
+ function abortDOM(signal) {
23
+ const e = new DOMException("The operation was aborted", "AbortError")
24
+ e.reason = signal.reason
25
+ return e
26
+ }
27
+
28
+ async function sleepInterruptible(ms, signal) {
29
+ if (!signal) return _rateHooks.sleep(ms)
30
+ if (signal.aborted) throw abortDOM(signal)
31
+ return new Promise((resolve, reject) => {
32
+ const onAbort = () => { signal.removeEventListener("abort", onAbort); reject(abortDOM(signal)) }
33
+ signal.addEventListener("abort", onAbort, { once: true })
34
+ _rateHooks.sleep(ms).then(
35
+ () => { signal.removeEventListener("abort", onAbort); resolve() },
36
+ (e) => { signal.removeEventListener("abort", onAbort); reject(e) },
37
+ )
38
+ })
39
+ }
40
+
20
41
  /** Create a validated provider config object from raw config */
21
42
  export function createProvider(config) {
22
43
  if (!config?.baseURL) throw new Error("provider config: baseURL is required — configure providers in ~/.thincoder/config.json")
@@ -40,11 +61,12 @@ export function createProvider(config) {
40
61
  }
41
62
 
42
63
  /** Send a streaming chat completion request with automatic continuation on truncation */
43
- export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, streamRules, firedPatterns }) {
64
+ export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, streamRules, firedPatterns, toolChoice, parallelToolCalls }) {
44
65
  // Sanitize BEFORE format dispatch — image poisoning bricks anthropic/google sessions
45
66
  // the same way it bricks OpenAI-format ones (all raster-only).
46
67
  const spec = specForModel(provider.model)
47
68
  messages = stripImagesForTextModel(messages, spec)
69
+ const _debugBeforeLen = process.env.THIN_DEBUG_BODY ? JSON.stringify(messages).length : 0
48
70
 
49
71
  // Format dispatch: delegate to non-OpenAI transports
50
72
  if (provider.format === "anthropic") {
@@ -53,7 +75,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
53
75
  const result = await anthropicChat(provider, {
54
76
  messages,
55
77
  tools: tools?.length ? normalizeTools(tools) : null,
56
- onToken, onReasoning, signal,
78
+ onToken, onReasoning, onWait, signal, toolChoice,
57
79
  })
58
80
  return result
59
81
  }
@@ -63,16 +85,31 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
63
85
  const result = await geminiChat(provider, {
64
86
  messages,
65
87
  tools: tools?.length ? normalizeTools(tools) : null,
66
- onToken, onReasoning, signal,
88
+ onToken, onReasoning, onWait, signal, toolChoice,
67
89
  })
68
90
  return result
69
91
  }
92
+ if (provider.format === "responses") {
93
+ // 2026-08-31:Responses API transport(PROVIDER.md §13)——双轨链在 transport 内部
94
+ // 自行管理(provider._responsesChain),agent 层零改动。
95
+ // round3 #3:配对归一化必须在此分派前(压缩/中断遗留的孤儿 tool 消息发向严格服务端会 400)
96
+ messages = normalizeToolPairing(messages)
97
+ const { chat: responsesChat } = await import("./responses.mjs")
98
+ return responsesChat(provider, {
99
+ messages,
100
+ tools,
101
+ onToken, onReasoning, onWait, signal, toolChoice,
102
+ })
103
+ }
70
104
 
71
105
  messages = normalizeToolPairing(messages)
72
106
  // 中和服务端的非标二次转义:会话里若出现字面 "\x"/"\u"(如讨论转义、grep 到含
73
107
  // 转义的代码),Kimi 等会把它们当 hex escape 再解析 → "unexpected end of hex escape" 400。
74
108
  // 发送前统一 double 掉会形成非法转义的序列(合法 \xNN/\uNNNN 不受影响)。
75
109
  messages = escapeMessages(messages)
110
+ if (process.env.THIN_DEBUG_BODY) {
111
+ console.error(`[debug-body] escape: ${_debugBeforeLen} -> ${JSON.stringify(messages).length} chars, ${messages.length} msgs (provider=${provider.name}, model=${provider.model})`)
112
+ }
76
113
  // Compile string-pattern rules to RegExp at call time
77
114
  const rules = compileStreamRules(streamRules)
78
115
  const body = {
@@ -110,6 +147,10 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
110
147
  const enableThinking = resolveEnableThinking(provider, spec)
111
148
  if (enableThinking !== undefined) body.enable_thinking = enableThinking
112
149
  if (tools?.length) body.tools = tools
150
+ // 2026-08-31:tool_choice 能力层(透传 OpenAI 语义);
151
+ // parallel_tool_calls 仅显式 true 时发送(默认不发=不改变现有行为)
152
+ if (toolChoice !== undefined) body.tool_choice = toolChoice
153
+ if (parallelToolCalls === true) body.parallel_tool_calls = true
113
154
 
114
155
  const estimated = estimateRequestTokens(body)
115
156
  await rateGate(provider, estimated, onWait, signal)
@@ -118,16 +159,19 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
118
159
  const result = await readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns })
119
160
  recordRate(provider, estimated, result.usage)
120
161
 
121
- // Stream rule triggered or user interrupted mid-generation — return partial result
162
+ // Stream rule triggered, user interrupted, or network partial — return immediately.
163
+ // 2026-08-31 会诊 #2:partial(网络错误中断但已有内容)与 interrupted 同级透传,
164
+ // 不再让上层把已收内容当整轮失败重试(重试从零开始浪费已流出的成本)。
122
165
  if (result.ruleTriggered) return result
123
166
  if (result.interrupted) return result
167
+ if (result.partial) return result
124
168
 
125
169
  // Retry on transient server overload (DeepSeek: insufficient_system_resource)
126
170
  const MAX_OVERLOAD_RETRIES = 1
127
171
  for (let r = 0; result.finishReason === "insufficient_system_resource" && r <= MAX_OVERLOAD_RETRIES; r++) {
128
172
  if (r > 0) {
129
173
  onWait?.({ phase: "overloaded", seconds: 3 })
130
- await _rateHooks.sleep(3000)
174
+ await sleepInterruptible(3000, signal)
131
175
  }
132
176
  const retryResponse = await requestWithRetry(provider, body, signal, onWait)
133
177
  const retryResult = await readSSE(retryResponse, { onToken, onReasoning })
@@ -136,13 +180,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
136
180
  // Merge any partial content from the failed attempt (streaming already showed it)
137
181
  result.content += retryResult.content
138
182
  result.reasoning += retryResult.reasoning ?? ""
139
- for (const tc of retryResult.toolCalls ?? []) {
140
- const idx = tc.index ?? result.toolCalls.length
141
- const s = (result.toolCalls[idx] ??= { id: "", name: "", arguments: "" })
142
- if (tc.id) s.id = tc.id
143
- s.name += tc.name ?? ""
144
- s.arguments += tc.arguments ?? ""
145
- }
183
+ mergeRetryToolCalls(result, retryResult.toolCalls)
146
184
  result.finishReason = retryResult.finishReason
147
185
  if (retryResult.usage) result.usage = retryResult.usage
148
186
  break
@@ -172,13 +210,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
172
210
  })
173
211
  result.content += continued.content
174
212
  result.reasoning += continued.reasoning ?? ""
175
- for (const tc of continued.toolCalls ?? []) {
176
- const idx = tc.index ?? result.toolCalls.length
177
- const s = (result.toolCalls[idx] ??= { id: "", name: "", arguments: "" })
178
- if (tc.id) s.id = tc.id
179
- s.name += tc.name ?? ""
180
- s.arguments += tc.arguments ?? ""
181
- }
213
+ mergeRetryToolCalls(result, continued.toolCalls)
182
214
  result.finishReason = continued.finishReason
183
215
  if (continued.usage) {
184
216
  const sum = (k) => (result.usage?.[k] ?? 0) + (continued.usage[k] ?? 0)
@@ -210,28 +242,98 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
210
242
  // their import paths.
211
243
  import { stripImagesForTextModel, normalizeToolPairing } from "./normalize.mjs"
212
244
  export { stripImagesForTextModel, normalizeToolPairing }
245
+ /** Merge tool calls from a retry/continuation into the accumulated result.
246
+ * 2026-08-31 会诊 #7/#17:readSSE 输出的 tc 已 finalize(无 index 字段),
247
+ * 原实现恒 append(重试里 provider 重发完整 tc → tool 名 "get_weatherget_weather"、
248
+ * arguments 重复)。改按 id 定位已有槽位、无 id 才追加;name 只设一次。 */
249
+ function mergeRetryToolCalls(result, toolCalls) {
250
+ for (const tc of toolCalls ?? []) {
251
+ if (!tc) continue
252
+ let s
253
+ if (tc.id) {
254
+ s = result.toolCalls.find((x) => x && x.id === tc.id)
255
+ }
256
+ if (!s) {
257
+ // 无 id(synthetic call_N 在重试间不稳定)或未命中:按 name 找同 slot(重试语义
258
+ // 是"同一批工具调用重新执行",同名合并最稳);仍找不到才追加。
259
+ s = tc.name ? result.toolCalls.find((x) => x && x.name === tc.name) : undefined
260
+ }
261
+ if (!s) {
262
+ s = { id: "", name: "", arguments: "" }
263
+ result.toolCalls.push(s)
264
+ }
265
+ if (tc.id && !s.id) s.id = tc.id
266
+ if (tc.name && !s.name) s.name = tc.name
267
+ s.arguments += tc.arguments ?? ""
268
+ }
269
+ }
270
+
213
271
  /** List available model IDs from the provider's /models endpoint */
214
272
  export async function listModels(provider, { signal } = {}) {
215
- const response = await fetch(`${provider.baseURL}/models`, {
273
+ // 2026-08-31 会诊 #10:与 chat 路径对齐——走 proxyUri、加 15s 超时、JSON 解析兜底
274
+ // (原实现直连 fetch 无超时无代理,慢/被墙域名的 /models 会挂死 UI)
275
+ const url = `${provider.baseURL}/models`
276
+ const opts = {
216
277
  headers: { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey}` },
217
- signal,
218
- })
278
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
279
+ _headerTimeoutMs: 15_000,
280
+ _bodyIdleMs: 15_000,
281
+ }
282
+ const response = await (provider.proxyUri ? proxyFetch(url, opts, provider.proxyUri) : fetch(url, opts))
219
283
  if (!response.ok) {
220
284
  const text = await response.text().catch(() => "")
221
285
  throw new Error(`GET /models failed ${response.status}: ${text}`)
222
286
  }
223
- const data = await response.json()
224
- return (data.data ?? []).map((m) => m.id).filter(Boolean).sort()
287
+ const data = await response.json().catch(() => null)
288
+ return (data?.data ?? []).map((m) => m.id).filter(Boolean).sort()
225
289
  }
226
290
 
227
291
  async function requestWithRetry(provider, body, signal, onWait) {
292
+ // THIN_DEBUG_BODY=1:发送前诊断——复现网关侧 "unexpected end of hex escape" 400 时
293
+ // 定位真实载荷里的毒序列(2026-08-31 slot 3 deepseek-v4-flash)。模拟网关最宽松的
294
+ // 爆炸条件:任何字面 "\u"/"\x" 后不足位(不看前置反斜杠)。
295
+ if (process.env.THIN_DEBUG_BODY) {
296
+ try {
297
+ const msgs = body?.messages ?? []
298
+ const raw = JSON.stringify(body)
299
+ const hits = []
300
+ for (let i = 0; i < msgs.length; i++) {
301
+ const m = msgs[i] ?? {}
302
+ const fields = []
303
+ if (typeof m.content === "string") fields.push(["content", m.content])
304
+ else if (Array.isArray(m.content)) m.content.forEach((p, pi) => { if (p && typeof p.text === "string") fields.push([`content[${pi}]`, p.text]) })
305
+ if (typeof m.reasoning_content === "string") fields.push(["reasoning_content", m.reasoning_content])
306
+ if (Array.isArray(m.tool_calls)) m.tool_calls.forEach((tc, ti) => { if (tc && typeof tc.arguments === "string") fields.push([`tool_calls[${ti}].arguments`, tc.arguments]) })
307
+ if (typeof m.name === "string") fields.push(["name", m.name])
308
+ for (const [f, t] of fields) {
309
+ const re = /\\[xu]/g
310
+ let mm
311
+ while ((mm = re.exec(t))) {
312
+ const c = t[mm.index + 1]
313
+ const need = c === "u" ? 4 : 2
314
+ const after = t.slice(mm.index + 2, mm.index + 2 + need)
315
+ if (!new RegExp(`^[0-9a-fA-F]{${need}}$`).test(after)) {
316
+ hits.push({ i, role: m.role, field: f, ctx: t.slice(Math.max(0, mm.index - 40), mm.index + 12) })
317
+ }
318
+ }
319
+ }
320
+ }
321
+ console.error(`[debug-body] messages=${msgs.length} bodyLen=${raw.length} suspicious=${hits.length}`)
322
+ for (const h of hits.slice(0, 20)) console.error("[debug-body] hit", JSON.stringify(h))
323
+ if (!hits.length && msgs[1151]) {
324
+ console.error("[debug-body] no suspicious hit; messages[1151] =", JSON.stringify({ role: msgs[1151].role, contentLen: msgs[1151].content?.length, contentHead: String(msgs[1151].content).slice(0, 150) }))
325
+ }
326
+ } catch (e) {
327
+ console.error("[debug-body] diag failed:", e.message)
328
+ }
329
+ }
228
330
  let lastError
229
331
  let lastStatus = 0
230
332
  let lastWas429 = false
231
333
  let rateLimitHits = 0
232
334
  const totalAttempts = MAX_RETRIES + 1
233
335
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
234
- if (attempt > 0 && !lastWas429) await _rateHooks.sleep(2 ** (attempt - 1) * 1000)
336
+ if (attempt > 0 && !lastWas429) await sleepInterruptible(2 ** (attempt - 1) * 1000, signal)
235
337
  lastWas429 = false
236
338
 
237
339
  let response
@@ -246,6 +348,10 @@ async function requestWithRetry(provider, body, signal, onWait) {
246
348
  },
247
349
  body: JSON.stringify(body),
248
350
  signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) : AbortSignal.timeout(FETCH_TIMEOUT_MS),
351
+ // 2026-08-31 会诊 #4:代理路径响应头超时对齐直连语义(原 15s 与直连 600s 割裂,
352
+ // DeepSeek 排队 TTFB>15s 即误报)— 仅 _ 前缀内部字段,proxyFetch 消费
353
+ _headerTimeoutMs: FETCH_TIMEOUT_MS,
354
+ _bodyIdleMs: 120_000,
249
355
  }
250
356
  response = provider.proxyUri
251
357
  ? await proxyFetch(url, opts, provider.proxyUri)
@@ -260,10 +366,10 @@ async function requestWithRetry(provider, body, signal, onWait) {
260
366
 
261
367
  const text = await response.text().catch(() => "")
262
368
  let message = `LLM API error ${response.status}: ${text}`
263
- // Kimi has TWO separate platforms with non-interchangeable keys (IK5VGJ):
264
- // Moonshot (api.moonshot.cn, sk-...) vs Kimi For Coding (api.kimi.com/coding/v1, sk-kimi-...).
265
- // A 401 on either endpoint is usually a wrong-platform key — say so instead of a bare 401.
266
- if (response.status === 401) {
369
+ // 401 双平台提示 + 诊断回显(2026-08-31 会诊 #15):
370
+ // Kimi 双平台 key 不互通的提示保留;通用加 baseURL host + key 前 6 位掩码,
371
+ // 帮用户快速分辨"配错平台还是配错账号"。
372
+ if (response.status === 401 || response.status === 403) {
267
373
  const key = String(provider.apiKey ?? "").trim()
268
374
  const base = String(provider.baseURL ?? "").toLowerCase()
269
375
  const kimiCodeKey = /^sk-kimi-/i.test(key)
@@ -271,20 +377,20 @@ async function requestWithRetry(provider, body, signal, onWait) {
271
377
  if (kimiCodeKey || kimiCodeUrl) {
272
378
  message += " — tip: Kimi has two separate platforms with NON-interchangeable API keys: Moonshot (api.moonshot.cn/v1, sk-...) and Kimi For Coding (api.kimi.com/coding/v1, sk-kimi-...). Your key or baseURL looks mismatched — check which platform issued it."
273
379
  }
380
+ const host = (() => { try { return new URL(provider.baseURL).host } catch { return provider.baseURL ?? "(unknown)" } })()
381
+ const masked = key.length > 8 ? key.slice(0, 6) + "…" + key.slice(-4) : (key ? key.slice(0, 4) + "…" : "(empty)")
382
+ message += ` [auth diag: baseURL=${host} key=${masked} status=${response.status}]`
274
383
  }
275
384
  lastStatus = response.status
276
385
  if (isNonRetryableError(response.status, text)) throw new Error(message)
277
386
  if (response.status === 429) {
278
- const retryAfter = Number(response.headers.get("retry-after"))
279
- const waitMs =
280
- Number.isFinite(retryAfter) && retryAfter > 0
281
- ? retryAfter * 1000
282
- : RATE_LIMIT_BACKOFF_MS[Math.min(rateLimitHits++, RATE_LIMIT_BACKOFF_MS.length - 1)]
387
+ const waitMs = parseRetryAfter(response.headers.get("retry-after"), rateLimitHits)
388
+ rateLimitHits++
283
389
  lastError = new Error(message)
284
390
  lastWas429 = true
285
391
  if (attempt < MAX_RETRIES) {
286
392
  onWait?.({ phase: "retry", seconds: Math.ceil(waitMs / 1000) })
287
- await _rateHooks.sleep(waitMs)
393
+ await sleepInterruptible(waitMs, signal)
288
394
  }
289
395
  continue
290
396
  }
@@ -299,7 +405,28 @@ async function requestWithRetry(provider, body, signal, onWait) {
299
405
  : lastStatus >= 500 ? "Server error persisted"
300
406
  : lastStatus > 0 ? "Request failed"
301
407
  : "Network error"
302
- throw new Error(`${verb} after ${totalAttempts} attempts${lastStatus ? ` (${lastStatus})` : ""}: ${lastError?.message ?? "unknown"}`)
408
+ // 会诊 #8:undici "fetch failed" 真因(ENOTFOUND/TLS/DNS/代理)藏在 error.cause
409
+ // 拼进去,全链路同一文案不再掩盖根因
410
+ const causeText = lastError?.cause
411
+ ? ` (${lastError.cause.code ?? lastError.cause.message ?? String(lastError.cause)})`
412
+ : ""
413
+ throw new Error(`${verb} after ${totalAttempts} attempts${lastStatus ? ` (${lastStatus})` : ""}: ${lastError?.message ?? "unknown"}${causeText}`)
414
+ }
415
+
416
+ /** Parse Retry-After: 秒数 or HTTP-date;上限 300s(会诊 #11)— 异常头不得让 CLI 睡数小时。
417
+ * header 缺失/非法时退回指数退避表(rateLimitHits 计数取档)。 */
418
+ export function parseRetryAfter(header, rateLimitHits = 0) {
419
+ const fallback = RATE_LIMIT_BACKOFF_MS[Math.min(rateLimitHits, RATE_LIMIT_BACKOFF_MS.length - 1)]
420
+ if (header == null) return fallback
421
+ let waitMs = 0
422
+ const numeric = Number(header.trim())
423
+ if (Number.isFinite(numeric) && numeric >= 0) waitMs = numeric * 1000
424
+ else {
425
+ const date = Date.parse(header.trim())
426
+ if (Number.isFinite(date)) waitMs = Math.max(0, date - Date.now())
427
+ }
428
+ if (waitMs <= 0) return fallback
429
+ return Math.min(waitMs, 300_000)
303
430
  }
304
431
 
305
432
  /**
@@ -5,6 +5,17 @@
5
5
  */
6
6
 
7
7
  import { proxyFetch } from "../proxy.mjs"
8
+ import { requestWithRetry } from "./retry.mjs"
9
+
10
+ /** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
11
+ function mapFunctionCallingConfig(choice) {
12
+ if (choice === "auto") return { mode: "AUTO" }
13
+ if (choice === "required") return { mode: "ANY" }
14
+ if (choice === "none") return { mode: "NONE" }
15
+ if (choice && typeof choice === "object" && choice.function?.name) return { mode: "ANY", allowedFunctionNames: [choice.function.name] }
16
+ throw new Error(`Invalid tool_choice for Gemini format: ${JSON.stringify(choice).slice(0, 120)}`)
17
+ }
18
+
8
19
 
9
20
  /** Convert OpenAI-format tools to Gemini format */
10
21
  export function normalizeTools(tools) {
@@ -59,8 +70,9 @@ export function convertMessages(messages) {
59
70
  return contents
60
71
  }
61
72
 
62
- /** Build and send a Gemini chat request. Returns the same shape as core.mjs chat. */
63
- export async function chat(provider, { messages, tools, onToken, onReasoning, signal }) {
73
+ /** Build and send a Gemini chat request. Returns the same shape as core.mjs chat.
74
+ * 2026-08-31 会诊 #6:接入 rateGate/recordRate(原实现完全绕过 TPM/RPM 闸门)。 */
75
+ export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice }) {
64
76
  const systemMessages = messages.filter((m) => m.role === "system")
65
77
  const contents = convertMessages(messages)
66
78
 
@@ -83,6 +95,10 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
83
95
  }
84
96
  }
85
97
  if (tools?.length) body.tools = tools
98
+ // 2026-08-31:tool_choice 能力层 → Gemini toolConfig.functionCallingConfig
99
+ if (toolChoice !== undefined) {
100
+ body.toolConfig = { functionCallingConfig: mapFunctionCallingConfig(toolChoice) }
101
+ }
86
102
 
87
103
  const FETCH_TIMEOUT_MS = 600_000
88
104
  // Gemini uses API key as query parameter
@@ -90,21 +106,29 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
90
106
 
91
107
  if (signal?.aborted) throw Object.assign(new DOMException("Aborted", "AbortError"), { reason: signal.reason })
92
108
 
93
- const response = await proxyFetch(url, {
94
- method: "POST",
95
- headers: { "Content-Type": "application/json" },
96
- body: JSON.stringify(body),
97
- signal: signal
98
- ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
99
- : AbortSignal.timeout(FETCH_TIMEOUT_MS),
100
- }, provider.proxyUri)
101
-
102
- if (!response.ok) {
103
- const text = await response.text().catch(() => "")
104
- throw new Error(`Gemini API error ${response.status}: ${text}`)
105
- }
109
+ // 会诊 #6:TPM/RPM 闸门 + 记账
110
+ const { rateGate, recordRate, estimateRequestTokens } = await import("./rate.mjs")
111
+ const estimated = estimateRequestTokens({ messages })
112
+ await rateGate(provider, estimated, onWait, signal)
113
+
114
+ // 2026-08-31:5xx/网络与 OpenAI 格式统一退避重试链(原完全无重试——Gemini 高峰
115
+ // 503 直接抛错崩溃整个 turn)
116
+ const response = await requestWithRetry(
117
+ () => proxyFetch(url, {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify(body),
121
+ signal: signal
122
+ ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
123
+ : AbortSignal.timeout(FETCH_TIMEOUT_MS),
124
+ _headerTimeoutMs: FETCH_TIMEOUT_MS,
125
+ _bodyIdleMs: 120_000,
126
+ }, provider.proxyUri),
127
+ { signal, onWait, buildMessage: (status, text) => `Gemini API error ${status}: ${text}` },
128
+ )
106
129
 
107
130
  const result = await parseGeminiStream(response, { onToken, onReasoning, signal })
131
+ recordRate(provider, estimated, result.usage)
108
132
 
109
133
  const usage = result.usage
110
134
  if (usage) {
@@ -177,6 +201,8 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
177
201
  throw e
178
202
  }
179
203
  buffer += decoder.decode(chunk, { stream: true })
204
+ // BOM 剥除(会诊 #12):首个 chunk 可能带 \uFEFF,否则首个 data 事件静默丢失
205
+ if (buffer.charCodeAt(0) === 0xfeff) buffer = buffer.slice(1)
180
206
  const lines = buffer.split("\n")
181
207
  buffer = lines.pop()
182
208
 
@@ -52,6 +52,11 @@ export function estimateRequestTokens(body) {
52
52
 
53
53
  /** Gate: sleep until window frees space when over budget */
54
54
  export async function rateGate(provider, estimated, onWait, signal) {
55
+ // 2026-08-31 会诊 #16:单请求估算已超 tpm 时原实现静默放行(必然撞服务端 429)。
56
+ // 保持放行(tpm 置 null 防止 overTokens 恒正值死等),但明确告警让上层/用户知情。
57
+ if (provider.tpm != null && estimated > provider.tpm) {
58
+ onWait?.({ phase: "warn", message: `estimated ${estimated} tokens > tpm ${provider.tpm} — request proceeds and may hit a server 429` })
59
+ }
55
60
  const tpm = provider.tpm != null && estimated <= provider.tpm ? provider.tpm : null
56
61
  const rpm = provider.rpm ?? null
57
62
  if (tpm == null && rpm == null) return