thincoder 0.12.52 → 0.12.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)
@@ -210,18 +238,50 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
210
238
  // their import paths.
211
239
  import { stripImagesForTextModel, normalizeToolPairing } from "./normalize.mjs"
212
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
+ }
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
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 ?? ""
264
+ }
265
+ }
266
+
213
267
  /** List available model IDs from the provider's /models endpoint */
214
268
  export async function listModels(provider, { signal } = {}) {
215
- 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 = {
216
273
  headers: { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey}` },
217
- signal,
218
- })
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))
219
279
  if (!response.ok) {
220
280
  const text = await response.text().catch(() => "")
221
281
  throw new Error(`GET /models failed ${response.status}: ${text}`)
222
282
  }
223
- const data = await response.json()
224
- 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()
225
285
  }
226
286
 
227
287
  async function requestWithRetry(provider, body, signal, onWait) {
@@ -231,7 +291,7 @@ async function requestWithRetry(provider, body, signal, onWait) {
231
291
  let rateLimitHits = 0
232
292
  const totalAttempts = MAX_RETRIES + 1
233
293
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
234
- if (attempt > 0 && !lastWas429) await _rateHooks.sleep(2 ** (attempt - 1) * 1000)
294
+ if (attempt > 0 && !lastWas429) await sleepInterruptible(2 ** (attempt - 1) * 1000, signal)
235
295
  lastWas429 = false
236
296
 
237
297
  let response
@@ -246,6 +306,10 @@ async function requestWithRetry(provider, body, signal, onWait) {
246
306
  },
247
307
  body: JSON.stringify(body),
248
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,
249
313
  }
250
314
  response = provider.proxyUri
251
315
  ? await proxyFetch(url, opts, provider.proxyUri)
@@ -260,10 +324,10 @@ async function requestWithRetry(provider, body, signal, onWait) {
260
324
 
261
325
  const text = await response.text().catch(() => "")
262
326
  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) {
327
+ // 401 双平台提示 + 诊断回显(2026-08-31 会诊 #15):
328
+ // Kimi 双平台 key 不互通的提示保留;通用加 baseURL host + key 前 6 位掩码,
329
+ // 帮用户快速分辨"配错平台还是配错账号"。
330
+ if (response.status === 401 || response.status === 403) {
267
331
  const key = String(provider.apiKey ?? "").trim()
268
332
  const base = String(provider.baseURL ?? "").toLowerCase()
269
333
  const kimiCodeKey = /^sk-kimi-/i.test(key)
@@ -271,20 +335,20 @@ async function requestWithRetry(provider, body, signal, onWait) {
271
335
  if (kimiCodeKey || kimiCodeUrl) {
272
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."
273
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}]`
274
341
  }
275
342
  lastStatus = response.status
276
343
  if (isNonRetryableError(response.status, text)) throw new Error(message)
277
344
  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)]
345
+ const waitMs = parseRetryAfter(response.headers.get("retry-after"), rateLimitHits)
346
+ rateLimitHits++
283
347
  lastError = new Error(message)
284
348
  lastWas429 = true
285
349
  if (attempt < MAX_RETRIES) {
286
350
  onWait?.({ phase: "retry", seconds: Math.ceil(waitMs / 1000) })
287
- await _rateHooks.sleep(waitMs)
351
+ await sleepInterruptible(waitMs, signal)
288
352
  }
289
353
  continue
290
354
  }
@@ -299,7 +363,28 @@ async function requestWithRetry(provider, body, signal, onWait) {
299
363
  : lastStatus >= 500 ? "Server error persisted"
300
364
  : lastStatus > 0 ? "Request failed"
301
365
  : "Network error"
302
- 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)
303
388
  }
304
389
 
305
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
 
@@ -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