thincoder 0.12.52 → 0.12.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/advisor/run.mjs +9 -11
- package/src/agent/dispatch.mjs +38 -13
- package/src/agent.mjs +34 -0
- package/src/cli/make-agent.mjs +11 -5
- package/src/mcp/helpers.mjs +14 -5
- package/src/mcp/transport-http.mjs +79 -27
- package/src/mcp/transport-stdio.mjs +57 -3
- package/src/mcp/transport-ws.mjs +46 -12
- package/src/mcp.mjs +197 -58
- package/src/prompts/discipline.md +43 -0
- package/src/provider/anthropic.mjs +51 -18
- package/src/provider/core.mjs +121 -36
- package/src/provider/google.mjs +41 -15
- package/src/provider/rate.mjs +5 -0
- package/src/provider/responses.mjs +498 -0
- package/src/provider/retry.mjs +125 -0
- package/src/provider/sse.mjs +58 -24
- package/src/proxy.mjs +36 -6
- package/src/tools/bash.md +2 -2
- package/src/tools/execute.md +1 -1
- package/src/tools/execute.mjs +3 -3
- package/src/tools/fetch.md +1 -0
- package/src/tools/file.mjs +136 -11
- package/src/tools/git.md +4 -2
- package/src/tools/git.mjs +35 -8
- package/src/tools/shared.mjs +5 -3
- package/src/tools/system.mjs +19 -1
- package/src/tools/web.mjs +44 -14
- package/src/tools/websearch.md +3 -1
- package/src/tui/fold-block.mjs +59 -11
- package/src/tui/index.mjs +56 -45
- package/src/tui/key-handler.mjs +3 -1
- package/src/tui/mouse.mjs +46 -7
- package/src/tui/render-conversation.mjs +225 -122
- package/src/tui/render-loop.mjs +10 -0
- package/src/tui/startup.mjs +1 -1
- package/src/tui/subagent-blocks.mjs +5 -1
- package/src/tui/tool-args.mjs +4 -0
package/src/provider/core.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
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
|
|
279
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
package/src/provider/google.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
package/src/provider/rate.mjs
CHANGED
|
@@ -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
|