thincoder 0.12.51 → 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.
Files changed (58) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/src/acp/bridge.mjs +1 -0
  5. package/src/advisor/run.mjs +9 -11
  6. package/src/agent/dispatch.mjs +38 -13
  7. package/src/agent/helpers.mjs +1 -1
  8. package/src/agent/setup.mjs +2 -2
  9. package/src/agent-tools/consult.mjs +0 -1
  10. package/src/agent-tools/skill.mjs +1 -1
  11. package/src/agent-tools/task.mjs +0 -2
  12. package/src/agent-tools/verify.mjs +0 -1
  13. package/src/agent.mjs +36 -3
  14. package/src/cli/make-agent.mjs +11 -5
  15. package/src/config.mjs +8 -103
  16. package/src/mcp/helpers.mjs +14 -5
  17. package/src/mcp/transport-http.mjs +79 -27
  18. package/src/mcp/transport-stdio.mjs +57 -3
  19. package/src/mcp/transport-ws.mjs +46 -12
  20. package/src/mcp.mjs +197 -58
  21. package/src/model-specs.mjs +108 -0
  22. package/src/prompts/discipline.md +43 -0
  23. package/src/prompts/system.md +1 -1
  24. package/src/provider/anthropic.mjs +51 -18
  25. package/src/provider/core.mjs +121 -102
  26. package/src/provider/google.mjs +41 -15
  27. package/src/provider/normalize.mjs +81 -0
  28. package/src/provider/rate.mjs +5 -0
  29. package/src/provider/responses.mjs +498 -0
  30. package/src/provider/retry.mjs +125 -0
  31. package/src/provider/sse.mjs +58 -24
  32. package/src/proxy.mjs +36 -6
  33. package/src/tools/bash.md +2 -2
  34. package/src/tools/execute.md +1 -1
  35. package/src/tools/execute.mjs +3 -3
  36. package/src/tools/fetch.md +1 -0
  37. package/src/tools/file.mjs +136 -11
  38. package/src/tools/git.md +4 -2
  39. package/src/tools/git.mjs +38 -11
  40. package/src/tools/shared.mjs +6 -3
  41. package/src/tools/system.mjs +19 -1
  42. package/src/tools/web.mjs +44 -14
  43. package/src/tools/websearch.md +3 -1
  44. package/src/tui/agent-turn.mjs +2 -10
  45. package/src/tui/clipboard.mjs +3 -1
  46. package/src/tui/dims.mjs +20 -47
  47. package/src/tui/fold-block.mjs +59 -11
  48. package/src/tui/index.mjs +65 -75
  49. package/src/tui/key-handler.mjs +4 -1
  50. package/src/tui/mouse.mjs +47 -7
  51. package/src/tui/render-conversation.mjs +226 -124
  52. package/src/tui/render-frame.mjs +7 -2
  53. package/src/tui/render-loop.mjs +10 -0
  54. package/src/tui/render.mjs +12 -1
  55. package/src/tui/startup.mjs +1 -2
  56. package/src/tui/subagent-blocks.mjs +6 -1
  57. package/src/tui/tool-args.mjs +4 -0
  58. package/src/tui/tool-events.mjs +2 -4
@@ -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,7 +61,7 @@ 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)
@@ -53,7 +74,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
53
74
  const result = await anthropicChat(provider, {
54
75
  messages,
55
76
  tools: tools?.length ? normalizeTools(tools) : null,
56
- onToken, onReasoning, signal,
77
+ onToken, onReasoning, onWait, signal, toolChoice,
57
78
  })
58
79
  return result
59
80
  }
@@ -63,10 +84,22 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
63
84
  const result = await geminiChat(provider, {
64
85
  messages,
65
86
  tools: tools?.length ? normalizeTools(tools) : null,
66
- onToken, onReasoning, signal,
87
+ onToken, onReasoning, onWait, signal, toolChoice,
67
88
  })
68
89
  return result
69
90
  }
91
+ if (provider.format === "responses") {
92
+ // 2026-08-31:Responses API transport(PROVIDER.md §13)——双轨链在 transport 内部
93
+ // 自行管理(provider._responsesChain),agent 层零改动。
94
+ // round3 #3:配对归一化必须在此分派前(压缩/中断遗留的孤儿 tool 消息发向严格服务端会 400)
95
+ messages = normalizeToolPairing(messages)
96
+ const { chat: responsesChat } = await import("./responses.mjs")
97
+ return responsesChat(provider, {
98
+ messages,
99
+ tools,
100
+ onToken, onReasoning, onWait, signal, toolChoice,
101
+ })
102
+ }
70
103
 
71
104
  messages = normalizeToolPairing(messages)
72
105
  // 中和服务端的非标二次转义:会话里若出现字面 "\x"/"\u"(如讨论转义、grep 到含
@@ -110,6 +143,10 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
110
143
  const enableThinking = resolveEnableThinking(provider, spec)
111
144
  if (enableThinking !== undefined) body.enable_thinking = enableThinking
112
145
  if (tools?.length) body.tools = tools
146
+ // 2026-08-31:tool_choice 能力层(透传 OpenAI 语义);
147
+ // parallel_tool_calls 仅显式 true 时发送(默认不发=不改变现有行为)
148
+ if (toolChoice !== undefined) body.tool_choice = toolChoice
149
+ if (parallelToolCalls === true) body.parallel_tool_calls = true
113
150
 
114
151
  const estimated = estimateRequestTokens(body)
115
152
  await rateGate(provider, estimated, onWait, signal)
@@ -118,16 +155,19 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
118
155
  const result = await readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns })
119
156
  recordRate(provider, estimated, result.usage)
120
157
 
121
- // Stream rule triggered or user interrupted mid-generation — return partial result
158
+ // Stream rule triggered, user interrupted, or network partial — return immediately.
159
+ // 2026-08-31 会诊 #2:partial(网络错误中断但已有内容)与 interrupted 同级透传,
160
+ // 不再让上层把已收内容当整轮失败重试(重试从零开始浪费已流出的成本)。
122
161
  if (result.ruleTriggered) return result
123
162
  if (result.interrupted) return result
163
+ if (result.partial) return result
124
164
 
125
165
  // Retry on transient server overload (DeepSeek: insufficient_system_resource)
126
166
  const MAX_OVERLOAD_RETRIES = 1
127
167
  for (let r = 0; result.finishReason === "insufficient_system_resource" && r <= MAX_OVERLOAD_RETRIES; r++) {
128
168
  if (r > 0) {
129
169
  onWait?.({ phase: "overloaded", seconds: 3 })
130
- await _rateHooks.sleep(3000)
170
+ await sleepInterruptible(3000, signal)
131
171
  }
132
172
  const retryResponse = await requestWithRetry(provider, body, signal, onWait)
133
173
  const retryResult = await readSSE(retryResponse, { onToken, onReasoning })
@@ -136,13 +176,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
136
176
  // Merge any partial content from the failed attempt (streaming already showed it)
137
177
  result.content += retryResult.content
138
178
  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
- }
179
+ mergeRetryToolCalls(result, retryResult.toolCalls)
146
180
  result.finishReason = retryResult.finishReason
147
181
  if (retryResult.usage) result.usage = retryResult.usage
148
182
  break
@@ -172,13 +206,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
172
206
  })
173
207
  result.content += continued.content
174
208
  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
- }
209
+ mergeRetryToolCalls(result, continued.toolCalls)
182
210
  result.finishReason = continued.finishReason
183
211
  if (continued.usage) {
184
212
  const sum = (k) => (result.usage?.[k] ?? 0) + (continued.usage[k] ?? 0)
@@ -205,89 +233,55 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
205
233
  * Sanitize at send time — history itself is left untouched, so switching back to a
206
234
  * capable model/format restores the images. Non-data-URL image refs (http) pass through.
207
235
  */
208
- const RASTER_IMAGE_URL = /^data:image\/(png|jpe?g|gif|webp);base64,/
209
-
210
- export function stripImagesForTextModel(messages, spec) {
211
- let changed = false
212
- const out = messages.map((m) => {
213
- if (!Array.isArray(m.content) || !m.content.some((p) => p?.type === "image_url")) return m
214
- let msgChanged = false
215
- const parts = m.content.map((p) => {
216
- if (p?.type !== "image_url") return p
217
- const url = p.image_url?.url || ""
218
- if (!url.startsWith("data:")) return p
219
- if (spec.multimodal && RASTER_IMAGE_URL.test(url)) return p
220
- msgChanged = true
221
- const reason = spec.multimodal
222
- ? `unsupported format ${url.match(/^data:([^;,]+)/)?.[1] || "unknown"}`
223
- : "this model does not support image input"
224
- return { type: "text", text: `[image omitted — ${reason}]` }
225
- })
226
- if (!msgChanged) return m
227
- changed = true
228
- return { ...m, content: parts }
229
- })
230
- return changed ? out : messages
231
- }
232
-
233
- /**
234
- * Enforce the OpenAI tool-message protocol on the outgoing payload: every tool message must
235
- * immediately follow the assistant message declaring its tool_call_id, and every declared
236
- * tool_call must have a result. Strict providers (DeepSeek) reject the whole request with 400
237
- * ("Messages with role 'tool' must be a response to a preceding message with 'tool_calls'").
238
- * History can legitimately violate this — parallel read_image injects a user message between
239
- * tool results, compaction splits, interrupted sessions leave dangling tool_calls — so sanitize
240
- * at send time. History itself is left untouched.
241
- */
242
- export function normalizeToolPairing(messages) {
243
- // Detach all tool messages; reinsert each right after its owner assistant.
244
- const toolById = new Map()
245
- const rest = []
246
- for (const m of messages) {
247
- if (m.role === "tool") {
248
- if (!toolById.has(m.tool_call_id)) toolById.set(m.tool_call_id, m)
249
- } else {
250
- rest.push(m)
236
+ // Pre-send payload normalization lives in normalize.mjs (2026-08-31 extract,
237
+ // TODO #2); re-exported so provider/index.mjs and tool-pairing.test.mjs keep
238
+ // their import paths.
239
+ import { stripImagesForTextModel, normalizeToolPairing } from "./normalize.mjs"
240
+ export { stripImagesForTextModel, normalizeToolPairing }
241
+ /** Merge tool calls from a retry/continuation into the accumulated result.
242
+ * 2026-08-31 会诊 #7/#17:readSSE 输出的 tc 已 finalize(无 index 字段),
243
+ * 原实现恒 append(重试里 provider 重发完整 tc → tool 名 "get_weatherget_weather"、
244
+ * arguments 重复)。改按 id 定位已有槽位、无 id 才追加;name 只设一次。 */
245
+ function mergeRetryToolCalls(result, toolCalls) {
246
+ for (const tc of toolCalls ?? []) {
247
+ if (!tc) continue
248
+ let s
249
+ if (tc.id) {
250
+ s = result.toolCalls.find((x) => x && x.id === tc.id)
251
251
  }
252
- }
253
- if (toolById.size === 0 && !messages.some((m) => m.role === "assistant" && m.tool_calls?.length)) {
254
- return messages // no tool messages AND no tool_calls declared — nothing to enforce
255
- }
256
- const out = []
257
- for (const m of rest) {
258
- out.push(m)
259
- if (m.role !== "assistant" || !m.tool_calls?.length) continue
260
- for (const tc of m.tool_calls) {
261
- const t = toolById.get(tc.id)
262
- if (t) {
263
- toolById.delete(tc.id)
264
- out.push(t)
265
- } else {
266
- // Declared tool_call with no recorded result (interrupted session / compaction split)
267
- out.push({
268
- role: "tool",
269
- tool_call_id: tc.id,
270
- content: "[Tool result missing: the call was interrupted or its result was dropped by context compaction]",
271
- })
272
- }
252
+ if (!s) {
253
+ // id(synthetic call_N 在重试间不稳定)或未命中:按 name 找同 slot(重试语义
254
+ // 是"同一批工具调用重新执行",同名合并最稳);仍找不到才追加。
255
+ s = tc.name ? result.toolCalls.find((x) => x && x.name === tc.name) : undefined
273
256
  }
257
+ if (!s) {
258
+ s = { id: "", name: "", arguments: "" }
259
+ result.toolCalls.push(s)
260
+ }
261
+ if (tc.id && !s.id) s.id = tc.id
262
+ if (tc.name && !s.name) s.name = tc.name
263
+ s.arguments += tc.arguments ?? ""
274
264
  }
275
- // Leftovers in toolById are orphans (owner assistant compacted away or never recorded) — dropped
276
- return out
277
265
  }
278
266
 
279
267
  /** List available model IDs from the provider's /models endpoint */
280
268
  export async function listModels(provider, { signal } = {}) {
281
- const response = await fetch(`${provider.baseURL}/models`, {
269
+ // 2026-08-31 会诊 #10:与 chat 路径对齐——走 proxyUri、加 15s 超时、JSON 解析兜底
270
+ // (原实现直连 fetch 无超时无代理,慢/被墙域名的 /models 会挂死 UI)
271
+ const url = `${provider.baseURL}/models`
272
+ const opts = {
282
273
  headers: { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey}` },
283
- signal,
284
- })
274
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
275
+ _headerTimeoutMs: 15_000,
276
+ _bodyIdleMs: 15_000,
277
+ }
278
+ const response = await (provider.proxyUri ? proxyFetch(url, opts, provider.proxyUri) : fetch(url, opts))
285
279
  if (!response.ok) {
286
280
  const text = await response.text().catch(() => "")
287
281
  throw new Error(`GET /models failed ${response.status}: ${text}`)
288
282
  }
289
- const data = await response.json()
290
- return (data.data ?? []).map((m) => m.id).filter(Boolean).sort()
283
+ const data = await response.json().catch(() => null)
284
+ return (data?.data ?? []).map((m) => m.id).filter(Boolean).sort()
291
285
  }
292
286
 
293
287
  async function requestWithRetry(provider, body, signal, onWait) {
@@ -297,7 +291,7 @@ async function requestWithRetry(provider, body, signal, onWait) {
297
291
  let rateLimitHits = 0
298
292
  const totalAttempts = MAX_RETRIES + 1
299
293
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
300
- if (attempt > 0 && !lastWas429) await _rateHooks.sleep(2 ** (attempt - 1) * 1000)
294
+ if (attempt > 0 && !lastWas429) await sleepInterruptible(2 ** (attempt - 1) * 1000, signal)
301
295
  lastWas429 = false
302
296
 
303
297
  let response
@@ -312,6 +306,10 @@ async function requestWithRetry(provider, body, signal, onWait) {
312
306
  },
313
307
  body: JSON.stringify(body),
314
308
  signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) : AbortSignal.timeout(FETCH_TIMEOUT_MS),
309
+ // 2026-08-31 会诊 #4:代理路径响应头超时对齐直连语义(原 15s 与直连 600s 割裂,
310
+ // DeepSeek 排队 TTFB>15s 即误报)— 仅 _ 前缀内部字段,proxyFetch 消费
311
+ _headerTimeoutMs: FETCH_TIMEOUT_MS,
312
+ _bodyIdleMs: 120_000,
315
313
  }
316
314
  response = provider.proxyUri
317
315
  ? await proxyFetch(url, opts, provider.proxyUri)
@@ -326,10 +324,10 @@ async function requestWithRetry(provider, body, signal, onWait) {
326
324
 
327
325
  const text = await response.text().catch(() => "")
328
326
  let message = `LLM API error ${response.status}: ${text}`
329
- // Kimi has TWO separate platforms with non-interchangeable keys (IK5VGJ):
330
- // Moonshot (api.moonshot.cn, sk-...) vs Kimi For Coding (api.kimi.com/coding/v1, sk-kimi-...).
331
- // A 401 on either endpoint is usually a wrong-platform key — say so instead of a bare 401.
332
- if (response.status === 401) {
327
+ // 401 双平台提示 + 诊断回显(2026-08-31 会诊 #15):
328
+ // Kimi 双平台 key 不互通的提示保留;通用加 baseURL host + key 前 6 位掩码,
329
+ // 帮用户快速分辨"配错平台还是配错账号"。
330
+ if (response.status === 401 || response.status === 403) {
333
331
  const key = String(provider.apiKey ?? "").trim()
334
332
  const base = String(provider.baseURL ?? "").toLowerCase()
335
333
  const kimiCodeKey = /^sk-kimi-/i.test(key)
@@ -337,20 +335,20 @@ async function requestWithRetry(provider, body, signal, onWait) {
337
335
  if (kimiCodeKey || kimiCodeUrl) {
338
336
  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."
339
337
  }
338
+ const host = (() => { try { return new URL(provider.baseURL).host } catch { return provider.baseURL ?? "(unknown)" } })()
339
+ const masked = key.length > 8 ? key.slice(0, 6) + "…" + key.slice(-4) : (key ? key.slice(0, 4) + "…" : "(empty)")
340
+ message += ` [auth diag: baseURL=${host} key=${masked} status=${response.status}]`
340
341
  }
341
342
  lastStatus = response.status
342
343
  if (isNonRetryableError(response.status, text)) throw new Error(message)
343
344
  if (response.status === 429) {
344
- const retryAfter = Number(response.headers.get("retry-after"))
345
- const waitMs =
346
- Number.isFinite(retryAfter) && retryAfter > 0
347
- ? retryAfter * 1000
348
- : RATE_LIMIT_BACKOFF_MS[Math.min(rateLimitHits++, RATE_LIMIT_BACKOFF_MS.length - 1)]
345
+ const waitMs = parseRetryAfter(response.headers.get("retry-after"), rateLimitHits)
346
+ rateLimitHits++
349
347
  lastError = new Error(message)
350
348
  lastWas429 = true
351
349
  if (attempt < MAX_RETRIES) {
352
350
  onWait?.({ phase: "retry", seconds: Math.ceil(waitMs / 1000) })
353
- await _rateHooks.sleep(waitMs)
351
+ await sleepInterruptible(waitMs, signal)
354
352
  }
355
353
  continue
356
354
  }
@@ -365,7 +363,28 @@ async function requestWithRetry(provider, body, signal, onWait) {
365
363
  : lastStatus >= 500 ? "Server error persisted"
366
364
  : lastStatus > 0 ? "Request failed"
367
365
  : "Network error"
368
- throw new Error(`${verb} after ${totalAttempts} attempts${lastStatus ? ` (${lastStatus})` : ""}: ${lastError?.message ?? "unknown"}`)
366
+ // 会诊 #8:undici "fetch failed" 真因(ENOTFOUND/TLS/DNS/代理)藏在 error.cause
367
+ // 拼进去,全链路同一文案不再掩盖根因
368
+ const causeText = lastError?.cause
369
+ ? ` (${lastError.cause.code ?? lastError.cause.message ?? String(lastError.cause)})`
370
+ : ""
371
+ throw new Error(`${verb} after ${totalAttempts} attempts${lastStatus ? ` (${lastStatus})` : ""}: ${lastError?.message ?? "unknown"}${causeText}`)
372
+ }
373
+
374
+ /** Parse Retry-After: 秒数 or HTTP-date;上限 300s(会诊 #11)— 异常头不得让 CLI 睡数小时。
375
+ * header 缺失/非法时退回指数退避表(rateLimitHits 计数取档)。 */
376
+ export function parseRetryAfter(header, rateLimitHits = 0) {
377
+ const fallback = RATE_LIMIT_BACKOFF_MS[Math.min(rateLimitHits, RATE_LIMIT_BACKOFF_MS.length - 1)]
378
+ if (header == null) return fallback
379
+ let waitMs = 0
380
+ const numeric = Number(header.trim())
381
+ if (Number.isFinite(numeric) && numeric >= 0) waitMs = numeric * 1000
382
+ else {
383
+ const date = Date.parse(header.trim())
384
+ if (Number.isFinite(date)) waitMs = Math.max(0, date - Date.now())
385
+ }
386
+ if (waitMs <= 0) return fallback
387
+ return Math.min(waitMs, 300_000)
369
388
  }
370
389
 
371
390
  /**
@@ -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
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * provider/normalize.mjs — pre-send payload normalization (2026-08-31 extract).
3
+ *
4
+ * Split from core.mjs (TODO #2: 420 lines, past the 300 advisory). These two
5
+ * pure functions sanitize the message array right before it hits the wire;
6
+ * no dependency on chat()/retry logic. core.mjs re-exports them so
7
+ * provider/index.mjs and tool-pairing.test.mjs keep their import paths.
8
+ */
9
+ import { specForModel } from "../config.mjs"
10
+
11
+ const RASTER_IMAGE_URL = /^data:image\/(png|jpe?g|gif|webp);base64,/
12
+
13
+ export function stripImagesForTextModel(messages, spec) {
14
+ let changed = false
15
+ const out = messages.map((m) => {
16
+ if (!Array.isArray(m.content) || !m.content.some((p) => p?.type === "image_url")) return m
17
+ let msgChanged = false
18
+ const parts = m.content.map((p) => {
19
+ if (p?.type !== "image_url") return p
20
+ const url = p.image_url?.url || ""
21
+ if (!url.startsWith("data:")) return p
22
+ if (spec.multimodal && RASTER_IMAGE_URL.test(url)) return p
23
+ msgChanged = true
24
+ const reason = spec.multimodal
25
+ ? `unsupported format ${url.match(/^data:([^;,]+)/)?.[1] || "unknown"}`
26
+ : "this model does not support image input"
27
+ return { type: "text", text: `[image omitted — ${reason}]` }
28
+ })
29
+ if (!msgChanged) return m
30
+ changed = true
31
+ return { ...m, content: parts }
32
+ })
33
+ return changed ? out : messages
34
+ }
35
+
36
+ /**
37
+ * Enforce the OpenAI tool-message protocol on the outgoing payload: every tool message must
38
+ * immediately follow the assistant message declaring its tool_call_id, and every declared
39
+ * tool_call must have a result. Strict providers (DeepSeek) reject the whole request with 400
40
+ * ("Messages with role 'tool' must be a response to a preceding message with 'tool_calls'").
41
+ * History can legitimately violate this — parallel read_image injects a user message between
42
+ * tool results, compaction splits, interrupted sessions leave dangling tool_calls — so sanitize
43
+ * at send time. History itself is left untouched.
44
+ */
45
+ export function normalizeToolPairing(messages) {
46
+ // Detach all tool messages; reinsert each right after its owner assistant.
47
+ const toolById = new Map()
48
+ const rest = []
49
+ for (const m of messages) {
50
+ if (m.role === "tool") {
51
+ if (!toolById.has(m.tool_call_id)) toolById.set(m.tool_call_id, m)
52
+ } else {
53
+ rest.push(m)
54
+ }
55
+ }
56
+ if (toolById.size === 0 && !messages.some((m) => m.role === "assistant" && m.tool_calls?.length)) {
57
+ return messages // no tool messages AND no tool_calls declared — nothing to enforce
58
+ }
59
+ const out = []
60
+ for (const m of rest) {
61
+ out.push(m)
62
+ if (m.role !== "assistant" || !m.tool_calls?.length) continue
63
+ for (const tc of m.tool_calls) {
64
+ const t = toolById.get(tc.id)
65
+ if (t) {
66
+ toolById.delete(tc.id)
67
+ out.push(t)
68
+ } else {
69
+ // Declared tool_call with no recorded result (interrupted session / compaction split)
70
+ out.push({
71
+ role: "tool",
72
+ tool_call_id: tc.id,
73
+ content: "[Tool result missing: the call was interrupted or its result was dropped by context compaction]",
74
+ })
75
+ }
76
+ }
77
+ }
78
+ // Leftovers in toolById are orphans (owner assistant compacted away or never recorded) — dropped
79
+ return out
80
+ }
81
+
@@ -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