thincoder 0.7.8 → 0.8.1
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/README.md +32 -13
- package/bin/thincoder.js +4 -0
- package/bin/thincoder.mjs +27 -346
- package/package.json +2 -2
- package/src/agent/dispatch.mjs +98 -0
- package/src/agent/helpers.mjs +185 -0
- package/src/agent/setup.mjs +117 -0
- package/src/agent-tools/goal.mjs +71 -0
- package/src/agent-tools/plan.mjs +31 -0
- package/src/agent-tools/recent-changes.mjs +23 -0
- package/src/agent-tools/skill.mjs +46 -0
- package/src/agent-tools/subagent.mjs +113 -0
- package/src/agent-tools/task.mjs +67 -0
- package/src/agent-tools/verify.mjs +198 -0
- package/src/agent-tools.mjs +12 -0
- package/src/agent.mjs +90 -1040
- package/src/cli/distill-command.mjs +85 -0
- package/src/cli/make-agent.mjs +85 -0
- package/src/cli/memory-command.mjs +63 -0
- package/src/cli/permission.mjs +41 -0
- package/src/cli/setup-wizard.mjs +70 -0
- package/src/config.mjs +5 -8
- package/src/context.mjs +10 -13
- package/src/distill.mjs +4 -3
- package/src/embedding.mjs +4 -2
- package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
- package/src/mcp/helpers.mjs +37 -0
- package/src/mcp/transport-http.mjs +176 -0
- package/src/mcp/transport-stdio.mjs +84 -0
- package/src/mcp/transport-ws.mjs +87 -0
- package/src/mcp.mjs +4 -428
- package/src/memory/code-index.mjs +211 -0
- package/src/memory/code-sync.mjs +306 -0
- package/src/memory/core.mjs +277 -0
- package/src/memory/docs.mjs +262 -0
- package/src/memory/schema.mjs +426 -0
- package/src/memory.mjs +12 -1403
- package/src/provider/core.mjs +239 -0
- package/src/provider/index.mjs +6 -0
- package/src/provider/rate.mjs +104 -0
- package/src/session.mjs +18 -5
- package/src/tools/bash.mjs +144 -0
- package/src/tools/file.mjs +205 -0
- package/src/tools/git.mjs +166 -0
- package/src/tools/glob.mjs +51 -0
- package/src/tools/grep.mjs +100 -0
- package/src/tools/index.mjs +22 -0
- package/src/tools/ls.mjs +36 -0
- package/src/tools/patch.mjs +226 -0
- package/src/tools/repomap-parse.mjs +168 -0
- package/src/tools/shared.mjs +257 -0
- package/src/tools/system.mjs +336 -0
- package/src/tools/web.mjs +121 -0
- package/src/tools.mjs +2 -1194
- package/src/tui/agent-turn.mjs +254 -0
- package/src/tui/ansi.mjs +32 -0
- package/src/tui/clipboard.mjs +48 -0
- package/src/tui/cmd-auto.mjs +21 -0
- package/src/tui/cmd-clear.mjs +26 -0
- package/src/tui/cmd-config.mjs +72 -0
- package/src/tui/cmd-exit.mjs +5 -0
- package/src/tui/cmd-extract.mjs +5 -0
- package/src/tui/cmd-goal.mjs +47 -0
- package/src/tui/cmd-help.mjs +25 -0
- package/src/tui/cmd-init.mjs +91 -0
- package/src/tui/cmd-mcp.mjs +146 -0
- package/src/tui/cmd-model.mjs +7 -0
- package/src/tui/cmd-new.mjs +18 -0
- package/src/tui/cmd-plan.mjs +21 -0
- package/src/tui/cmd-reindex.mjs +44 -0
- package/src/tui/cmd-restore.mjs +39 -0
- package/src/tui/cmd-session.mjs +42 -0
- package/src/tui/cmd-skills.mjs +17 -0
- package/src/tui/cmd-think.mjs +56 -0
- package/src/tui/config-helpers.mjs +34 -0
- package/src/tui/distill-cmd.mjs +45 -0
- package/src/tui/index.mjs +330 -0
- package/src/tui/interaction.mjs +79 -0
- package/src/tui/key-handler.mjs +267 -0
- package/src/tui/layout.mjs +115 -0
- package/src/tui/pickers.mjs +279 -0
- package/src/tui/render-frame.mjs +304 -0
- package/src/tui/render.mjs +205 -0
- package/src/tui/slash-commands.mjs +138 -0
- package/src/tui/startup.mjs +113 -0
- package/src/tui/wizard.mjs +168 -0
- package/src/tui-render.mjs +4 -0
- package/src/tui.mjs +3 -2566
- package/src/provider.mjs +0 -383
- /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
- /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
- /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
- /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
- /package/src/{main-overlay.md → prompts/main.md} +0 -0
- /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
- /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
- /package/src/{repomap.mjs → tools/repomap.mjs} +0 -0
package/src/provider.mjs
DELETED
|
@@ -1,383 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* provider.mjs — LLM 调用层
|
|
3
|
-
* 原生 fetch 直连 OpenAI 兼容协议,SSE 流式,零依赖。
|
|
4
|
-
* 覆盖:OpenAI / DeepSeek / Moonshot / Ollama / 一切 OpenAI 兼容端点。
|
|
5
|
-
* 模型私有能力(Kimi/Qwen Partial Mode、DeepSeek Prefix Completion)由 config.mjs 的规格表声明,这里只按能力开关分支。
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { specForModel } from "./config.mjs"
|
|
9
|
-
|
|
10
|
-
export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
|
|
11
|
-
const MAX_RETRIES = 3
|
|
12
|
-
const MAX_CONTINUATIONS = 3 // Partial Mode 截断续写上限(防异常的无限 length 循环)
|
|
13
|
-
// 429 的专项退避:RPM/TPM 按 60s 窗口计(账户级独立计数器),通用退避的 1s/2s/4s 等不出窗口
|
|
14
|
-
const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* 测试钩子:睡眠/时钟/窗口长度可替换(离线测试不能真等 60s)。
|
|
18
|
-
* 生产代码不要直接调 setTimeout/sleep,统一走这里。
|
|
19
|
-
*/
|
|
20
|
-
export const _rateHooks = {
|
|
21
|
-
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
22
|
-
now: () => Date.now(),
|
|
23
|
-
windowMs: 60_000,
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------- TPM/RPM 主动节流闸门
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 滑动窗口闸门:发请求前若 窗口内已耗 + 本次估算 超预算,就睡到最早的记录滑出窗口。
|
|
30
|
-
* 按 baseURL+apiKey 记账(限速是账户级);预算来自 provider.tpm / provider.rpm,
|
|
31
|
-
* 不配则闸门关闭(429 反应式退避仍然生效)。主循环/压缩摘要/子 agent/截断续写全走这里。
|
|
32
|
-
*/
|
|
33
|
-
const rateWindows = new Map() // key → { tokens: [{ts, n}], requests: [ts] }
|
|
34
|
-
|
|
35
|
-
function rateKey(provider) {
|
|
36
|
-
return `${provider.baseURL}|${provider.apiKey ?? ""}`
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** 粗估文本 token 数(与 context.mjs 同口径:ASCII/4 + 非 ASCII/1;context 依赖本模块,不能反向 import) */
|
|
40
|
-
function estimateText(s) {
|
|
41
|
-
let nonAscii = 0
|
|
42
|
-
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) nonAscii++
|
|
43
|
-
return Math.ceil((s.length - nonAscii) / 4) + nonAscii
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** 本次请求的 prompt 估算:messages 正文 + 思考链 + tool_calls 参数 + tools schema */
|
|
47
|
-
function estimateRequestTokens(body) {
|
|
48
|
-
let tokens = 0
|
|
49
|
-
for (const m of body.messages ?? []) {
|
|
50
|
-
if (typeof m.content === "string") tokens += estimateText(m.content)
|
|
51
|
-
if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
|
|
52
|
-
for (const tc of m.tool_calls ?? []) {
|
|
53
|
-
tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
if (body.tools) tokens += estimateText(JSON.stringify(body.tools))
|
|
57
|
-
return tokens
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** 闸门:超预算则睡到窗口腾出空间;onWait({phase:"gate", seconds}) 供 UI 展示等待 */
|
|
61
|
-
async function rateGate(provider, estimated, onWait, signal) {
|
|
62
|
-
// 单次请求估算已超过 TPM 预算时闸门无意义(等到天荒地老也塞不下),放行由服务端裁决
|
|
63
|
-
const tpm = provider.tpm != null && estimated <= provider.tpm ? provider.tpm : null
|
|
64
|
-
const rpm = provider.rpm ?? null
|
|
65
|
-
if (tpm == null && rpm == null) return
|
|
66
|
-
const w = rateWindows.get(rateKey(provider)) ?? { tokens: [], requests: [] }
|
|
67
|
-
rateWindows.set(rateKey(provider), w)
|
|
68
|
-
for (;;) {
|
|
69
|
-
const now = _rateHooks.now()
|
|
70
|
-
const cutoff = now - _rateHooks.windowMs
|
|
71
|
-
w.tokens = w.tokens.filter((e) => e.ts > cutoff)
|
|
72
|
-
w.requests = w.requests.filter((ts) => ts > cutoff)
|
|
73
|
-
const usedTokens = w.tokens.reduce((s, e) => s + e.n, 0)
|
|
74
|
-
const overTokens = tpm != null ? usedTokens + estimated - tpm : 0
|
|
75
|
-
const overRequests = rpm != null ? w.requests.length + 1 - rpm : 0
|
|
76
|
-
if (overTokens <= 0 && overRequests <= 0) break
|
|
77
|
-
let waitMs = _rateHooks.windowMs
|
|
78
|
-
if (overTokens > 0) {
|
|
79
|
-
// tokens 按时间升序:累加最早若干条,过期量足够腾出空间时的过期时刻
|
|
80
|
-
let freed = 0
|
|
81
|
-
for (const e of w.tokens) {
|
|
82
|
-
freed += e.n
|
|
83
|
-
if (freed >= overTokens) {
|
|
84
|
-
waitMs = Math.min(waitMs, e.ts + _rateHooks.windowMs - now)
|
|
85
|
-
break
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
if (overRequests > 0) {
|
|
90
|
-
// 等第 overRequests-1 条(升序)滑出,窗口内请求数即降回 rpm-1
|
|
91
|
-
waitMs = Math.min(waitMs, w.requests[overRequests - 1] + _rateHooks.windowMs - now)
|
|
92
|
-
}
|
|
93
|
-
waitMs = Math.max(waitMs, 50)
|
|
94
|
-
onWait?.({ phase: "gate", seconds: Math.ceil(waitMs / 1000) })
|
|
95
|
-
await _rateHooks.sleep(waitMs)
|
|
96
|
-
if (signal?.aborted) return
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** 记账:响应回来后按实测 usage 记(无 usage 用发送前的估算兜底);被拒的请求不记(服务端未处理) */
|
|
101
|
-
function recordRate(provider, estimated, usage) {
|
|
102
|
-
if (provider.tpm == null && provider.rpm == null) return
|
|
103
|
-
const w = rateWindows.get(rateKey(provider)) ?? { tokens: [], requests: [] }
|
|
104
|
-
rateWindows.set(rateKey(provider), w)
|
|
105
|
-
const now = _rateHooks.now()
|
|
106
|
-
w.requests.push(now)
|
|
107
|
-
// TPM 按输入+输出总量计(Moonshot 口径)
|
|
108
|
-
w.tokens.push({ ts: now, n: usage ? (usage.prompt_tokens ?? estimated) + (usage.completion_tokens ?? 0) : estimated })
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* 创建 provider。config: { baseURL, apiKey, model, maxTokens?, temperature?, thinking?, reasoningEffort?, tpm?, rpm? }
|
|
113
|
-
* thinking: { type: "enabled"|"disabled" } 思维模式开关
|
|
114
|
-
* reasoningEffort: "low"|"high"|"max" 推理强度(DeepSeek/Kimi/GLM 通用)
|
|
115
|
-
* tpm / rpm: 主动节流预算(tokens/分钟、请求数/分钟,按账户限速等级自配),不配则闸门关闭
|
|
116
|
-
*/
|
|
117
|
-
export function createProvider(config) {
|
|
118
|
-
if (!config?.baseURL) throw new Error("provider config: baseURL is required")
|
|
119
|
-
if (!config?.apiKey) throw new Error("provider config: apiKey is required (config file or THINCODER_API_KEY env)")
|
|
120
|
-
if (!config?.model) throw new Error("provider config: model is required")
|
|
121
|
-
return {
|
|
122
|
-
baseURL: config.baseURL.replace(/\/+$/, ""),
|
|
123
|
-
apiKey: config.apiKey,
|
|
124
|
-
model: config.model,
|
|
125
|
-
maxTokens: config.maxTokens,
|
|
126
|
-
temperature: config.temperature,
|
|
127
|
-
thinking: config.thinking,
|
|
128
|
-
reasoningEffort: config.reasoningEffort,
|
|
129
|
-
tpm: config.tpm,
|
|
130
|
-
rpm: config.rpm,
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* 流式对话。
|
|
136
|
-
* messages: OpenAI 格式数组; tools: OpenAI tools schema(可选)
|
|
137
|
-
* onToken(text): 正文流式回调; onReasoning(text): 思考流回调(DeepSeek-R1 类模型)
|
|
138
|
-
* onWait({phase, seconds}): 节流等待回调(phase: "gate"=主动节流 / "retry"=429 退避)
|
|
139
|
-
* signal: AbortSignal(可选)
|
|
140
|
-
* 返回 { content, reasoning, toolCalls: [{id, name, arguments}], usage, finishReason }
|
|
141
|
-
* 注意:toolCalls[i].arguments 是 JSON 字符串,调用方负责 parse
|
|
142
|
-
*
|
|
143
|
-
* 截断续写(按规格表能力门控,未声明的模型原样返回截断结果):
|
|
144
|
-
* finish_reason=length 且已有正文时,把已输出内容作为前缀 assistant 消息回传,
|
|
145
|
-
* 模型接着续写而非丢弃重跑。思考阶段被截断(content 为空)时无前缀可续,直接返回。
|
|
146
|
-
* - partialMode(Kimi / Qwen):assistant 消息带 partial:true;K3 思考续写需回传 reasoning_content
|
|
147
|
-
* - prefixMode(DeepSeek):assistant 消息带 prefix:true,且须走 /beta 端点;
|
|
148
|
-
* 思考模式不支持前缀续写,已产出 reasoning 时放弃续写
|
|
149
|
-
*/
|
|
150
|
-
export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal }) {
|
|
151
|
-
const spec = specForModel(provider.model)
|
|
152
|
-
const body = {
|
|
153
|
-
model: provider.model,
|
|
154
|
-
messages,
|
|
155
|
-
stream: true,
|
|
156
|
-
stream_options: { include_usage: true },
|
|
157
|
-
}
|
|
158
|
-
if (provider.maxTokens) body.max_tokens = provider.maxTokens
|
|
159
|
-
if (provider.temperature != null) {
|
|
160
|
-
// 按规格表裁剪 temperature:GLM [0,1] 限两位小数,DeepSeek ≤2,未声明则不裁剪
|
|
161
|
-
let t = provider.temperature
|
|
162
|
-
if (spec.tempRange) {
|
|
163
|
-
t = Math.min(spec.tempRange[1], Math.max(spec.tempRange[0], t))
|
|
164
|
-
t = Math.round(t * 100) / 100
|
|
165
|
-
}
|
|
166
|
-
body.temperature = t
|
|
167
|
-
}
|
|
168
|
-
if (provider.thinking) body.thinking = provider.thinking
|
|
169
|
-
if (provider.reasoningEffort) {
|
|
170
|
-
// 按规格表校验 reasoning_effort 枚举:不在枚举内则报错,不映射、不猜测
|
|
171
|
-
if (spec.reasoningEffortEnum && !spec.reasoningEffortEnum.includes(provider.reasoningEffort)) {
|
|
172
|
-
throw new Error(
|
|
173
|
-
`reasoning_effort "${provider.reasoningEffort}" not supported by model "${provider.model}"; ` +
|
|
174
|
-
`valid values: ${spec.reasoningEffortEnum.join(", ")}`
|
|
175
|
-
)
|
|
176
|
-
}
|
|
177
|
-
body.reasoning_effort = provider.reasoningEffort
|
|
178
|
-
}
|
|
179
|
-
if (tools?.length) body.tools = tools
|
|
180
|
-
|
|
181
|
-
// TPM/RPM 主动节流:超预算先在本地睡到窗口腾出空间,不打 429 碰运气
|
|
182
|
-
const estimated = estimateRequestTokens(body)
|
|
183
|
-
await rateGate(provider, estimated, onWait, signal)
|
|
184
|
-
|
|
185
|
-
const response = await requestWithRetry(provider, body, signal, onWait)
|
|
186
|
-
const result = await readSSE(response, { onToken, onReasoning })
|
|
187
|
-
recordRate(provider, estimated, result.usage)
|
|
188
|
-
|
|
189
|
-
// 截断续写:仅规格表声明续写协议的模型(其他端点不认识 partial/prefix 字段,可能 400)
|
|
190
|
-
if (!spec.partialMode && !spec.prefixMode) return result
|
|
191
|
-
// DeepSeek prefix 续写不支持思考模式,已产出 reasoning 时无前缀协议可用
|
|
192
|
-
if (spec.prefixMode && !spec.partialMode && result.reasoning) return result
|
|
193
|
-
for (let n = 0; result.finishReason === "length" && result.content && n < MAX_CONTINUATIONS; n++) {
|
|
194
|
-
const continued = await chat(spec.prefixMode ? { ...provider, baseURL: betaBaseURL(provider.baseURL) } : provider, {
|
|
195
|
-
messages: [
|
|
196
|
-
...messages,
|
|
197
|
-
spec.partialMode
|
|
198
|
-
? {
|
|
199
|
-
role: "assistant",
|
|
200
|
-
content: result.content,
|
|
201
|
-
partial: true,
|
|
202
|
-
// K3 思考模式续写必须回传 reasoning_content
|
|
203
|
-
...(result.reasoning ? { reasoning_content: result.reasoning } : {}),
|
|
204
|
-
}
|
|
205
|
-
: { role: "assistant", content: result.content, prefix: true },
|
|
206
|
-
],
|
|
207
|
-
tools,
|
|
208
|
-
onToken,
|
|
209
|
-
onReasoning,
|
|
210
|
-
onWait,
|
|
211
|
-
signal,
|
|
212
|
-
})
|
|
213
|
-
result.content += continued.content
|
|
214
|
-
result.reasoning += continued.reasoning ?? ""
|
|
215
|
-
for (const tc of continued.toolCalls ?? []) {
|
|
216
|
-
if (tc.index == null) { result.toolCalls = continued.toolCalls; break }
|
|
217
|
-
const s = result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" }
|
|
218
|
-
if (tc.id) s.id = tc.id
|
|
219
|
-
s.name += tc.name ?? ""
|
|
220
|
-
s.arguments += tc.arguments ?? ""
|
|
221
|
-
}
|
|
222
|
-
result.finishReason = continued.finishReason
|
|
223
|
-
if (continued.usage) {
|
|
224
|
-
const sum = (k) => (result.usage?.[k] ?? 0) + (continued.usage[k] ?? 0)
|
|
225
|
-
result.usage = {
|
|
226
|
-
prompt_tokens: sum("prompt_tokens"),
|
|
227
|
-
completion_tokens: sum("completion_tokens"),
|
|
228
|
-
total_tokens: sum("total_tokens"),
|
|
229
|
-
// 缓存命中/未命中也要累计(DeepSeek 计费与状态栏展示依赖这两个字段)
|
|
230
|
-
prompt_cache_hit_tokens: sum("prompt_cache_hit_tokens"),
|
|
231
|
-
prompt_cache_miss_tokens: sum("prompt_cache_miss_tokens"),
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return result
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* 拉取端点可用模型列表(GET /v1/models)。
|
|
240
|
-
* 返回模型 id 数组;端点不支持时抛错。
|
|
241
|
-
*/
|
|
242
|
-
export async function listModels(provider, { signal } = {}) {
|
|
243
|
-
const response = await fetch(`${provider.baseURL}/models`, {
|
|
244
|
-
headers: { Authorization: `Bearer ${provider.apiKey}` },
|
|
245
|
-
signal,
|
|
246
|
-
})
|
|
247
|
-
if (!response.ok) {
|
|
248
|
-
const text = await response.text().catch(() => "")
|
|
249
|
-
throw new Error(`GET /models failed ${response.status}: ${text}`)
|
|
250
|
-
}
|
|
251
|
-
const data = await response.json()
|
|
252
|
-
return (data.data ?? []).map((m) => m.id).filter(Boolean).sort()
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* 带重试的请求:网络错误与 5xx 指数退避(1s/2s/4s);429 专项处理——
|
|
257
|
-
* 有 Retry-After 以它为准,没有按 15s/30s/60s 退避(RPM/TPM 是 60s 窗口,秒级退避等不出去)。
|
|
258
|
-
* 配额/余额错误(如 exceeded_current_quota_error)与限速同状态码但语义不同:重试无用,直接抛。
|
|
259
|
-
*/
|
|
260
|
-
async function requestWithRetry(provider, body, signal, onWait) {
|
|
261
|
-
let lastError
|
|
262
|
-
let lastWas429 = false
|
|
263
|
-
let rateLimitHits = 0 // 连续 429 计数(退避档位用,与 attempt 解耦)
|
|
264
|
-
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
265
|
-
if (attempt > 0 && !lastWas429) await _rateHooks.sleep(2 ** (attempt - 1) * 1000)
|
|
266
|
-
lastWas429 = false
|
|
267
|
-
|
|
268
|
-
let response
|
|
269
|
-
try {
|
|
270
|
-
response = await fetch(`${provider.baseURL}${provider.chatPath ?? "/chat/completions"}`, {
|
|
271
|
-
method: "POST",
|
|
272
|
-
headers: {
|
|
273
|
-
"Content-Type": "application/json",
|
|
274
|
-
Authorization: `Bearer ${provider.apiKey}`,
|
|
275
|
-
},
|
|
276
|
-
body: JSON.stringify(body),
|
|
277
|
-
signal,
|
|
278
|
-
})
|
|
279
|
-
} catch (error) {
|
|
280
|
-
if (error.name === "AbortError") throw error
|
|
281
|
-
lastError = error // 网络层错误,可重试
|
|
282
|
-
continue
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
if (response.ok) return response
|
|
286
|
-
|
|
287
|
-
const text = await response.text().catch(() => "")
|
|
288
|
-
const message = `LLM API error ${response.status}: ${text}`
|
|
289
|
-
if (isQuotaError(text)) throw new Error(message)
|
|
290
|
-
if (response.status === 429) {
|
|
291
|
-
const retryAfter = Number(response.headers.get("retry-after"))
|
|
292
|
-
const waitMs =
|
|
293
|
-
Number.isFinite(retryAfter) && retryAfter > 0
|
|
294
|
-
? retryAfter * 1000
|
|
295
|
-
: RATE_LIMIT_BACKOFF_MS[Math.min(rateLimitHits++, RATE_LIMIT_BACKOFF_MS.length - 1)]
|
|
296
|
-
lastError = new Error(message)
|
|
297
|
-
lastWas429 = true
|
|
298
|
-
if (attempt < MAX_RETRIES) {
|
|
299
|
-
onWait?.({ phase: "retry", seconds: Math.ceil(waitMs / 1000) })
|
|
300
|
-
await _rateHooks.sleep(waitMs)
|
|
301
|
-
}
|
|
302
|
-
continue
|
|
303
|
-
}
|
|
304
|
-
if (RETRYABLE_STATUS.has(response.status)) {
|
|
305
|
-
lastError = new Error(message)
|
|
306
|
-
continue
|
|
307
|
-
}
|
|
308
|
-
throw new Error(message)
|
|
309
|
-
}
|
|
310
|
-
throw lastError
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** 配额/余额错误(重试无意义):错误体 type 含 quota,如 Moonshot exceeded_current_quota_error */
|
|
314
|
-
function isQuotaError(text) {
|
|
315
|
-
try {
|
|
316
|
-
const type = JSON.parse(text)?.error?.type
|
|
317
|
-
return typeof type === "string" && type.includes("quota")
|
|
318
|
-
} catch {
|
|
319
|
-
return false
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** 解析 SSE 流,累积正文/思考/tool_calls */
|
|
324
|
-
async function readSSE(response, { onToken, onReasoning }) {
|
|
325
|
-
const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
|
|
326
|
-
const decoder = new TextDecoder()
|
|
327
|
-
let buffer = ""
|
|
328
|
-
|
|
329
|
-
const processLines = (lines) => {
|
|
330
|
-
for (const line of lines) {
|
|
331
|
-
if (!line.startsWith("data:")) continue
|
|
332
|
-
const data = line.slice(5).trim()
|
|
333
|
-
if (!data || data === "[DONE]") continue
|
|
334
|
-
|
|
335
|
-
let json
|
|
336
|
-
try {
|
|
337
|
-
json = JSON.parse(data)
|
|
338
|
-
} catch {
|
|
339
|
-
continue // 忽略坏行,流不能因为一帧坏数据断掉
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (json.usage) result.usage = json.usage
|
|
343
|
-
const choice = json.choices?.[0]
|
|
344
|
-
if (!choice) continue
|
|
345
|
-
if (choice.finish_reason) result.finishReason = choice.finish_reason
|
|
346
|
-
|
|
347
|
-
const delta = choice.delta ?? {}
|
|
348
|
-
if (delta.reasoning_content) {
|
|
349
|
-
result.reasoning += delta.reasoning_content
|
|
350
|
-
onReasoning?.(delta.reasoning_content)
|
|
351
|
-
}
|
|
352
|
-
if (delta.content) {
|
|
353
|
-
result.content += delta.content
|
|
354
|
-
onToken?.(delta.content)
|
|
355
|
-
}
|
|
356
|
-
// tool_calls 按 index 分槽累积,arguments 是分片到达的需拼接;
|
|
357
|
-
// name 个别 API(GLM 偶尔)会重发完整 name 而非增量,用 += 会拼成 "readread"——只取第一次非空值
|
|
358
|
-
for (const tc of delta.tool_calls ?? []) {
|
|
359
|
-
const slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
|
|
360
|
-
if (tc.id) slot.id = tc.id
|
|
361
|
-
if (tc.function?.name && !slot.name) slot.name = tc.function.name
|
|
362
|
-
if (tc.function?.arguments) slot.arguments += tc.function.arguments
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
if (!response.body) throw new Error("No stream response body")
|
|
368
|
-
for await (const chunk of response.body) {
|
|
369
|
-
buffer += decoder.decode(chunk, { stream: true })
|
|
370
|
-
const lines = buffer.split("\n")
|
|
371
|
-
buffer = lines.pop() // 最后半行留到下一轮
|
|
372
|
-
processLines(lines)
|
|
373
|
-
}
|
|
374
|
-
// flush 解码器内部残留(流以不完整 UTF-8 序列截断时不丢尾部字节),并处理没有换行结尾的尾行
|
|
375
|
-
buffer += decoder.decode()
|
|
376
|
-
processLines(buffer.split("\n"))
|
|
377
|
-
return result
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/** DeepSeek Prefix Completion 只在 /beta 端点开放:.../v1 → .../beta */
|
|
381
|
-
function betaBaseURL(baseURL) {
|
|
382
|
-
return baseURL.replace(/\/v1$/, "/beta")
|
|
383
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|