@thincoder/core 0.9.1

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 (203) hide show
  1. package/abort-provenance.mjs +116 -0
  2. package/advisor/citations.mjs +139 -0
  3. package/advisor/compaction.mjs +174 -0
  4. package/advisor/convergence.mjs +80 -0
  5. package/advisor/history.mjs +77 -0
  6. package/advisor/loop.mjs +288 -0
  7. package/advisor/messages.mjs +299 -0
  8. package/advisor/notice.mjs +141 -0
  9. package/advisor/project-context.mjs +197 -0
  10. package/advisor/repos.mjs +150 -0
  11. package/advisor/run.mjs +190 -0
  12. package/advisor/truncate.mjs +57 -0
  13. package/advisor.mjs +281 -0
  14. package/agent/child-marks.mjs +24 -0
  15. package/agent/completion.mjs +145 -0
  16. package/agent/dispatch.mjs +493 -0
  17. package/agent/family-tools.mjs +174 -0
  18. package/agent/helpers.mjs +412 -0
  19. package/agent/post-turn.mjs +70 -0
  20. package/agent/record-results.mjs +174 -0
  21. package/agent/relay-prefix.mjs +39 -0
  22. package/agent/run-stages.mjs +244 -0
  23. package/agent/setup-reminders.mjs +199 -0
  24. package/agent/setup.mjs +234 -0
  25. package/agent/spawn-child.mjs +258 -0
  26. package/agent/suspension.mjs +240 -0
  27. package/agent/write-gate.mjs +87 -0
  28. package/agent-tools/advisor-async.mjs +481 -0
  29. package/agent-tools/advisor-settle.mjs +240 -0
  30. package/agent-tools/advisor.mjs +280 -0
  31. package/agent-tools/async-discard.mjs +143 -0
  32. package/agent-tools/async-settle.mjs +299 -0
  33. package/agent-tools/batch-segment.mjs +265 -0
  34. package/agent-tools/child-permission.mjs +45 -0
  35. package/agent-tools/consult.mjs +471 -0
  36. package/agent-tools/design-token.mjs +117 -0
  37. package/agent-tools/digest-budget.mjs +76 -0
  38. package/agent-tools/eng.mjs +102 -0
  39. package/agent-tools/escalate-async.mjs +302 -0
  40. package/agent-tools/goal.mjs +119 -0
  41. package/agent-tools/panel-blocks.mjs +24 -0
  42. package/agent-tools/parent-channel.mjs +231 -0
  43. package/agent-tools/plan.mjs +86 -0
  44. package/agent-tools/read-history.mjs +309 -0
  45. package/agent-tools/recent-changes.mjs +24 -0
  46. package/agent-tools/review-facts.mjs +31 -0
  47. package/agent-tools/settings.mjs +268 -0
  48. package/agent-tools/skill.mjs +63 -0
  49. package/agent-tools/spawn-gates.mjs +109 -0
  50. package/agent-tools/subagent-actions.mjs +496 -0
  51. package/agent-tools/subagent-async.mjs +456 -0
  52. package/agent-tools/subagent-panel.mjs +160 -0
  53. package/agent-tools/subagent-run.mjs +208 -0
  54. package/agent-tools/subagent-scheduler.mjs +446 -0
  55. package/agent-tools/subagent-spawn.mjs +478 -0
  56. package/agent-tools/subagent.mjs +419 -0
  57. package/agent-tools/task.mjs +87 -0
  58. package/agent-tools/timer.mjs +46 -0
  59. package/agent-tools/verify.mjs +295 -0
  60. package/agent-tools.mjs +23 -0
  61. package/agent.mjs +430 -0
  62. package/auto-think.mjs +115 -0
  63. package/compress-form.mjs +24 -0
  64. package/config-io.mjs +277 -0
  65. package/config-migrate.mjs +178 -0
  66. package/config-presets.mjs +49 -0
  67. package/config.mjs +419 -0
  68. package/context.mjs +495 -0
  69. package/conventions.mjs +223 -0
  70. package/embedding.mjs +120 -0
  71. package/escape.mjs +152 -0
  72. package/expand-home.mjs +16 -0
  73. package/explore-distill.mjs +152 -0
  74. package/generate-title.mjs +123 -0
  75. package/git/checkpoint.mjs +448 -0
  76. package/git/gitmem.mjs +100 -0
  77. package/history-window.mjs +179 -0
  78. package/hooks.mjs +108 -0
  79. package/i18n.mjs +106 -0
  80. package/index-bin.mjs +48 -0
  81. package/index-discover.mjs +176 -0
  82. package/ledger-cmd.mjs +209 -0
  83. package/ledger-db.mjs +86 -0
  84. package/ledger-surface.mjs +76 -0
  85. package/ledger.mjs +202 -0
  86. package/log.mjs +195 -0
  87. package/manifest.mjs +338 -0
  88. package/markdown.mjs +106 -0
  89. package/mcp/helpers.mjs +51 -0
  90. package/mcp/transport-http.mjs +248 -0
  91. package/mcp/transport-stdio.mjs +140 -0
  92. package/mcp/transport-ws.mjs +122 -0
  93. package/mcp.mjs +295 -0
  94. package/memory/code-index.mjs +219 -0
  95. package/memory/code-sync.mjs +427 -0
  96. package/memory/core.mjs +318 -0
  97. package/memory/delete.mjs +242 -0
  98. package/memory/docs.mjs +431 -0
  99. package/memory/file-walk.mjs +109 -0
  100. package/memory/origin.mjs +24 -0
  101. package/memory/scan.mjs +177 -0
  102. package/memory/schema.mjs +460 -0
  103. package/memory.mjs +21 -0
  104. package/model-ref.mjs +66 -0
  105. package/model-specs.mjs +277 -0
  106. package/package.json +32 -0
  107. package/peer-domains.mjs +265 -0
  108. package/peer-instances.mjs +178 -0
  109. package/permission.mjs +79 -0
  110. package/process-probe.mjs +315 -0
  111. package/prompt-files.mjs +113 -0
  112. package/prompt-overlays.mjs +78 -0
  113. package/prompts/advisor-design.md +43 -0
  114. package/prompts/advisor-round1.md +41 -0
  115. package/prompts/advisor-round2.md +46 -0
  116. package/prompts/advisor-round3.md +42 -0
  117. package/prompts/common.md +158 -0
  118. package/prompts/consult-base.md +19 -0
  119. package/prompts/discipline-engineering.md +123 -0
  120. package/prompts/discipline-normal.md +206 -0
  121. package/prompts/persona-coder.md +21 -0
  122. package/prompts/persona-eng-coder.md +41 -0
  123. package/prompts/persona-eng-designer.md +80 -0
  124. package/prompts/persona-engineering.md +160 -0
  125. package/prompts/persona-explore.md +15 -0
  126. package/prompts/persona-normal.md +35 -0
  127. package/prompts/persona-plan.md +27 -0
  128. package/provider/anthropic.mjs +225 -0
  129. package/provider/core.mjs +491 -0
  130. package/provider/errors.mjs +101 -0
  131. package/provider/google.mjs +257 -0
  132. package/provider/index.mjs +7 -0
  133. package/provider/list-models.mjs +163 -0
  134. package/provider/normalize.mjs +81 -0
  135. package/provider/rate.mjs +168 -0
  136. package/provider/responses.mjs +495 -0
  137. package/provider/retry.mjs +88 -0
  138. package/provider/sse.mjs +264 -0
  139. package/provider/wait-status.mjs +59 -0
  140. package/proxy.mjs +274 -0
  141. package/rules.mjs +53 -0
  142. package/session-gc.mjs +248 -0
  143. package/session-guard.mjs +59 -0
  144. package/session-lifecycle.mjs +305 -0
  145. package/session-migrate.mjs +48 -0
  146. package/session-rename.mjs +38 -0
  147. package/session-segments.mjs +99 -0
  148. package/session-slot-write.mjs +168 -0
  149. package/session-slots-manifest.mjs +264 -0
  150. package/session-slots.mjs +298 -0
  151. package/session-store.mjs +441 -0
  152. package/session.mjs +244 -0
  153. package/skills.mjs +234 -0
  154. package/text-budget.mjs +79 -0
  155. package/token-ttl.mjs +285 -0
  156. package/tool-docs/apply_patch.md +15 -0
  157. package/tool-docs/bash.md +38 -0
  158. package/tool-docs/delete.md +13 -0
  159. package/tool-docs/edit.md +30 -0
  160. package/tool-docs/execute.md +21 -0
  161. package/tool-docs/fetch.md +12 -0
  162. package/tool-docs/file_ops.md +17 -0
  163. package/tool-docs/get_current_time.md +8 -0
  164. package/tool-docs/git.md +54 -0
  165. package/tool-docs/glob.md +11 -0
  166. package/tool-docs/grep.md +19 -0
  167. package/tool-docs/hashline_edit.md +14 -0
  168. package/tool-docs/insert_after.md +15 -0
  169. package/tool-docs/lint.md +10 -0
  170. package/tool-docs/ls.md +12 -0
  171. package/tool-docs/lsp.md +10 -0
  172. package/tool-docs/process.md +10 -0
  173. package/tool-docs/question.md +16 -0
  174. package/tool-docs/read.md +20 -0
  175. package/tool-docs/read_image.md +8 -0
  176. package/tool-docs/tree.md +14 -0
  177. package/tool-docs/wait_for.md +22 -0
  178. package/tool-docs/websearch.md +16 -0
  179. package/tool-docs/write.md +11 -0
  180. package/tools/bash.mjs +276 -0
  181. package/tools/edit-batch.mjs +204 -0
  182. package/tools/edit-diff.mjs +388 -0
  183. package/tools/exec-run.mjs +43 -0
  184. package/tools/execute.mjs +243 -0
  185. package/tools/file.mjs +464 -0
  186. package/tools/git-checkpoint.mjs +143 -0
  187. package/tools/git-ext.mjs +173 -0
  188. package/tools/git.mjs +415 -0
  189. package/tools/glob-dialect.mjs +130 -0
  190. package/tools/index.mjs +76 -0
  191. package/tools/linter.mjs +120 -0
  192. package/tools/lsp.mjs +335 -0
  193. package/tools/ops.mjs +293 -0
  194. package/tools/patch.mjs +290 -0
  195. package/tools/question.mjs +26 -0
  196. package/tools/repomap.mjs +314 -0
  197. package/tools/search.mjs +248 -0
  198. package/tools/shared.mjs +467 -0
  199. package/tools/tree.mjs +81 -0
  200. package/tools/web.mjs +224 -0
  201. package/tools/write-path.mjs +191 -0
  202. package/traces/trace-store.mjs +303 -0
  203. package/undo-stack.mjs +47 -0
@@ -0,0 +1,491 @@
1
+ /**
2
+ * provider/core.mjs — LLM call core
3
+ * chat / createProvider / requestWithRetry
4
+ * SSE parsing → provider/sse.mjs
5
+ */
6
+
7
+ import { providerSpec, resolveEnableThinking } from "../config.mjs"
8
+ import { proxyFetch } from "../proxy.mjs"
9
+ import { escapeMessages, stripLocalMessageFields } from "../escape.mjs"
10
+ import { logEvent, errText, classifyErr, headText } from "../log.mjs"
11
+ import { abortError, annotateAbort, deathLine } from "../abort-provenance.mjs"
12
+ import { recordChatTrace } from "../traces/trace-store.mjs"
13
+ import { readSSE } from "./sse.mjs"
14
+ export { readSSE } from "./sse.mjs"
15
+ import {
16
+ RETRYABLE_STATUS, MAX_RETRIES, MAX_CONTINUATIONS,
17
+ RATE_LIMIT_BACKOFF_MS, _rateHooks,
18
+ estimateRequestTokens, rateGate, recordRate,
19
+ } from "./rate.mjs"
20
+ // 2026-09-05 module-split:错误分类/流规则族迁 provider/errors.mjs(core.mjs 557 > 500 硬限)
21
+ import { parseRetryAfter, isNonRetryableError, betaBaseURL, compileStreamRules, assertProviderModel } from "./errors.mjs"
22
+ // 测试 import 面(provider-stream/stream-rules)——core 曾直接 export 这两个
23
+ export { parseRetryAfter, compileStreamRules } from "./errors.mjs"
24
+
25
+ // 2026-09-01:FETCH_TIMEOUT_MS 常量退役(绝对墙钟语义废除)——fetchTimeoutMs 现为每调用从 provider 读(config 归一化),见 effectiveFetchTimeoutMs。
26
+
27
+ /** 可中断 sleep(会诊 #5)——retry.mjs 同用(2026-09-08 ENG-SESSION-PROVIDER-CLEANUP D2.3 去重——单实现,retry.mjs 导入)。 */
28
+ export async function sleepInterruptible(ms, signal) {
29
+ if (!signal) return _rateHooks.sleep(ms)
30
+ if (signal.aborted) throw abortError(signal, "provider", "sleep")
31
+ return new Promise((resolve, reject) => {
32
+ const onAbort = () => { signal.removeEventListener("abort", onAbort); reject(abortError(signal, "provider", "sleep")) }
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
+
41
+ /** Create a validated provider config object from raw config */
42
+ export function createProvider(config) {
43
+ if (!config?.baseURL) throw new Error("provider config: baseURL is required — configure providers in ~/.thincoder/config.json")
44
+ if (!config?.apiKey) throw new Error("provider config: apiKey is required — configure it in ~/.thincoder/config.json")
45
+ if (!config?.model) throw new Error("provider config: model is required — configure in ~/.thincoder/config.json")
46
+ return {
47
+ baseURL: config.baseURL.replace(/\/+$/, ""),
48
+ apiKey: config.apiKey,
49
+ model: config.model,
50
+ maxTokens: config.maxTokens,
51
+ temperature: config.temperature,
52
+ thinking: config.thinking,
53
+ reasoningEffort: config.reasoningEffort,
54
+ tpm: config.tpm,
55
+ rpm: config.rpm,
56
+ format: config.format,
57
+ chatPath: config.chatPath,
58
+ proxy: config.proxy,
59
+ proxyUri: config.proxyUri,
60
+ }
61
+ }
62
+
63
+ /** Send a streaming chat completion request with automatic continuation on truncation */
64
+ // 2026-09-01 根因修复:600s 绝对墙钟曾腰斩长上下文子代理(eng-coder TTFB>10min 即死)——TTFB 阶段改用
65
+ // fetchTimeoutMs(默认 600s,agent.fetchTimeoutMs 可配),body 阶段 idle 超时(FETCH_BODY_IDLE_MS,无新数据才断)。
66
+ const FETCH_BODY_IDLE_MS = 120_000
67
+ /** §6.14 设计值:prefix 续写只保留最近 8 条非工具文本(截断点语境足够,N 以测试锁定) */
68
+ const PREFIX_CONTINUATION_KEEP = 8
69
+
70
+ /** 2026-09-01:响应头阶段超时(默认 600s,agent.fetchTimeoutMs 可配)——anthropic/responses transport 共用 */
71
+ export function effectiveFetchTimeoutMs(provider) {
72
+ return Number.isFinite(provider?.fetchTimeoutMs) && provider.fetchTimeoutMs > 0 ? provider.fetchTimeoutMs : 600_000
73
+ }
74
+
75
+ export async function chat(provider, opts = {}) {
76
+ // LOGGING(docs/design/LOGGING.md):llm:* 事件统一在此落点——所有 chat 调用
77
+ // (主回合/消化轮/compress/distill/advisor/子代理/auto-think/consult)都经本函数,
78
+ // 格式分派(anthropic/google/responses)在内部——单点覆盖即 llm:* 全覆盖。
79
+ // 续写/重试各自为独立 HTTP 请求——续写递归(下方 chatImpl 内)会再包一层(嵌套
80
+ // llm:start/done 对——每请求一事件);重试在 requestWithRetry 内部不可见。
81
+ // §18.6 完整轨迹存档(AGENT-LOOP.md §18.6 N-TR2——权威句 D-TR1):采集点唯一=
82
+ // 本函数出口——所有 chat 调用(主回合/消化轮/compress/distill/advisor/子代理/
83
+ // auto-think/consult)都经本函数;续写/重试在出口已合并——reasoning 全量才完整。
84
+ const logCtx = opts.logCtx ?? {}
85
+ const t0 = Date.now()
86
+ const pname = provider?.name ?? provider?.model ?? "unknown"
87
+ logEvent("llm:start", { provider: pname, model: provider?.model ?? "", stage: logCtx.stage, turn: logCtx.turn, auto: logCtx.auto === true, child: logCtx.child })
88
+ try {
89
+ const result = await chatImpl(provider, opts)
90
+ logEvent("llm:done", {
91
+ provider: pname, model: provider?.model ?? "",
92
+ ms: Date.now() - t0,
93
+ stage: logCtx.stage, turn: logCtx.turn, auto: logCtx.auto === true, child: logCtx.child,
94
+ head: headText(result?.content ?? "", 300, { paragraph: true }),
95
+ len: String(result?.content ?? "").length,
96
+ finish: result?.finishReason ?? null,
97
+ tools: Array.isArray(result?.toolCalls) ? result.toolCalls.length : 0,
98
+ })
99
+ // §18.6 D-TR1/D-TR5:出口收集——成功路径轨迹(含 content/reasoning 全文/toolCalls)
100
+ recordChatTrace(provider, opts, result, null)
101
+ return result
102
+ } catch (e) {
103
+ logEvent("llm:error", {
104
+ provider: pname, model: provider?.model ?? "",
105
+ ms: Date.now() - t0,
106
+ stage: logCtx.stage, turn: logCtx.turn, auto: logCtx.auto === true, child: logCtx.child,
107
+ err: errText(deathLine(e, opts.signal), 200),
108
+ kind: classifyErr(e, opts.signal),
109
+ })
110
+ // §18.6 D-TR5:失败路径也落盘——error(errText 截断 + 类别)+ finishReason:null
111
+ recordChatTrace(provider, opts, null, e)
112
+ throw e
113
+ }
114
+ }
115
+
116
+ /** chat 本体(LOG(LLM) 事件包装之外——见上方 chat 包装器)。 */
117
+ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWait, signal, streamRules, firedPatterns, toolChoice, parallelToolCalls, logCtx }) {
118
+ // Sanitize BEFORE format dispatch — image poisoning bricks anthropic/google sessions
119
+ // the same way it bricks OpenAI-format ones (all raster-only).
120
+ // providerSpec: spec with the provider-level context override (PROVIDER.md §6.15) — the
121
+ // window/clamping logic below reads the overridden value where it matters.
122
+ const spec = providerSpec(provider)
123
+ messages = stripImagesForTextModel(messages, spec)
124
+ // SESSION.md §6.9 T-S3: local-only message fields (ts/transient) never reach the wire.
125
+ // Stripped BEFORE format dispatch — anthropic/responses transports pass whole message
126
+ // objects through verbatim (only the OpenAI path ran escapeMessages). Copy-on-write:
127
+ // history keeps the fields, the request never sees them.
128
+ messages = stripLocalMessageFields(messages)
129
+ const _debugBeforeLen = process.env.THIN_DEBUG_BODY ? JSON.stringify(messages).length : 0
130
+
131
+ // Format dispatch: delegate to non-OpenAI transports
132
+ if (provider.format === "anthropic") {
133
+ const { chat: anthropicChat } = await import("./anthropic.mjs")
134
+ const { normalizeTools } = await import("./anthropic.mjs")
135
+ const result = await anthropicChat(provider, {
136
+ messages,
137
+ tools: tools?.length ? normalizeTools(tools) : null,
138
+ onToken, onReasoning, onWait, signal, toolChoice,
139
+ })
140
+ return result
141
+ }
142
+ if (provider.format === "google") {
143
+ const { chat: geminiChat } = await import("./google.mjs")
144
+ const { normalizeTools } = await import("./google.mjs")
145
+ const result = await geminiChat(provider, {
146
+ messages,
147
+ tools: tools?.length ? normalizeTools(tools) : null,
148
+ onToken, onReasoning, onWait, signal, toolChoice,
149
+ })
150
+ return result
151
+ }
152
+ if (provider.format === "responses") {
153
+ // 2026-08-31:Responses API transport(PROVIDER.md §6.13)——双轨链在 transport 内部
154
+ // 自行管理(provider._responsesChain),agent 层零改动。
155
+ // round3 #3:配对归一化必须在此分派前(压缩/中断遗留的孤儿 tool 消息发向严格服务端会 400)
156
+ messages = normalizeToolPairing(messages)
157
+ const { chat: responsesChat } = await import("./responses.mjs")
158
+ return responsesChat(provider, {
159
+ messages,
160
+ tools,
161
+ onToken, onReasoning, onWait, signal, toolChoice,
162
+ })
163
+ }
164
+
165
+ messages = normalizeToolPairing(messages)
166
+ // 中和服务端的非标二次转义:会话里若出现字面 "\x"/"\u"(如讨论转义、grep 到含
167
+ // 转义的代码),Kimi 等会把它们当 hex escape 再解析 → "unexpected end of hex escape" 400。
168
+ // 发送前统一 double 掉会形成非法转义的序列(合法 \xNN/\uNNNN 不受影响)。
169
+ messages = escapeMessages(messages)
170
+ if (process.env.THIN_DEBUG_BODY) {
171
+ console.error(`[debug-body] escape: ${_debugBeforeLen} -> ${JSON.stringify(messages).length} chars, ${messages.length} msgs (provider=${provider.name}, model=${provider.model})`)
172
+ }
173
+ // Compile string-pattern rules to RegExp at call time
174
+ const rules = compileStreamRules(streamRules)
175
+ // F-1 (MODEL-400-FIX):请求体组装前断言 model 恒有值——见 errors.mjs assertProviderModel
176
+ assertProviderModel(provider)
177
+ const body = {
178
+ model: provider.model,
179
+ messages,
180
+ stream: true,
181
+ }
182
+ // Skip usage stream for models that don't support it (GLM, MiniMax, Gemini)
183
+ if (!spec.noUsageStream) body.stream_options = { include_usage: true }
184
+ if (provider.maxTokens) body.max_tokens = provider.maxTokens
185
+ if (provider.temperature != null) {
186
+ let t = provider.temperature
187
+ if (spec.tempRange) {
188
+ t = Math.min(spec.tempRange[1], Math.max(spec.tempRange[0], t))
189
+ t = Math.round(t * 100) / 100
190
+ }
191
+ body.temperature = t
192
+ }
193
+ if (provider.thinking) body.thinking = provider.thinking
194
+ // reasoning_effort is a provider-native parameter — routers/proxies (model ID with "/"
195
+ // prefix like kimi/kimi-k3) may misinterpret it, causing empty responses or 400s.
196
+ const isRouter = provider.model.includes("/")
197
+ if (provider.reasoningEffort && !isRouter && provider.format !== "anthropic" && provider.format !== "google") {
198
+ if (spec.reasoningEffortEnum && !spec.reasoningEffortEnum.includes(provider.reasoningEffort)) {
199
+ throw new Error(
200
+ `reasoning_effort "${provider.reasoningEffort}" not supported by model "${provider.model}"; ` +
201
+ `valid values: ${spec.reasoningEffortEnum.join(", ")}`
202
+ )
203
+ }
204
+ body.reasoning_effort = provider.reasoningEffort
205
+ } else if (
206
+ // D-14(MODEL-SPECS §9.6 · 批 2026-09-20-channel-onboarding):effort 族非百炼渠道的
207
+ // 「off 静默失效」——两端「关思考」发的是 `thinking:null` 标记(CLI cmd-think.mjs / VSC
208
+ // reasoning-mode.mjs),该标记在 :193 被 falsy 跳过 ⇒ 服务端默认想 on、不发任何思考字段
209
+ // = UI 显 OFF 实际在想(假告知,同 PROVIDER.md §6.12 先例)。`reasoning_effort:"none"` 是
210
+ // 实测唯一有效 off 路径(hy3 / seed:tok=0、rc 消失)。五 guard 面(MODEL-SPECS §9.9 B-5):
211
+ // 无枚举行(hy4-preview)透传不变 / 显式档优先只携该档(上支)/ 百炼 flash 同义多携 /
212
+ // 枚举不含 "none" 者(kimi-k3)不发 / 含 "/" 路由形态名不发(复用 isRouter)。
213
+ provider.thinking === null &&
214
+ spec.thinkApi === "effort" &&
215
+ spec.reasoningEffortEnum?.includes("none") &&
216
+ provider.reasoningEffort == null &&
217
+ !isRouter
218
+ ) {
219
+ body.reasoning_effort = "none"
220
+ }
221
+ // enable_thinking — Bailian hybrid-thinking switch (PROVIDER.md §6.12): qwen3.x defaults to
222
+ // thinking ON, so an explicit off must send enable_thinking:false or the server keeps thinking.
223
+ // NOT gated by isRouter: the whitelist keys on model prefix + Bailian host, not the model-ID slash.
224
+ const enableThinking = resolveEnableThinking(provider, spec)
225
+ if (enableThinking !== undefined) body.enable_thinking = enableThinking
226
+ if (tools?.length) body.tools = tools
227
+ // 2026-08-31:tool_choice 能力层(透传 OpenAI 语义);
228
+ // parallel_tool_calls 仅显式 true 时发送(默认不发=不改变现有行为)
229
+ if (toolChoice !== undefined) body.tool_choice = toolChoice
230
+ if (parallelToolCalls === true) body.parallel_tool_calls = true
231
+
232
+ const estimated = estimateRequestTokens(body)
233
+ await rateGate(provider, estimated, onWait, signal)
234
+
235
+ const response = await requestWithRetry(provider, body, signal, onWait)
236
+ const result = await readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns })
237
+ recordRate(provider, estimated, result.usage)
238
+
239
+ // Stream rule triggered, user interrupted, or network partial — return immediately.
240
+ // 2026-08-31 会诊 #2:partial(网络错误中断但已有内容)与 interrupted 同级透传,
241
+ // 不再让上层把已收内容当整轮失败重试(重试从零开始浪费已流出的成本)。
242
+ if (result.ruleTriggered) return result
243
+ if (result.interrupted) return result
244
+ if (result.partial) return result
245
+
246
+ // Retry on transient server overload (DeepSeek: insufficient_system_resource)
247
+ const MAX_OVERLOAD_RETRIES = 1
248
+ for (let r = 0; result.finishReason === "insufficient_system_resource" && r <= MAX_OVERLOAD_RETRIES; r++) {
249
+ if (r > 0) {
250
+ onWait?.({ phase: "overloaded", seconds: 3 })
251
+ await sleepInterruptible(3000, signal)
252
+ }
253
+ const retryResponse = await requestWithRetry(provider, body, signal, onWait)
254
+ const retryResult = await readSSE(retryResponse, { onToken, onReasoning })
255
+ recordRate(provider, estimated, retryResult.usage)
256
+ if (retryResult.finishReason !== "insufficient_system_resource") {
257
+ // Merge any partial content from the failed attempt (streaming already showed it)
258
+ result.content += retryResult.content
259
+ result.reasoning += retryResult.reasoning ?? ""
260
+ mergeRetryToolCalls(result, retryResult.toolCalls)
261
+ result.finishReason = retryResult.finishReason
262
+ if (retryResult.usage) result.usage = retryResult.usage
263
+ break
264
+ }
265
+ // Retry exhausted — keep the partial result with insufficient_system_resource finish_reason
266
+ }
267
+
268
+ if (!spec.partialMode && !spec.prefixMode) return result
269
+ for (let n = 0; result.finishReason === "length" && result.content && n < MAX_CONTINUATIONS; n++) {
270
+ let continued
271
+ try {
272
+ continued = await chat(spec.prefixMode ? { ...provider, baseURL: betaBaseURL(provider.baseURL) } : provider, {
273
+ messages: buildContinuationMessages(messages, result, spec),
274
+ tools,
275
+ onToken,
276
+ onReasoning,
277
+ onWait,
278
+ signal,
279
+ // §18.6:续写是同一逻辑调用的子请求——logCtx 原样透传(元数据与门控
280
+ // traces.enabled 对续写调用同样生效,不在出口静默越过开关)
281
+ // fix round1(D-TR1):续写子请求标记 isContinuation:true(T-TR14——true =
282
+ // 该调用是续写链的一环;外层新调用 false)——分析"纠结"时区分续写/重试链。
283
+ logCtx: { ...logCtx, isContinuation: true },
284
+ })
285
+ } catch (error) {
286
+ // §6.14 失败可见性:续写失败注入 _warnings(agent 机读线可见)不整轮飞出;AbortError 用户中断透传
287
+ if (error?.name === "AbortError") throw error
288
+ result._warnings ??= []
289
+ result._warnings.push({ name: "continuation-failed", message: `output continuation failed: ${error.message}` })
290
+ break
291
+ }
292
+ result.content += continued.content
293
+ result.reasoning += continued.reasoning ?? ""
294
+ mergeRetryToolCalls(result, continued.toolCalls)
295
+ result.finishReason = continued.finishReason
296
+ if (continued.usage) {
297
+ const sum = (k) => (result.usage?.[k] ?? 0) + (continued.usage[k] ?? 0)
298
+ result.usage = {
299
+ prompt_tokens: sum("prompt_tokens"),
300
+ completion_tokens: sum("completion_tokens"),
301
+ total_tokens: sum("total_tokens"),
302
+ prompt_cache_hit_tokens: sum("prompt_cache_hit_tokens"),
303
+ prompt_cache_miss_tokens: sum("prompt_cache_miss_tokens"),
304
+ }
305
+ }
306
+ }
307
+ return result
308
+ }
309
+
310
+ /** 续写消息构造(§6.14):prefix 精简历史(deepseek /beta 网关对含工具链历史必 400,真机矩阵);partial 保持现状 */
311
+ export function buildContinuationMessages(messages, result, spec) {
312
+ const tail = (extra) => ({ role: "assistant", content: result.content, ...extra, ...(result.reasoning ? { reasoning_content: result.reasoning } : {}) })
313
+ if (!spec.prefixMode) return [...messages, tail({ partial: true })]
314
+ const slim = messages.filter((m) => m.role !== "tool" && !(m.role === "assistant" && m.tool_calls?.length))
315
+ return [...slim.filter((m) => m.role === "system"), ...slim.filter((m) => m.role !== "system").slice(-PREFIX_CONTINUATION_KEEP), tail({ prefix: true })]
316
+ }
317
+
318
+ /**
319
+ * Replace image parts with text placeholders when they would 400 the request:
320
+ * - the model has no vision support at all (history may carry image_url parts from a
321
+ * session resumed after switching from a vision model — text-only APIs like DeepSeek
322
+ * reject the ENTIRE request, bricking the conversation);
323
+ * - the model IS vision-capable but the data URL is not a raster format it can ingest
324
+ * (Kimi/Anthropic/OpenAI/Gemini are all raster-only — Kimi 400s "unsupported image
325
+ * format" on EVERY subsequent request once an svg/bmp part sits in history).
326
+ * Sanitize at send time — history itself is left untouched, so switching back to a
327
+ * capable model/format restores the images. Non-data-URL image refs (http) pass through.
328
+ */
329
+ // Pre-send payload normalization lives in normalize.mjs (2026-08-31 extract,
330
+ // TODO #2); re-exported so provider/index.mjs and tool-pairing.test.mjs keep
331
+ // their import paths.
332
+ import { stripImagesForTextModel, normalizeToolPairing } from "./normalize.mjs"
333
+ export { stripImagesForTextModel, normalizeToolPairing }
334
+ /** Merge tool calls from a retry/continuation into the accumulated result.
335
+ * 2026-08-31 会诊 #7/#17:readSSE 输出的 tc 已 finalize(无 index 字段),
336
+ * 原实现恒 append(重试里 provider 重发完整 tc → tool 名 "get_weatherget_weather"、
337
+ * arguments 重复)。改按 id 定位已有槽位、无 id 才追加;name 只设一次。 */
338
+ function mergeRetryToolCalls(result, toolCalls) {
339
+ for (const tc of toolCalls ?? []) {
340
+ if (!tc) continue
341
+ let s
342
+ if (tc.id) {
343
+ s = result.toolCalls.find((x) => x && x.id === tc.id)
344
+ }
345
+ if (!s) {
346
+ // 无 id(synthetic call_N 在重试间不稳定)或未命中:按 name 找同 slot(重试语义
347
+ // 是"同一批工具调用重新执行",同名合并最稳);仍找不到才追加。
348
+ s = tc.name ? result.toolCalls.find((x) => x && x.name === tc.name) : undefined
349
+ }
350
+ if (!s) {
351
+ s = { id: "", name: "", arguments: "" }
352
+ result.toolCalls.push(s)
353
+ }
354
+ if (tc.id && !s.id) s.id = tc.id
355
+ if (tc.name && !s.name) s.name = tc.name
356
+ s.arguments += tc.arguments ?? ""
357
+ }
358
+ }
359
+
360
+ // listModels 已迁 provider/list-models.mjs(PROVIDER.md §6.16 M1——按 format 分派):2026-09-10
361
+ // 模型选择面重构——原 OpenAI-only 实现在此,迁出并扩 anthropic / google 两分支;
362
+ // provider/index.mjs re-export 改指新文件——调用点零改。
363
+
364
+ async function requestWithRetry(provider, body, signal, onWait) {
365
+ // THIN_DEBUG_BODY=1:发送前诊断——复现网关侧 "unexpected end of hex escape" 400 时
366
+ // 定位真实载荷里的毒序列(2026-08-31 slot 3 deepseek-v4-flash)。模拟网关最宽松的
367
+ // 爆炸条件:任何字面 "\u"/"\x" 后不足位(不看前置反斜杠)。
368
+ if (process.env.THIN_DEBUG_BODY) {
369
+ try {
370
+ const msgs = body?.messages ?? []
371
+ const raw = JSON.stringify(body)
372
+ const hits = []
373
+ for (let i = 0; i < msgs.length; i++) {
374
+ const m = msgs[i] ?? {}
375
+ const fields = []
376
+ if (typeof m.content === "string") fields.push(["content", m.content])
377
+ else if (Array.isArray(m.content)) m.content.forEach((p, pi) => { if (p && typeof p.text === "string") fields.push([`content[${pi}]`, p.text]) })
378
+ if (typeof m.reasoning_content === "string") fields.push(["reasoning_content", m.reasoning_content])
379
+ 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]) })
380
+ if (typeof m.name === "string") fields.push(["name", m.name])
381
+ for (const [f, t] of fields) {
382
+ const re = /\\[xu]/g
383
+ let mm
384
+ while ((mm = re.exec(t))) {
385
+ const c = t[mm.index + 1]
386
+ const need = c === "u" ? 4 : 2
387
+ const after = t.slice(mm.index + 2, mm.index + 2 + need)
388
+ if (!new RegExp(`^[0-9a-fA-F]{${need}}$`).test(after)) {
389
+ hits.push({ i, role: m.role, field: f, ctx: t.slice(Math.max(0, mm.index - 40), mm.index + 12) })
390
+ }
391
+ }
392
+ }
393
+ }
394
+ console.error(`[debug-body] messages=${msgs.length} bodyLen=${raw.length} suspicious=${hits.length}`)
395
+ for (const h of hits.slice(0, 20)) console.error("[debug-body] hit", JSON.stringify(h))
396
+ if (!hits.length && msgs[1151]) {
397
+ 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) }))
398
+ }
399
+ } catch (e) {
400
+ console.error("[debug-body] diag failed:", e.message)
401
+ }
402
+ }
403
+ let lastError
404
+ let lastStatus = 0
405
+ let lastWas429 = false
406
+ let rateLimitHits = 0
407
+ const totalAttempts = MAX_RETRIES + 1
408
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
409
+ if (attempt > 0 && !lastWas429) await sleepInterruptible(2 ** (attempt - 1) * 1000, signal)
410
+ lastWas429 = false
411
+
412
+ let response
413
+ try {
414
+ const url = `${provider.baseURL}${provider.chatPath ?? "/chat/completions"}`
415
+ const opts = {
416
+ method: "POST",
417
+ headers: {
418
+ ...(provider.headers ?? {}), // custom per-provider headers (desktop proposal ④: X-Device-Id etc.)
419
+ "Content-Type": "application/json",
420
+ Authorization: `Bearer ${provider.apiKey}`,
421
+ },
422
+ body: JSON.stringify(body),
423
+ // 2026-09-01 根因修复:原 600s 绝对墙钟会腰斩长上下文子代理(TTFB/首 token >10min 即死)。
424
+ // 拆分语义:响应头阶段仍用 fetchTimeoutMs(600s,覆盖网关排队);body 阶段由读侧 idle 超时管
425
+ // (sse.mjs readIdleMs——无新数据才断)。signal 只保留用户取消链,不再叠加绝对墙钟。
426
+ signal,
427
+ // 2026-08-31 会诊 #4:代理路径响应头超时对齐直连语义(原 15s 与直连 600s 割裂,
428
+ // DeepSeek 排队 TTFB>15s 即误报)— 仅 _ 前缀内部字段,proxyFetch 消费
429
+ _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
430
+ _bodyIdleMs: FETCH_BODY_IDLE_MS,
431
+ }
432
+ response = provider.proxyUri
433
+ ? await proxyFetch(url, opts, provider.proxyUri)
434
+ : await fetch(url, opts)
435
+ } catch (error) {
436
+ // §20.3 站点 #2(第 24 批):fetch 拒否面补标来源(undici 拒否的 AbortError 现场无 reason)
437
+ if (error.name === "AbortError") throw annotateAbort(error, signal, "provider", "request")
438
+ lastError = error
439
+ continue
440
+ }
441
+
442
+ if (response.ok) return response
443
+
444
+ const text = await response.text().catch(() => "")
445
+ let message = `LLM API error ${response.status}: ${text}`
446
+ // 401 双平台提示 + 诊断回显(2026-08-31 会诊 #15):
447
+ // Kimi 双平台 key 不互通的提示保留;通用加 baseURL host + key 前 6 位掩码,
448
+ // 帮用户快速分辨"配错平台还是配错账号"。
449
+ if (response.status === 401 || response.status === 403) {
450
+ const key = String(provider.apiKey ?? "").trim()
451
+ const base = String(provider.baseURL ?? "").toLowerCase()
452
+ const kimiCodeKey = /^sk-kimi-/i.test(key)
453
+ const kimiCodeUrl = base.includes("api.kimi.com")
454
+ if (kimiCodeKey || kimiCodeUrl) {
455
+ 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."
456
+ }
457
+ const host = (() => { try { return new URL(provider.baseURL).host } catch { return provider.baseURL ?? "(unknown)" } })()
458
+ const masked = key.length > 8 ? key.slice(0, 6) + "…" + key.slice(-4) : (key ? key.slice(0, 4) + "…" : "(empty)")
459
+ message += ` [auth diag: baseURL=${host} key=${masked} status=${response.status}]`
460
+ }
461
+ lastStatus = response.status
462
+ if (isNonRetryableError(response.status, text)) throw new Error(message)
463
+ if (response.status === 429) {
464
+ const waitMs = parseRetryAfter(response.headers.get("retry-after"), rateLimitHits)
465
+ rateLimitHits++
466
+ lastError = new Error(message)
467
+ lastWas429 = true
468
+ if (attempt < MAX_RETRIES) {
469
+ onWait?.({ phase: "retry", seconds: Math.ceil(waitMs / 1000) })
470
+ await sleepInterruptible(waitMs, signal)
471
+ }
472
+ continue
473
+ }
474
+ if (RETRYABLE_STATUS.has(response.status)) {
475
+ lastError = new Error(message)
476
+ continue
477
+ }
478
+ throw new Error(message)
479
+ }
480
+ // All retries exhausted — build a descriptive error
481
+ const verb = lastWas429 ? "Rate limit not resolved"
482
+ : lastStatus >= 500 ? "Server error persisted"
483
+ : lastStatus > 0 ? "Request failed"
484
+ : "Network error"
485
+ // 会诊 #8:undici "fetch failed" 真因(ENOTFOUND/TLS/DNS/代理)藏在 error.cause —
486
+ // 拼进去,全链路同一文案不再掩盖根因
487
+ const causeText = lastError?.cause
488
+ ? ` (${lastError.cause.code ?? lastError.cause.message ?? String(lastError.cause)})`
489
+ : ""
490
+ throw new Error(`${verb} after ${totalAttempts} attempts${lastStatus ? ` (${lastStatus})` : ""}: ${lastError?.message ?? "unknown"}${causeText}`)
491
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * provider/errors.mjs — 错误分类与流规则编译族(2026-09-05 module-split:core.mjs
3
+ * 557 > 500 硬限——parseRetryAfter/isNonRetryableError/betaBaseURL/compileStreamRules
4
+ * verbatim 迁入,语义零变;core.mjs import 回(chat 调用点零改)。
5
+ * 注:retry.mjs(anthropic/google/responses 通道)自 2026-09-08 起导入本文件的
6
+ * parseRetryAfter/isNonRetryableError(ENG-SESSION-PROVIDER-CLEANUP D2.2/D2.4 去重——
7
+ * 单实现;早先的"循环依赖回避"复制已随依赖方向实测消解)。
8
+ */
9
+
10
+ import { RETRYABLE_STATUS, RATE_LIMIT_BACKOFF_MS } from "./rate.mjs"
11
+
12
+ /** Parse Retry-After: 秒数 or HTTP-date;上限 300s(会诊 #11)— 异常头不得让 CLI 睡数小时。
13
+ * header 缺失/非法时退回指数退避表(rateLimitHits 计数取档)。 */
14
+ export function parseRetryAfter(header, rateLimitHits = 0) {
15
+ const fallback = RATE_LIMIT_BACKOFF_MS[Math.min(rateLimitHits, RATE_LIMIT_BACKOFF_MS.length - 1)]
16
+ if (header == null) return fallback
17
+ let waitMs = 0
18
+ const numeric = Number(header.trim())
19
+ if (Number.isFinite(numeric) && numeric >= 0) waitMs = numeric * 1000
20
+ else {
21
+ const date = Date.parse(header.trim())
22
+ if (Number.isFinite(date)) waitMs = Math.max(0, date - Date.now())
23
+ }
24
+ if (waitMs <= 0) return fallback
25
+ return Math.min(waitMs, 300_000)
26
+ }
27
+
28
+ /**
29
+ * Detect errors that should NOT be retried — quota, billing, auth, invalid params.
30
+ * Different providers use wildly different error formats. Check body text for known patterns.
31
+ */
32
+ export function isNonRetryableError(status, text) {
33
+ // Auth errors: never retry
34
+ if (status === 401 || status === 403) return true
35
+ // 400-level non-429: usually invalid params
36
+ if (status >= 400 && status < 500 && status !== 429 && !RETRYABLE_STATUS.has(status)) return true
37
+ // For 429, check if it's actually a billing/quota error (not rate limit)
38
+ if (status === 429) {
39
+ const lower = text.toLowerCase()
40
+ // Chinese providers often return 429 for billing issues
41
+ if (lower.includes("余额不足") || lower.includes("余额") || lower.includes("充值")) return true
42
+ if (lower.includes("insufficient") && (lower.includes("balance") || lower.includes("quota") || lower.includes("credit"))) return true
43
+ if (lower.includes("quota") && (lower.includes("exceeded") || lower.includes("insufficient"))) return true
44
+ // Standard OpenAI billing error (error.type === "insufficient_quota" or similar)
45
+ try {
46
+ const j = JSON.parse(text)
47
+ const errType = j?.error?.type || ""
48
+ if (typeof errType === "string" && (errType.includes("quota") || errType.includes("billing") || errType.includes("insufficient") || errType.includes("balance"))) return true
49
+ const errCode = j?.error?.code || ""
50
+ if (typeof errCode === "string" && (errCode === "1113" || errCode === "1114")) return true // GLM billing codes
51
+ } catch {}
52
+ }
53
+ return false
54
+ }
55
+
56
+ export function betaBaseURL(baseURL) {
57
+ // DeepSeek prefix continuation uses /beta endpoint; only handle /v1 suffix, append /beta when /v1 is missing
58
+ if (/\/v1$/.test(baseURL)) return baseURL.replace(/\/v1$/, "/beta")
59
+ return baseURL.endsWith("/") ? baseURL + "beta" : baseURL + "/beta"
60
+ }
61
+
62
+ /**
63
+ * Compile stream rules from config format (string patterns) to executable RegExp objects.
64
+ * Rules format: { pattern: "regex source", message: "reminder text", action: "abort"|"warn" }
65
+ */
66
+ export function compileStreamRules(rules) {
67
+ if (!rules?.length) return null
68
+ return rules.map((r) => {
69
+ try {
70
+ return { ...r, _regex: new RegExp(r.pattern, r.flags ?? "") }
71
+ } catch {
72
+ // Invalid regex — skip silently so one bad rule doesn't break the whole pipeline
73
+ return null
74
+ }
75
+ }).filter(Boolean)
76
+ }
77
+
78
+ /**
79
+ * Provider-level pre-flight error (MODEL-400-FIX F-1 — 请求体组装前断言): carries the
80
+ * provider identity so a fail-fast throw is readable ("which provider + what to fix")
81
+ * instead of a wire-time serde 400 or a bare message without context.
82
+ */
83
+ export class ProviderError extends Error {
84
+ constructor(provider, message) {
85
+ super(`provider "${provider?.name ?? provider?.model ?? "unknown"}": ${message}`)
86
+ this.name = "ProviderError"
87
+ }
88
+ }
89
+
90
+ /**
91
+ * F-1 (MODEL-400-FIX) 根因兜底——请求体组装前断言:渠道裸克隆(`{...渠道}`——渠道只带
92
+ * 默认单值 `model`,克隆链未重派生 `.model` 时)provider.model 为 undefined/null——
93
+ * JSON.stringify 会丢 undefined 键 → 无 model 请求 → serde 400。
94
+ * fail-fast 报可读错误(带 provider 名 + 修复线索),不发病体。core.mjs chatImpl openai body 组装
95
+ * 前调用(单行——core.mjs 500 行硬限)。
96
+ */
97
+ export function assertProviderModel(provider) {
98
+ if (!provider.model) {
99
+ throw new ProviderError(provider, "model is undefined — provider cloned without model re-derivation (set providers[].model — the channel default model)")
100
+ }
101
+ }