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
|
@@ -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
|
+
}
|