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
@@ -0,0 +1,498 @@
1
+ /**
2
+ * provider/responses.mjs — OpenAI Responses API transport(2026-08-31,PROVIDER.md §13)
3
+ *
4
+ * format: "responses"。双轨设计:
5
+ * - 本地消息历史仍由 agent 层全量提供(事实源不变);本 transport 只决定"怎么发"。
6
+ * - 链模式(stateful):同一 turn 内工具往返使用 previous_response_id 增量发送,
7
+ * 跨 turn / 压缩 / 换模型自动重置(chainKey 不匹配即全量)——正确性不依赖服务端状态。
8
+ * - host 白名单:只有已实证支持 previous_response_id 的端点才开链(DeepSeek 官方明说
9
+ * 不支持参数被静默忽略——链发出去被忽略 = 只剩增量 input = 无声丢上下文,必须防)。
10
+ *
11
+ * 事件流规范:流以 response.completed / response.incomplete / response.failed 结束,
12
+ * 没有 "data: [DONE]"。
13
+ */
14
+ import { specForModel, isBailianHost } from "../config.mjs"
15
+ import { proxyFetch } from "../proxy.mjs"
16
+ import { requestWithRetry } from "./retry.mjs"
17
+ import { rateGate, recordRate, estimateRequestTokens } from "./rate.mjs"
18
+
19
+ const FETCH_TIMEOUT_MS = 600_000
20
+
21
+ /** 白名单:已实证 previous_response_id 的官方端(2026-08-31 真机验证:
22
+ * 百炼 store:true 全链路 ✅;GLM(open.bigmodel.cn/api/v1)store:true 全链路 ✅)。 */
23
+ function isStatefulHost(baseURL) {
24
+ try {
25
+ const host = new URL(baseURL).hostname
26
+ return /(^|\.)openai\.com$/.test(host) || isBailianHost(baseURL) || /(^|\.)bigmodel\.cn$/.test(host)
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ /** store 必开 host(链保留依赖 store:true——真机:百炼 store:false → 链 400;GLM 同)。
33
+ * OpenAI 官方 store:false 链仍可用,不在内。 */
34
+ export function isStoreRequiredHost(baseURL) {
35
+ try {
36
+ const host = new URL(baseURL).hostname
37
+ return isBailianHost(baseURL) || /(^|\.)bigmodel\.cn$/.test(host)
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ /** 灰名单:格式完整但链未证实/不支持——显式全量 + 一次性 warning(不靠服务端报错)。
44
+ * 2026-08-31 真机后仅剩 DeepSeek(官方明确 previous_response_id 不支持且参数静默忽略)。 */
45
+ function isNonStatefulHost(baseURL) {
46
+ try {
47
+ const host = new URL(baseURL).hostname
48
+ return /(^|\.)deepseek\.com$/.test(host)
49
+ } catch {
50
+ return false
51
+ }
52
+ }
53
+
54
+ /** 链 key:system 部分 + 最后一条 user 消息(turn 内不变、跨 turn 变、压缩后变)。 */
55
+ function chainKey(messages) {
56
+ let sig = "s:"
57
+ let lastUser = ""
58
+ for (const m of messages ?? []) {
59
+ if (m.role === "system") sig += (typeof m.content === "string" ? m.content : "") + "\u0001"
60
+ else if (m.role === "user") lastUser = typeof m.content === "string" ? m.content : ""
61
+ }
62
+ return sig + "\u0002u:" + lastUser
63
+ }
64
+
65
+ /** 内置工具声明(2026-08-31 用户拍板"内置工具还是要用",一期 web_search)。
66
+ * 按 host 映射默认集;provider.builtinTools === false 关闭、数组显式覆盖。
67
+ * 注意:内置工具由**服务端执行**——绕过我们的工具权限门/审计(产品决策,用户拍板)。 */
68
+ export function builtinToolsFor(baseURL, providerBuiltin) {
69
+ if (providerBuiltin === false) return []
70
+ if (Array.isArray(providerBuiltin)) return providerBuiltin
71
+ try {
72
+ const host = new URL(baseURL).hostname
73
+ if (/(^|\.)openai\.com$/.test(host) || isBailianHost(baseURL) || /(^|\.)deepseek\.com$/.test(host)) {
74
+ return [{ type: "web_search" }]
75
+ }
76
+ } catch { /* fallthrough */ }
77
+ return []
78
+ }
79
+
80
+ /** OpenAI Chat 消息 → Responses input items。system 提升为 instructions(不进 input)。
81
+ * 内置工具结果(web_search_call 本地化 tool 消息)→ 原样 web_search_call item 回传。 */
82
+ function toItems(messages, { instructions } = {}) {
83
+ const items = []
84
+ for (const m of messages ?? []) {
85
+ if (m.role === "system") continue
86
+ if (typeof m.tool_call_id === "string" && m.tool_call_id.startsWith("web_search_call_") && typeof m.content === "string") {
87
+ // 内置工具结果本地化消息 → 原样回传(DeepSeek 官方:web_search_call 原样回传即可,
88
+ // 服务端自动恢复搜索结果)。id 用 content 里的原始服务端 id(msg_xxx),前缀只是本地锚点。
89
+ let query = ""
90
+ let srcs = []
91
+ let wsId = m.tool_call_id.slice("web_search_call_".length)
92
+ try {
93
+ const parsed = JSON.parse(m.content)
94
+ query = parsed.query ?? ""
95
+ srcs = parsed.sources ?? []
96
+ if (parsed.id) wsId = parsed.id
97
+ } catch { /* content 非 JSON(纯展示)→ query 缺省 */ }
98
+ items.push({ type: "web_search_call", id: wsId, status: "completed", action: { query, type: "search", sources: srcs } })
99
+ continue
100
+ }
101
+ if (m.role === "user") {
102
+ const content = m.content
103
+ if (Array.isArray(content)) {
104
+ items.push({
105
+ role: "user",
106
+ content: content
107
+ .map((p) => (p?.type === "image_url"
108
+ ? { type: "input_image", image_url: p.image_url?.url }
109
+ : p?.type === "text" || typeof p === "string"
110
+ ? { type: "input_text", text: typeof p === "string" ? p : p.text }
111
+ : null))
112
+ .filter(Boolean),
113
+ })
114
+ } else {
115
+ items.push({ role: "user", content: [{ type: "input_text", text: String(content ?? "") }] })
116
+ }
117
+ } else if (m.role === "assistant") {
118
+ const tcList = m.tool_calls ?? []
119
+ items.push({ role: "assistant", content: [{ type: "output_text", text: String(m.content ?? "") }] })
120
+ for (const tc of tcList) {
121
+ items.push({
122
+ type: "function_call",
123
+ call_id: tc.id ?? "",
124
+ name: tc.function?.name ?? "",
125
+ arguments: tc.function?.arguments ?? "{}",
126
+ })
127
+ }
128
+ } else if (m.role === "tool" || m.role === "function") {
129
+ items.push({
130
+ type: "function_call_output",
131
+ call_id: m.tool_call_id ?? m.name ?? "",
132
+ output: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""),
133
+ })
134
+ }
135
+ }
136
+ return { items, instructions: instructions ?? (messages ?? []).find((m) => m.role === "system")?.content ?? "" }
137
+ }
138
+
139
+ /** OpenAI 工具 schema → Responses 扁平 tools。 */
140
+ function toTools(tools) {
141
+ return (tools ?? []).map((t) => ({
142
+ type: "function",
143
+ name: t.function?.name ?? t.name,
144
+ description: t.function?.description ?? t.description,
145
+ parameters: t.function?.parameters ?? t.parameters ?? { type: "object", properties: {} },
146
+ }))
147
+ }
148
+
149
+ /** Responses usage → 内部 cache 字段形状。 */
150
+ function normalizeUsage(usage) {
151
+ if (!usage) return null
152
+ return {
153
+ prompt_tokens: usage.input_tokens ?? 0,
154
+ completion_tokens: usage.output_tokens ?? 0,
155
+ total_tokens: usage.total_tokens ?? (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0),
156
+ prompt_cache_hit_tokens: usage.input_tokens_details?.cached_tokens ?? 0,
157
+ prompt_cache_miss_tokens: Math.max(0, (usage.input_tokens ?? 0) - (usage.input_tokens_details?.cached_tokens ?? 0)),
158
+ }
159
+ }
160
+
161
+ /**
162
+ * 组装请求体 + 链状态机决策。
163
+ * @returns {{ body, previousResponseId, warnings, newChain: {id,key}|null }}
164
+ */
165
+ export function buildBody(provider, messages, tools, opts = {}) {
166
+ const { instructions: extracted, items } = toItems(messages)
167
+ const toolsFlat = tools?.length ? toTools(tools) : undefined
168
+ const spec = specForModel(provider.model)
169
+ const warnings = []
170
+ const wantStateful = provider.stateful !== false && opts.stateful !== false
171
+ const hostStateful = isStatefulHost(provider.baseURL)
172
+ const hostNonStateful = isNonStatefulHost(provider.baseURL)
173
+
174
+ let chain = provider._responsesChain ?? null
175
+ const key = chainKey(messages)
176
+
177
+ if (!wantStateful) {
178
+ chain = null // stateful:false 显式覆盖:残留链(同 session 开过)必须作废
179
+ } else if (hostNonStateful && !opts.forceStateful) {
180
+ // 灰名单:链不支持/未证实(DeepSeek 静默忽略 → 无声丢上下文)——显式全量 + 一次警告
181
+ if (wantStateful) {
182
+ warnings.push({ name: "responses-stateful-unsupported", message: "endpoint 未实证支持 previous_response_id;已发送全量上下文(可 provider.stateful=false 关闭此消息)" })
183
+ }
184
+ chain = null
185
+ } else if (chain && chain.key !== key) {
186
+ chain = null // 跨 turn/压缩/换模型:链失效,全量重建
187
+ } else if (chain && !hostStateful && !opts.forceStateful) {
188
+ chain = null // 非白名单 host 且无显式 forceStateful:不冒险开链
189
+ }
190
+ // 2026-08-31 真机冒烟:百炼/GLM 开链 = 云端留存 7 天——首次知情警告(不刷屏)
191
+ if (wantStateful && hostStateful && isStoreRequiredHost(provider.baseURL) && !provider._responsesStoreWarned) {
192
+ provider._responsesStoreWarned = true
193
+ warnings.push({ name: "responses-store-retention", message: "链生效需要 store:true——对话将在云端留存 7 天(PROVIDER.md §13.3 D10;provider.stateful=false 可退出)" })
194
+ }
195
+
196
+ const body = {
197
+ model: provider.model,
198
+ input: items, // 占位:chain 有效时下方替换为增量
199
+ stream: true,
200
+ // 2026-08-31 真机冒烟实锤:百炼/GLM 链要求 R1 store:true(store:false → 链 400
201
+ // Not found);OpenAI 官方 store:false 链仍可用。开链时 = 对话在云端留存 7 天
202
+ // (警告上报);灰名单全量 store:false。
203
+ store: wantStateful && hostStateful && isStoreRequiredHost(provider.baseURL),
204
+ ...(extracted ? { instructions: extracted } : {}),
205
+ ...(toolsFlat ? { tools: toolsFlat } : {}),
206
+ ...(spec.maxOutput || provider.maxTokens ? { max_output_tokens: provider.maxTokens ?? spec.maxOutput } : {}),
207
+ }
208
+ // 内置工具声明追加(2026-08-31 用户拍板):web_search 与本地 function 工具共存
209
+ const builtin = builtinToolsFor(provider.baseURL, provider.builtinTools)
210
+ if (builtin.length) body.tools = [...(toolsFlat ?? []), ...builtin]
211
+ if (provider.temperature != null) {
212
+ let t = provider.temperature
213
+ if (spec.tempRange) {
214
+ t = Math.min(spec.tempRange[1], Math.max(spec.tempRange[0], t))
215
+ t = Math.round(t * 100) / 100 // 与 core.mjs 同语义(round3 #7)
216
+ }
217
+ body.temperature = t
218
+ }
219
+ if (provider.reasoningEffort) body.reasoning = { effort: provider.reasoningEffort }
220
+ if (opts.toolChoice !== undefined) body.tool_choice = opts.toolChoice
221
+
222
+ // 链模式:turn 内增量 = 上一链轮未发送的 function_call_output(工具结果)。
223
+ // 注意:assistant 的 function_call item 与服务端链输出重复(服务端自动含上轮 output),
224
+ // 增量只发工具结果即可;新 user 消息/压缩/换模型已由 chainKey 挡掉 → 全量。
225
+ const outputs = items.filter((i) => i.type === "function_call_output")
226
+ let previousResponseId = null
227
+ if (chain && chain.id) {
228
+ const newOutputs = outputs.slice(chain.outputSent ?? 0)
229
+ if (newOutputs.length === 0) {
230
+ chain = null // 无新增(重复调用/异常重试):退化为全量,正确性优先
231
+ } else {
232
+ body.input = newOutputs
233
+ previousResponseId = chain.id
234
+ }
235
+ } else {
236
+ body.input = items
237
+ }
238
+
239
+ const newChain = chain
240
+ ? { ...chain, key, outputSent: outputs.length }
241
+ : (wantStateful && (hostStateful || opts.forceStateful) ? { id: null, key, outputSent: outputs.length } : null)
242
+
243
+ return { body, previousResponseId, warnings, newChain }
244
+ }
245
+
246
+ /** 链失效回退:404/无效 id → 返回 null 表示"应全量重发";其他失败抛错。 */
247
+ export function isChainInvalidError(status) {
248
+ return status === 404 || status === 400
249
+ }
250
+
251
+ /**
252
+ * Responses 事件流解析(事件状态机)。输出与 chat completions 同形:
253
+ * { content, reasoning, toolCalls, usage, finishReason, interrupted? }
254
+ */
255
+ export async function parseStream(response, { onToken, onReasoning, signal }) {
256
+ const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
257
+ const slots = new Map() // call_id → { id, name, arguments }
258
+ const itemToCall = new Map() // item_id → call_id(delta 事件用 item_id 定位)
259
+ const order = [] // 槽顺序(output_index 稳定输出)
260
+ result.builtinToolResults = [] // 内置工具(web_search_call)结果 —— agent 层本地化为 tool 消息
261
+
262
+ const seal = (finalResponse) => {
263
+ result.toolCalls = order.map((callId) => slots.get(callId)).filter(Boolean)
264
+ if (finalResponse?.usage) result.usage = normalizeUsage(finalResponse.usage)
265
+ if (finalResponse?.id) result.responseId = finalResponse.id
266
+ return result
267
+ }
268
+
269
+ try {
270
+ await readResponseStream(response, {
271
+ onToken: (t) => { result.content += t; onToken?.(t) },
272
+ onReasoning: (t) => { result.reasoning += t; onReasoning?.(t) },
273
+ onBuiltinWebSearch: (r) => { result.builtinToolResults.push(r) },
274
+ onFunctionCall: (callId, name, itemId) => {
275
+ if (!slots.has(callId)) {
276
+ slots.set(callId, { id: callId, name, arguments: "" })
277
+ order.push(callId)
278
+ }
279
+ if (itemId) itemToCall.set(itemId, callId)
280
+ },
281
+ onFunctionArgsDelta: (itemId, delta) => {
282
+ const callId = itemToCall.get(itemId)
283
+ const slot = callId ? slots.get(callId) : null
284
+ if (slot) slot.arguments += delta
285
+ },
286
+ onFunctionDone: (callId, fullArgs) => {
287
+ const slot = slots.get(callId)
288
+ if (!slot) return
289
+ if (fullArgs && fullArgs !== slot.arguments) slot.arguments = fullArgs
290
+ },
291
+ onCompleted: seal,
292
+ onIncomplete: (resp) => {
293
+ seal(resp)
294
+ // 非长度原因(content_filter 等)不能报成 "length"——agent 层 322 行按原因给用户提示
295
+ result.finishReason = resp?.incomplete_details?.reason === "content_filter" ? "content_filter" : "length"
296
+ },
297
+ onFailed: (resp) => {
298
+ const err = resp?.error
299
+ const msg = err?.message ?? JSON.stringify(err ?? {}).slice(0, 500)
300
+ const e = new Error(`responses API failed: ${msg}`)
301
+ e.status = resp?.error?.code
302
+ throw e
303
+ },
304
+ })
305
+ } catch (e) {
306
+ // 用户 Ctrl+I 中断:与 core 同构——提交已生成部分(agent 层 interrupted 分支消费)——
307
+ // 不丢已流出的 token;超时/网络错误仍照常抛(不应伪装成 interrupted)
308
+ if (e?.name === "AbortError" && signal?.aborted && signal?.reason?.interrupt) {
309
+ seal(null)
310
+ return { ...result, interrupted: true, interruptMessage: signal.reason.message }
311
+ }
312
+ throw e
313
+ }
314
+
315
+ // 无显式 finished 事件(流异常结束)时也收尾
316
+ if (result.toolCalls.length === 0 && order.length === 0) seal(null)
317
+ return result
318
+ }
319
+
320
+ /** 事件流核心循环(SSE data: 帧,event 序列由 data 内 type 字段标识)。 */
321
+ async function readResponseStream(response, handlers) {
322
+ const decoder = new TextDecoder()
323
+ let buffer = ""
324
+ for await (const chunk of response.body) {
325
+ buffer += decoder.decode(chunk, { stream: true })
326
+ let idx
327
+ while ((idx = buffer.indexOf("\n\n")) >= 0) {
328
+ const frame = buffer.slice(0, idx)
329
+ buffer = buffer.slice(idx + 2)
330
+ // 2026-08-31 真机冒烟:①百炼 SSE 帧为 `data:{…}` 无空格(OpenAI/DeepSeek 带空格)——
331
+ // slice(5).trim() 兼容;②帧 event: 头行(百炼 `event:error` 形态:data 无 type 字段,
332
+ // HTTP 200 内嵌业务 400——原实现静默吞掉 = 空内容当回复,必须识别后抛错)。
333
+ const eventHeader = frame.split("\n").find((l) => l.startsWith("event:"))?.slice(6).trim() ?? ""
334
+ const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("\n")
335
+ if (!data) continue
336
+ let ev
337
+ try { ev = JSON.parse(data) } catch {
338
+ if (eventHeader === "error") throw new Error(`responses API error frame: ${data.slice(0, 300)}`)
339
+ continue
340
+ }
341
+ if (eventHeader === "error" && !ev.type) {
342
+ const e = new Error(`responses API error ${ev.code ?? ev.status ?? ""}: ${ev.message ?? JSON.stringify(ev).slice(0, 300)}`)
343
+ e.status = 400
344
+ throw e
345
+ }
346
+ handleEvent(ev, handlers)
347
+ }
348
+ }
349
+ buffer += decoder.decode()
350
+ const data = buffer.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("\n")
351
+ if (data) {
352
+ let ev
353
+ try { ev = JSON.parse(data) } catch { return }
354
+ // 残余帧(无 \n\n 定界)同样走完整事件语义:error/failed 帧的错误必须传播——
355
+ // 静默吞 = 空内容当回复(2026-08-31 真机冒烟同类别缺陷,尾部边界版本)
356
+ handleEvent(ev, handlers)
357
+ }
358
+ }
359
+
360
+ function handleEvent(ev, h) {
361
+ switch (ev.type) {
362
+ case "response.output_item.added": {
363
+ const item = ev.item ?? {}
364
+ if (item.type === "function_call") h.onFunctionCall(item.call_id ?? item.id ?? "", item.name ?? "", item.id ?? "")
365
+ break
366
+ }
367
+ case "response.output_text.delta":
368
+ h.onToken?.(ev.delta ?? "")
369
+ break
370
+ case "response.content_part.delta": {
371
+ // OpenRouter 变体(2026-08-31 官方文档核实):事件名 content_part.delta,part.type 区分
372
+ // output_text / reasoning_text;另以 response.done + data:[DONE] 收尾
373
+ const part = ev.part ?? {}
374
+ if (part.type === "reasoning_text") h.onReasoning?.(ev.delta ?? "")
375
+ else h.onToken?.(ev.delta ?? "")
376
+ break
377
+ }
378
+ case "response.done":
379
+ h.onCompleted?.(ev.response)
380
+ break
381
+ case "response.reasoning_text.delta":
382
+ h.onReasoning?.(ev.delta ?? "")
383
+ break
384
+ case "response.function_call_arguments.delta":
385
+ h.onFunctionArgsDelta?.(ev.item_id ?? "", ev.delta ?? "")
386
+ break
387
+ case "response.output_item.done": {
388
+ const item = ev.item ?? {}
389
+ if (item.type === "function_call") h.onFunctionDone?.(item.call_id ?? item.id ?? "", item.arguments ?? "")
390
+ else if (item.type === "web_search_call") {
391
+ h.onBuiltinWebSearch?.({
392
+ id: item.id ?? "",
393
+ query: item.action?.query ?? "",
394
+ status: item.status ?? "completed",
395
+ sources: item.action?.sources ?? [],
396
+ })
397
+ }
398
+ break
399
+ }
400
+ case "response.completed":
401
+ h.onCompleted?.(ev.response)
402
+ break
403
+ case "response.incomplete":
404
+ h.onIncomplete?.(ev.response)
405
+ break
406
+ case "response.failed":
407
+ h.onFailed?.(ev.response)
408
+ break
409
+ default:
410
+ break
411
+ }
412
+ }
413
+
414
+ /** 主入口:请求 + 链状态推进(与 core.mjs 的 chat 同形返回)。 */
415
+ export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, stateful }) {
416
+ const { body: reqBody, previousResponseId, warnings, newChain } = buildBody(provider, messages, tools, { toolChoice, stateful })
417
+ const body = { ...reqBody, ...(previousResponseId ? { previous_response_id: previousResponseId } : {}) }
418
+
419
+ // rateGate/recordRate 对齐 core(responses body 无 messages 键——按本地全量 messages 估算)
420
+ const estimated = estimateRequestTokens({ messages })
421
+ await rateGate(provider, estimated, onWait, signal)
422
+
423
+ // round2 复验 #1(2026-08-31):retry 层对 4xx 非可重试是 throw 而非返回——
424
+ // requestWithRetry 从不返回 400/404 响应 → 下方 isChainInvalidError 分支原来不可达(D6 死代码)。
425
+ // 修复:catch 出错(e.status 已由 retry.mjs 挂上)→ 链失效时清链全量重发一次(仅一次,防死循环)。
426
+ let response
427
+ try {
428
+ response = await requestWithRetry(
429
+ () => proxyFetch(`${provider.baseURL}/responses`, {
430
+ method: "POST",
431
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
432
+ body: JSON.stringify(body),
433
+ signal: signal
434
+ ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
435
+ : AbortSignal.timeout(FETCH_TIMEOUT_MS),
436
+ _headerTimeoutMs: FETCH_TIMEOUT_MS,
437
+ _bodyIdleMs: 120_000,
438
+ }, provider.proxyUri),
439
+ { signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
440
+ )
441
+ } catch (chainErr) {
442
+ if (!isChainInvalidError(chainErr?.status)) throw chainErr
443
+ // 链失效(404/400):先清残留链再重建——D6 语义是真·全量重发(body.input=items)。
444
+ // 不清链会走 buildBody 217-224 的增量分支:body.input = 裸 function_call_output 且
445
+ // previousResponseId 未随 fullBody 带走 → 服务端 call_id 无归属 → 二次 400(2026-08-31 评审 #1)
446
+ provider._responsesChain = null
447
+ const fresh = buildBody(provider, messages, tools, { toolChoice, stateful, forceStateful: true })
448
+ const fullBody = { ...fresh.body }
449
+ response = await requestWithRetry(
450
+ () => proxyFetch(`${provider.baseURL}/responses`, {
451
+ method: "POST",
452
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
453
+ body: JSON.stringify(fullBody),
454
+ signal: signal
455
+ ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
456
+ : AbortSignal.timeout(FETCH_TIMEOUT_MS),
457
+ _headerTimeoutMs: FETCH_TIMEOUT_MS,
458
+ _bodyIdleMs: 120_000,
459
+ }, provider.proxyUri),
460
+ { signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
461
+ )
462
+ }
463
+ if (response.ok === false && isChainInvalidError(response.status)) {
464
+ // 2026-08-31 round3 #1:requestWithRetry 唯一返回路径是 response.ok(非 2xx 全部 throw)
465
+ // ——本分支不可达(retry 语义回归时才会走到)。仅作防御:与 catch 分支同处置——
466
+ // 清链后重发 body 仍带旧 previous_response_id(412 注入)是错的——重建全量。
467
+ provider._responsesChain = null
468
+ const fresh2 = buildBody(provider, messages, tools, { toolChoice, stateful, forceStateful: true })
469
+ response = await requestWithRetry(
470
+ () => proxyFetch(`${provider.baseURL}/responses`, {
471
+ method: "POST",
472
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
473
+ body: JSON.stringify(fresh2.body),
474
+ signal: signal
475
+ ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
476
+ : AbortSignal.timeout(FETCH_TIMEOUT_MS),
477
+ _headerTimeoutMs: FETCH_TIMEOUT_MS,
478
+ _bodyIdleMs: 120_000,
479
+ }, provider.proxyUri),
480
+ { signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
481
+ )
482
+ }
483
+ return finish(provider, response, { onToken, onReasoning, signal, newChain, warnings, estimated })
484
+ }
485
+
486
+ async function finish(provider, response, { onToken, onReasoning, signal, newChain, warnings, estimated }) {
487
+ const result = await parseStream(response, { onToken, onReasoning, signal })
488
+ recordRate(provider, estimated, result.usage)
489
+ // 链状态推进:completed 事件里 response.id 供同一 turn 后续增量;
490
+ // 截断/失败/无 id(部分端点不回传)→ 链作废(后续全量,正确性优先)。
491
+ if (newChain && result.finishReason !== "length" && result.responseId) {
492
+ provider._responsesChain = { ...newChain, id: result.responseId }
493
+ } else {
494
+ provider._responsesChain = null
495
+ }
496
+ result._warnings = warnings
497
+ return result
498
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * provider/retry.mjs — 通用请求重试链(2026-08-31)
3
+ * anthropic/google 曾只处理 429 单次重试(google 完全无重试)——5xx/网络错误与
4
+ * OpenAI 格式(core.mjs requestWithRetry)语义割裂:DeepSeek/Claude 排队 503 时
5
+ * OpenAI 格式会自动退避重试,其他两格式直接抛错。
6
+ * 本模块提供与 core 等价的退避链:2^(n-1)s 指数退避、429 Retry-After(秒/HTTP-date、
7
+ * 300s 上限)、RETRYABLE_STATUS、AbortError 透传、cause 解包。
8
+ * 测试钩子走 rate.mjs 的 _rateHooks.sleep(与 core 同一替换点)。 */
9
+ import { RETRYABLE_STATUS, MAX_RETRIES, RATE_LIMIT_BACKOFF_MS, _rateHooks } from "./rate.mjs"
10
+
11
+ /** 计费/配额类 429 特征(与 core.mjs isNonRetryableError 同源——round3 #4:
12
+ * 余额/配额耗尽时立即抛错,不按限流干等 15/30/60s 后报泛化错误)。 */
13
+ function isQuotaExhausted(text) {
14
+ return /余额不足|充值|insufficient_quota|quota exhausted|billing|1113|1114/i.test(text ?? "")
15
+ }
16
+
17
+ /** Parse Retry-After: 秒数 or HTTP-date;上限 300s(与 core.mjs parseRetryAfter 同语义,
18
+ * 无 core 依赖复制于此——anthropic/google 引入 core 会造成循环依赖)。 */
19
+ export function parseRetryAfter(header, rateLimitHits = 0) {
20
+ const fallback = RATE_LIMIT_BACKOFF_MS[Math.min(rateLimitHits, RATE_LIMIT_BACKOFF_MS.length - 1)]
21
+ if (header == null) return fallback
22
+ let waitMs = 0
23
+ const numeric = Number(header.trim())
24
+ if (Number.isFinite(numeric) && numeric >= 0) waitMs = numeric * 1000
25
+ else {
26
+ const date = Date.parse(header.trim())
27
+ if (Number.isFinite(date)) waitMs = Math.max(0, date - Date.now())
28
+ }
29
+ if (waitMs <= 0) return fallback
30
+ return Math.min(waitMs, 300_000)
31
+ }
32
+
33
+ /** 可中断 sleep(与 core.mjs sleepInterruptible 同语义)。 */
34
+ export async function sleepInterruptible(ms, signal) {
35
+ if (!signal) return _rateHooks.sleep(ms)
36
+ if (signal.aborted) throw abortDOM(signal)
37
+ return new Promise((resolve, reject) => {
38
+ const onAbort = () => { signal.removeEventListener("abort", onAbort); reject(abortDOM(signal)) }
39
+ signal.addEventListener("abort", onAbort, { once: true })
40
+ _rateHooks.sleep(ms).then(
41
+ () => { signal.removeEventListener("abort", onAbort); resolve() },
42
+ (e) => { signal.removeEventListener("abort", onAbort); reject(e) },
43
+ )
44
+ })
45
+ }
46
+
47
+ function abortDOM(signal) {
48
+ const e = new DOMException("The operation was aborted", "AbortError")
49
+ e.reason = signal.reason
50
+ return e
51
+ }
52
+
53
+ /**
54
+ * 通用退避重试链。request() 每次尝试建连(返回 Response);buildMessage(status, text)
55
+ * 由调用方生成错误文案(可含 provider 特有诊断)。
56
+ * 返回 ok 的 Response;重试耗尽抛 Error(文案含 cause 解包,见会诊 #8)。
57
+ */
58
+ export async function requestWithRetry(request, {
59
+ signal, onWait, maxAttempts = MAX_RETRIES + 1, buildMessage,
60
+ } = {}) {
61
+ let lastError
62
+ let lastStatus = 0
63
+ let lastWas429 = false
64
+ let rateLimitHits = 0
65
+
66
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
67
+ if (attempt > 0 && !lastWas429) await sleepInterruptible(2 ** (attempt - 1) * 1000, signal)
68
+ lastWas429 = false
69
+
70
+ let response
71
+ try {
72
+ response = await request()
73
+ } catch (error) {
74
+ if (error.name === "AbortError") throw error
75
+ lastError = error
76
+ continue
77
+ }
78
+
79
+ if (response.ok) return response
80
+
81
+ const text = await response.text().catch(() => "")
82
+ const message = buildMessage ? buildMessage(response.status, text) : `LLM API error ${response.status}: ${text}`
83
+ lastStatus = response.status
84
+
85
+ if (response.status === 401 || response.status === 403) {
86
+ const e = new Error(message); e.status = response.status; throw e
87
+ }
88
+ // 4xx 非 429 非可重试:无重试直接抛——带 status 供 responses chat() 的 D6 链失效回退识别(2026-08-31 round2 复验 #1)
89
+ if (response.status >= 400 && response.status < 500 && response.status !== 429 && !RETRYABLE_STATUS.has(response.status)) {
90
+ const e = new Error(message); e.status = response.status; throw e
91
+ }
92
+
93
+ if (response.status === 429) {
94
+ // 计费/配额类 429(余额不足/充值、insufficient_quota 等)不是限流:重试只会干等
95
+ // 后报泛化错误——与 core.mjs 的 isNonRetryableError 同语义,立即抛错(round3 #4)
96
+ if (isQuotaExhausted(text)) {
97
+ onWait?.({ phase: "quota", message: `quota exhausted: ${text.slice(0, 200)}` })
98
+ const e = new Error(message); e.status = 429; throw e
99
+ }
100
+ const waitMs = parseRetryAfter(response.headers.get("retry-after"), rateLimitHits)
101
+ rateLimitHits++
102
+ lastError = new Error(message)
103
+ lastWas429 = true
104
+ if (attempt < maxAttempts - 1) {
105
+ onWait?.({ phase: "retry", seconds: Math.ceil(waitMs / 1000) })
106
+ await sleepInterruptible(waitMs, signal)
107
+ }
108
+ continue
109
+ }
110
+ if (RETRYABLE_STATUS.has(response.status)) {
111
+ lastError = new Error(message)
112
+ continue
113
+ }
114
+ throw new Error(message)
115
+ }
116
+
117
+ const verb = lastWas429 ? "Rate limit not resolved"
118
+ : lastStatus >= 500 ? "Server error persisted"
119
+ : lastStatus > 0 ? "Request failed"
120
+ : "Network error"
121
+ const causeText = lastError?.cause
122
+ ? ` (${lastError.cause.code ?? lastError.cause.message ?? String(lastError.cause)})`
123
+ : ""
124
+ throw new Error(`${verb} after ${maxAttempts} attempts${lastStatus ? ` (${lastStatus})` : ""}: ${lastError?.message ?? "unknown"}${causeText}`)
125
+ }