thincoder 0.12.52 → 0.12.54
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 +36 -0
- package/package.json +1 -1
- package/src/acp.mjs +60 -18
- package/src/advisor/run.mjs +9 -11
- package/src/agent/dispatch.mjs +38 -13
- package/src/agent/setup.mjs +2 -1
- package/src/agent.mjs +34 -0
- package/src/cli/make-agent.mjs +11 -5
- package/src/escape.mjs +43 -8
- package/src/git/checkpoint.mjs +32 -6
- 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 +44 -1
- package/src/provider/anthropic.mjs +51 -18
- package/src/provider/core.mjs +163 -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/session-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +267 -306
- 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-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +21 -6
- package/src/tools/git.mjs +68 -155
- 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/ansi.mjs +2 -0
- package/src/tui/cmd-new.mjs +6 -6
- package/src/tui/cmd-restore.mjs +27 -6
- package/src/tui/cmd-session.mjs +17 -4
- package/src/tui/fold-block.mjs +59 -11
- package/src/tui/index.mjs +59 -67
- package/src/tui/key-handler.mjs +3 -1
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +86 -8
- package/src/tui/render-conversation.mjs +260 -214
- package/src/tui/render-frame.mjs +22 -6
- package/src/tui/render-loop.mjs +11 -1
- package/src/tui/startup.mjs +1 -1
- package/src/tui/subagent-blocks.mjs +5 -1
- package/src/tui/subagent-panel.mjs +81 -0
- package/src/tui/tool-args.mjs +4 -0
- package/src/tui/tool-events.mjs +1 -1
- package/src/tui/tui-lifecycle.mjs +45 -0
|
@@ -19,9 +19,10 @@ UI & interface design:
|
|
|
19
19
|
- A value with a FIXED set of choices (enum, level, mode, flag) must be OPTIONS — picker / menu / choices / buttons. Never free-text input.
|
|
20
20
|
- Free-text for a discrete value forces the user to guess the exact spelling, needs manual validation, and fails silently on typos. This has happened repeatedly (e.g. reasoning-effort levels typed by hand).
|
|
21
21
|
- Free-text is correct ONLY when the input is genuinely open-ended (a name, a path, a message).
|
|
22
|
+
- **用户约定执行纪律(2026-08-31,两次违约教训)**:用户对交互/行为的约定以用户原话为准——实现时逐字对照,不得用"等效实现"替换约定本身(已发生:滚动→点击翻窗、滚动到头自动加载→PgUp 键触发)。已确认约定的简化/降级必须提前上报,不得包装成"升级路径"交付。注释里的 parity with X / 对齐 X 只描述来源,不代表 X 就是正确语义——以用户约定为唯一判据,实现后真机验证用户原话的每个承诺点。
|
|
22
23
|
|
|
23
24
|
Tool routing — use the dedicated tool, not bash:
|
|
24
|
-
- **git operations** → `git` tool (action=status/diff/log/show/add/commit/push/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick; `workdir` for sub-repos). Never run git via bash.
|
|
25
|
+
- **git operations** → `git` tool (action=status/diff/log/show/add/commit/push/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick/ls-remote/clone/init/rebase/remote/clean/switch/apply/worktree/archive/blame/mv; `workdir` for sub-repos). Never run git via bash.
|
|
25
26
|
- **JavaScript** → `execute` (inline code; or `scriptFile`+`nodeArgs` for `node <file>` / `node --test` / `node --check`). Never `bash node -e`.
|
|
26
27
|
- **File reads/searches** → `read` / `grep` / `ls` / `glob` — never `cat` / `type` / `findstr` / `dir` / shell-grep.
|
|
27
28
|
- **File mutations** → `write` / `edit` / `apply_patch` / `hashline_edit` / `insert_after` / `file_ops` (move/copy/rename) / `delete`.
|
|
@@ -29,6 +30,48 @@ Tool routing — use the dedicated tool, not bash:
|
|
|
29
30
|
- Each tool's description carries a "Route to X instead of bash" mapping.
|
|
30
31
|
- **bash IS correct for**: package-manager/CLI subprocesses (`npm`/`vsce`/`ovsx`, git-CLI-only flags the tool lacks), servers, interactive/TTY programs, and one-off shell pipelines no dedicated tool expresses.
|
|
31
32
|
|
|
33
|
+
**Full tool routing table** (one row per tool; "alias" = what bash/pipes people reach for instead):
|
|
34
|
+
| Tool | Use it for | Not (use dedicated tool instead of) |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `read` | read a text file (paged / hashes=true for editing) | `cat`, `type`, `node -e fs.readFileSync` |
|
|
37
|
+
| `write` | create/overwrite a file | `echo >`, `printf >`, heredocs |
|
|
38
|
+
| `edit` | exact-string single replacement | `sed -i`, `perl -p` |
|
|
39
|
+
| `hashline_edit` | line-targeted edit by content hash (whitespace/encoding drift proof) | `sed` by line number |
|
|
40
|
+
| `insert_after` | add a block after a known line / regex-anchored | `sed` insertion, line-number surgery |
|
|
41
|
+
| `apply_patch` | multi-file unified diff (all-or-nothing) | `git apply` by hand, patch gymnastics |
|
|
42
|
+
| `delete` | remove a single file (tracked files need force) | `del`, `rm` |
|
|
43
|
+
| `file_ops` | move / copy / rename files or dirs | `mv`, `cp`, `ren` |
|
|
44
|
+
| `ls` | list directory contents (typed, sized) | `dir`, `ls` in bash |
|
|
45
|
+
| `glob` | find files by pattern | `find`, `dir /b /s`, shell globs |
|
|
46
|
+
| `grep` | regex search file contents (context supported) | `findstr`, `grep -rn`, `rg` |
|
|
47
|
+
| `tree` | directory tree overview | `tree`, `find .` |
|
|
48
|
+
| `repo_outline` | module dependency / symbol map | ad-hoc scripts |
|
|
49
|
+
| `code_search` | natural-language code search | grep gymnastics |
|
|
50
|
+
| `doc_search` | search project docs (design/AGENTS) | `findstr` in docs |
|
|
51
|
+
| `read_image` | view an image (vision models) | external viewers |
|
|
52
|
+
| `execute` | run JS inline / scriptFile (+ nodeArgs for `node --test`/`--check`) | `bash node -e`, `node <script>` via bash |
|
|
53
|
+
| `bash` | npm/vsce/CLI subprocess, servers, TTY programs, one-off pipelines no tool expresses | always; see allowed list above |
|
|
54
|
+
| `git` | ALL git ops (status/diff/log/show/add/commit/push/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick/ls-remote/clone/init/rebase/remote/clean/switch/apply/worktree/archive/blame/mv) | `git` in bash |
|
|
55
|
+
| `process` | list running processes | `tasklist`, `ps`, `wmic` |
|
|
56
|
+
| `get_current_time` | current date/time | `date` |
|
|
57
|
+
| `timer` | thinking budget / wait reminder | `sleep`, `timeout` (for real waits) |
|
|
58
|
+
| `lint` | lint / syntax check after edits (full=true for cascade) | ad-hoc eslint runs |
|
|
59
|
+
| `verify` | pre-completion self-check (syntax/tests/diff/checklist) | manual diff/test runs |
|
|
60
|
+
| `task` / `checklist` | session-level tasks / persistent requirements tracking | README-style todo lists |
|
|
61
|
+
| `goal` | long-running autonomous goal (machine-checkable criteria) | prose promises |
|
|
62
|
+
| `plan` / `eng` | plan mode / engineering mode entry-exit | none (mode transitions only here) |
|
|
63
|
+
| `skill` | load project skills (.thincoder/skills/) | re-inventing workflows |
|
|
64
|
+
| `question` | ask the user (ambiguity, design decisions) | guessing |
|
|
65
|
+
| `advisor` | independent review of code/design | self-review only |
|
|
66
|
+
| `subagent` | delegate subtasks to isolated contexts | inlining exploration |
|
|
67
|
+
| `consult_start` / `consult_check` / `consult_stop` | parallel multi-model consultation | single-model guessing |
|
|
68
|
+
| `escalate` | fly in a stronger model for hard implementation | burning attempts |
|
|
69
|
+
| `memory_put` / `memory_search` | long-term knowledge save/search | session notes |
|
|
70
|
+
| `checkpoint` | git snapshots / rewind safety | manual branches |
|
|
71
|
+
| `fetch` | fetch a URL (explicit proxy per target; config proxy NOT auto-applied) | `curl` |
|
|
72
|
+
| `websearch` | Bing search (weak for technical; MCP search tool first) | `curl` scraping |
|
|
73
|
+
| `glm-websearch_web_search_prime` | technical lookups (primary when available) | Bing fallback loop |
|
|
74
|
+
|
|
32
75
|
Review discipline (standard mode only — engineering mode has its own review timing rules):
|
|
33
76
|
- **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context).
|
|
34
77
|
- **After each advisor review, reply with a response table** — exact header `| # | Action | Detail |` (the runtime extracts this header; keep it verbatim). One row per issue; `#` = the advisor's issue number (`Orig#` on rounds 2+).
|
|
@@ -6,9 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
import { specForModel } from "../config.mjs"
|
|
8
8
|
import { proxyFetch } from "../proxy.mjs"
|
|
9
|
+
import { requestWithRetry } from "./retry.mjs"
|
|
9
10
|
|
|
10
11
|
const ANTHROPIC_VERSION = "2023-06-01"
|
|
11
12
|
|
|
13
|
+
/** OpenAI 语义 tool_choice → Anthropic tool_choice(2026-08-31 能力层)。
|
|
14
|
+
* 传入值形态:undefined | "auto" | "required" | "none" | {type:"function",function:{name}} */
|
|
15
|
+
function mapToolChoice(choice) {
|
|
16
|
+
if (choice === "auto") return { type: "auto" }
|
|
17
|
+
if (choice === "required") return { type: "any" }
|
|
18
|
+
if (choice === "none") return { type: "none" }
|
|
19
|
+
if (choice && typeof choice === "object" && choice.function?.name) return { type: "tool", name: choice.function.name }
|
|
20
|
+
throw new Error(`Invalid tool_choice for Anthropic format: ${JSON.stringify(choice).slice(0, 120)}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
12
24
|
/** Convert OpenAI-format tools to Anthropic format */
|
|
13
25
|
export function normalizeTools(tools) {
|
|
14
26
|
return (tools || []).map((t) => ({
|
|
@@ -18,8 +30,11 @@ export function normalizeTools(tools) {
|
|
|
18
30
|
}))
|
|
19
31
|
}
|
|
20
32
|
|
|
21
|
-
/** Build and send an Anthropic chat request. Returns the same shape as core.mjs chat.
|
|
22
|
-
|
|
33
|
+
/** Build and send an Anthropic chat request. Returns the same shape as core.mjs chat.
|
|
34
|
+
* 2026-08-31 会诊 #6:接入 rateGate/recordRate + 429 Retry-After 单次重试
|
|
35
|
+
* (原实现完全绕过 TPM/RPM 闸门与记账——用户配了 tpm 以为受控实际不受控)。
|
|
36
|
+
* 注:5xx/网络退避重试未与 OpenAI 格式对齐(三 transport 共用那步工作量大,见报告)。 */
|
|
37
|
+
export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, parallelToolCalls }) {
|
|
23
38
|
// Extract system message(s) — Anthropic uses top-level `system` field
|
|
24
39
|
const systemMessages = []
|
|
25
40
|
const chatMessages = []
|
|
@@ -40,6 +55,9 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
|
|
|
40
55
|
}
|
|
41
56
|
if (systemMessages.length > 0) body.system = systemMessages.join("\n\n")
|
|
42
57
|
if (tools?.length) body.tools = tools
|
|
58
|
+
// 2026-08-31:tool_choice 能力层——OpenAI 语义映射到 Anthropic tool_choice
|
|
59
|
+
// (auto→{type:"auto"} / required→{type:"any"} / none→{type:"none"} / 具体函数→{type:"tool",name})
|
|
60
|
+
if (toolChoice !== undefined) body.tool_choice = mapToolChoice(toolChoice)
|
|
43
61
|
if (provider.temperature != null) {
|
|
44
62
|
let t = provider.temperature
|
|
45
63
|
// Anthropic API hard limit is 0-1; models without a declared tempRange still get clamped
|
|
@@ -59,23 +77,37 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
|
|
|
59
77
|
// Active signal check
|
|
60
78
|
if (signal?.aborted) throw Object.assign(new DOMException("Aborted", "AbortError"), { reason: signal.reason })
|
|
61
79
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
// 会诊 #6:TPM/RPM 闸门 + 记账(rate.mjs 与 OpenAI 格式共用同一窗口)
|
|
81
|
+
const { rateGate, recordRate } = await import("./rate.mjs")
|
|
82
|
+
const estimated = await (async () => {
|
|
83
|
+
const { estimateRequestTokens } = await import("./rate.mjs")
|
|
84
|
+
return estimateRequestTokens(body)
|
|
85
|
+
})()
|
|
86
|
+
await rateGate(provider, estimated, onWait, signal)
|
|
87
|
+
|
|
88
|
+
// 2026-08-31:4xx/5xx/网络与 OpenAI 格式统一退避重试链(原仅 429 Retry-After 单次重试,
|
|
89
|
+
// 5xx 直接抛——DeepSeek/Claude 排队 503 时其他格式可自动恢复,这里语义割裂)
|
|
90
|
+
const response = await requestWithRetry(
|
|
91
|
+
() => proxyFetch(`${provider.baseURL}/messages`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers,
|
|
94
|
+
body: JSON.stringify(body),
|
|
95
|
+
signal: signal
|
|
96
|
+
? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
|
|
97
|
+
: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
98
|
+
_headerTimeoutMs: FETCH_TIMEOUT_MS,
|
|
99
|
+
_bodyIdleMs: 120_000,
|
|
100
|
+
}, provider.proxyUri),
|
|
101
|
+
{ signal, onWait, buildMessage: (status, text) => `Anthropic API error ${status}: ${text}` },
|
|
102
|
+
)
|
|
75
103
|
|
|
76
104
|
const result = await parseAnthropicStream(response, { onToken, onReasoning, signal })
|
|
105
|
+
recordRate(provider, estimated, result.usage)
|
|
106
|
+
return finishAnthropic(result)
|
|
107
|
+
}
|
|
77
108
|
|
|
78
|
-
|
|
109
|
+
/** Convert the parsed stream to the core.mjs result shape (usage → OpenAI-compatible). */
|
|
110
|
+
function finishAnthropic(result) {
|
|
79
111
|
const usage = result.usage
|
|
80
112
|
if (usage) {
|
|
81
113
|
return {
|
|
@@ -91,7 +123,6 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
|
|
|
91
123
|
toolCalls: result.toolCalls,
|
|
92
124
|
}
|
|
93
125
|
}
|
|
94
|
-
|
|
95
126
|
return { content: result.content, reasoning: result.reasoning, toolCalls: result.toolCalls }
|
|
96
127
|
}
|
|
97
128
|
|
|
@@ -157,6 +188,8 @@ async function parseAnthropicStream(response, { onToken, onReasoning, signal })
|
|
|
157
188
|
throw e
|
|
158
189
|
}
|
|
159
190
|
buffer += decoder.decode(chunk, { stream: true })
|
|
191
|
+
// BOM 剥除(会诊 #12):首个 chunk 可能带 \uFEFF,否则 message_start 事件被静默丢失(含 usage)
|
|
192
|
+
if (buffer.charCodeAt(0) === 0xfeff) buffer = buffer.slice(1)
|
|
160
193
|
const lines = buffer.split("\n")
|
|
161
194
|
buffer = lines.pop()
|
|
162
195
|
|
|
@@ -167,7 +200,7 @@ async function parseAnthropicStream(response, { onToken, onReasoning, signal })
|
|
|
167
200
|
currentData = ""
|
|
168
201
|
} else if (line.startsWith("data: ")) {
|
|
169
202
|
currentData = line.slice(6).trim()
|
|
170
|
-
} else if (line === "") {
|
|
203
|
+
} else if (line === "" || line === "\r") { // CRLF 空行是 "\r"(会诊 #13)
|
|
171
204
|
if (currentEvent) processEvent(currentEvent, currentData)
|
|
172
205
|
currentEvent = ""
|
|
173
206
|
currentData = ""
|
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,11 +61,12 @@ 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)
|
|
47
68
|
messages = stripImagesForTextModel(messages, spec)
|
|
69
|
+
const _debugBeforeLen = process.env.THIN_DEBUG_BODY ? JSON.stringify(messages).length : 0
|
|
48
70
|
|
|
49
71
|
// Format dispatch: delegate to non-OpenAI transports
|
|
50
72
|
if (provider.format === "anthropic") {
|
|
@@ -53,7 +75,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
53
75
|
const result = await anthropicChat(provider, {
|
|
54
76
|
messages,
|
|
55
77
|
tools: tools?.length ? normalizeTools(tools) : null,
|
|
56
|
-
onToken, onReasoning, signal,
|
|
78
|
+
onToken, onReasoning, onWait, signal, toolChoice,
|
|
57
79
|
})
|
|
58
80
|
return result
|
|
59
81
|
}
|
|
@@ -63,16 +85,31 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
63
85
|
const result = await geminiChat(provider, {
|
|
64
86
|
messages,
|
|
65
87
|
tools: tools?.length ? normalizeTools(tools) : null,
|
|
66
|
-
onToken, onReasoning, signal,
|
|
88
|
+
onToken, onReasoning, onWait, signal, toolChoice,
|
|
67
89
|
})
|
|
68
90
|
return result
|
|
69
91
|
}
|
|
92
|
+
if (provider.format === "responses") {
|
|
93
|
+
// 2026-08-31:Responses API transport(PROVIDER.md §13)——双轨链在 transport 内部
|
|
94
|
+
// 自行管理(provider._responsesChain),agent 层零改动。
|
|
95
|
+
// round3 #3:配对归一化必须在此分派前(压缩/中断遗留的孤儿 tool 消息发向严格服务端会 400)
|
|
96
|
+
messages = normalizeToolPairing(messages)
|
|
97
|
+
const { chat: responsesChat } = await import("./responses.mjs")
|
|
98
|
+
return responsesChat(provider, {
|
|
99
|
+
messages,
|
|
100
|
+
tools,
|
|
101
|
+
onToken, onReasoning, onWait, signal, toolChoice,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
70
104
|
|
|
71
105
|
messages = normalizeToolPairing(messages)
|
|
72
106
|
// 中和服务端的非标二次转义:会话里若出现字面 "\x"/"\u"(如讨论转义、grep 到含
|
|
73
107
|
// 转义的代码),Kimi 等会把它们当 hex escape 再解析 → "unexpected end of hex escape" 400。
|
|
74
108
|
// 发送前统一 double 掉会形成非法转义的序列(合法 \xNN/\uNNNN 不受影响)。
|
|
75
109
|
messages = escapeMessages(messages)
|
|
110
|
+
if (process.env.THIN_DEBUG_BODY) {
|
|
111
|
+
console.error(`[debug-body] escape: ${_debugBeforeLen} -> ${JSON.stringify(messages).length} chars, ${messages.length} msgs (provider=${provider.name}, model=${provider.model})`)
|
|
112
|
+
}
|
|
76
113
|
// Compile string-pattern rules to RegExp at call time
|
|
77
114
|
const rules = compileStreamRules(streamRules)
|
|
78
115
|
const body = {
|
|
@@ -110,6 +147,10 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
110
147
|
const enableThinking = resolveEnableThinking(provider, spec)
|
|
111
148
|
if (enableThinking !== undefined) body.enable_thinking = enableThinking
|
|
112
149
|
if (tools?.length) body.tools = tools
|
|
150
|
+
// 2026-08-31:tool_choice 能力层(透传 OpenAI 语义);
|
|
151
|
+
// parallel_tool_calls 仅显式 true 时发送(默认不发=不改变现有行为)
|
|
152
|
+
if (toolChoice !== undefined) body.tool_choice = toolChoice
|
|
153
|
+
if (parallelToolCalls === true) body.parallel_tool_calls = true
|
|
113
154
|
|
|
114
155
|
const estimated = estimateRequestTokens(body)
|
|
115
156
|
await rateGate(provider, estimated, onWait, signal)
|
|
@@ -118,16 +159,19 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
118
159
|
const result = await readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns })
|
|
119
160
|
recordRate(provider, estimated, result.usage)
|
|
120
161
|
|
|
121
|
-
// Stream rule triggered
|
|
162
|
+
// Stream rule triggered, user interrupted, or network partial — return immediately.
|
|
163
|
+
// 2026-08-31 会诊 #2:partial(网络错误中断但已有内容)与 interrupted 同级透传,
|
|
164
|
+
// 不再让上层把已收内容当整轮失败重试(重试从零开始浪费已流出的成本)。
|
|
122
165
|
if (result.ruleTriggered) return result
|
|
123
166
|
if (result.interrupted) return result
|
|
167
|
+
if (result.partial) return result
|
|
124
168
|
|
|
125
169
|
// Retry on transient server overload (DeepSeek: insufficient_system_resource)
|
|
126
170
|
const MAX_OVERLOAD_RETRIES = 1
|
|
127
171
|
for (let r = 0; result.finishReason === "insufficient_system_resource" && r <= MAX_OVERLOAD_RETRIES; r++) {
|
|
128
172
|
if (r > 0) {
|
|
129
173
|
onWait?.({ phase: "overloaded", seconds: 3 })
|
|
130
|
-
await
|
|
174
|
+
await sleepInterruptible(3000, signal)
|
|
131
175
|
}
|
|
132
176
|
const retryResponse = await requestWithRetry(provider, body, signal, onWait)
|
|
133
177
|
const retryResult = await readSSE(retryResponse, { onToken, onReasoning })
|
|
@@ -136,13 +180,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
136
180
|
// Merge any partial content from the failed attempt (streaming already showed it)
|
|
137
181
|
result.content += retryResult.content
|
|
138
182
|
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
|
-
}
|
|
183
|
+
mergeRetryToolCalls(result, retryResult.toolCalls)
|
|
146
184
|
result.finishReason = retryResult.finishReason
|
|
147
185
|
if (retryResult.usage) result.usage = retryResult.usage
|
|
148
186
|
break
|
|
@@ -172,13 +210,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
172
210
|
})
|
|
173
211
|
result.content += continued.content
|
|
174
212
|
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
|
-
}
|
|
213
|
+
mergeRetryToolCalls(result, continued.toolCalls)
|
|
182
214
|
result.finishReason = continued.finishReason
|
|
183
215
|
if (continued.usage) {
|
|
184
216
|
const sum = (k) => (result.usage?.[k] ?? 0) + (continued.usage[k] ?? 0)
|
|
@@ -210,28 +242,98 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
210
242
|
// their import paths.
|
|
211
243
|
import { stripImagesForTextModel, normalizeToolPairing } from "./normalize.mjs"
|
|
212
244
|
export { stripImagesForTextModel, normalizeToolPairing }
|
|
245
|
+
/** Merge tool calls from a retry/continuation into the accumulated result.
|
|
246
|
+
* 2026-08-31 会诊 #7/#17:readSSE 输出的 tc 已 finalize(无 index 字段),
|
|
247
|
+
* 原实现恒 append(重试里 provider 重发完整 tc → tool 名 "get_weatherget_weather"、
|
|
248
|
+
* arguments 重复)。改按 id 定位已有槽位、无 id 才追加;name 只设一次。 */
|
|
249
|
+
function mergeRetryToolCalls(result, toolCalls) {
|
|
250
|
+
for (const tc of toolCalls ?? []) {
|
|
251
|
+
if (!tc) continue
|
|
252
|
+
let s
|
|
253
|
+
if (tc.id) {
|
|
254
|
+
s = result.toolCalls.find((x) => x && x.id === tc.id)
|
|
255
|
+
}
|
|
256
|
+
if (!s) {
|
|
257
|
+
// 无 id(synthetic call_N 在重试间不稳定)或未命中:按 name 找同 slot(重试语义
|
|
258
|
+
// 是"同一批工具调用重新执行",同名合并最稳);仍找不到才追加。
|
|
259
|
+
s = tc.name ? result.toolCalls.find((x) => x && x.name === tc.name) : undefined
|
|
260
|
+
}
|
|
261
|
+
if (!s) {
|
|
262
|
+
s = { id: "", name: "", arguments: "" }
|
|
263
|
+
result.toolCalls.push(s)
|
|
264
|
+
}
|
|
265
|
+
if (tc.id && !s.id) s.id = tc.id
|
|
266
|
+
if (tc.name && !s.name) s.name = tc.name
|
|
267
|
+
s.arguments += tc.arguments ?? ""
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
213
271
|
/** List available model IDs from the provider's /models endpoint */
|
|
214
272
|
export async function listModels(provider, { signal } = {}) {
|
|
215
|
-
|
|
273
|
+
// 2026-08-31 会诊 #10:与 chat 路径对齐——走 proxyUri、加 15s 超时、JSON 解析兜底
|
|
274
|
+
// (原实现直连 fetch 无超时无代理,慢/被墙域名的 /models 会挂死 UI)
|
|
275
|
+
const url = `${provider.baseURL}/models`
|
|
276
|
+
const opts = {
|
|
216
277
|
headers: { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey}` },
|
|
217
|
-
signal,
|
|
218
|
-
|
|
278
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
|
|
279
|
+
_headerTimeoutMs: 15_000,
|
|
280
|
+
_bodyIdleMs: 15_000,
|
|
281
|
+
}
|
|
282
|
+
const response = await (provider.proxyUri ? proxyFetch(url, opts, provider.proxyUri) : fetch(url, opts))
|
|
219
283
|
if (!response.ok) {
|
|
220
284
|
const text = await response.text().catch(() => "")
|
|
221
285
|
throw new Error(`GET /models failed ${response.status}: ${text}`)
|
|
222
286
|
}
|
|
223
|
-
const data = await response.json()
|
|
224
|
-
return (data
|
|
287
|
+
const data = await response.json().catch(() => null)
|
|
288
|
+
return (data?.data ?? []).map((m) => m.id).filter(Boolean).sort()
|
|
225
289
|
}
|
|
226
290
|
|
|
227
291
|
async function requestWithRetry(provider, body, signal, onWait) {
|
|
292
|
+
// THIN_DEBUG_BODY=1:发送前诊断——复现网关侧 "unexpected end of hex escape" 400 时
|
|
293
|
+
// 定位真实载荷里的毒序列(2026-08-31 slot 3 deepseek-v4-flash)。模拟网关最宽松的
|
|
294
|
+
// 爆炸条件:任何字面 "\u"/"\x" 后不足位(不看前置反斜杠)。
|
|
295
|
+
if (process.env.THIN_DEBUG_BODY) {
|
|
296
|
+
try {
|
|
297
|
+
const msgs = body?.messages ?? []
|
|
298
|
+
const raw = JSON.stringify(body)
|
|
299
|
+
const hits = []
|
|
300
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
301
|
+
const m = msgs[i] ?? {}
|
|
302
|
+
const fields = []
|
|
303
|
+
if (typeof m.content === "string") fields.push(["content", m.content])
|
|
304
|
+
else if (Array.isArray(m.content)) m.content.forEach((p, pi) => { if (p && typeof p.text === "string") fields.push([`content[${pi}]`, p.text]) })
|
|
305
|
+
if (typeof m.reasoning_content === "string") fields.push(["reasoning_content", m.reasoning_content])
|
|
306
|
+
if (Array.isArray(m.tool_calls)) m.tool_calls.forEach((tc, ti) => { if (tc && typeof tc.arguments === "string") fields.push([`tool_calls[${ti}].arguments`, tc.arguments]) })
|
|
307
|
+
if (typeof m.name === "string") fields.push(["name", m.name])
|
|
308
|
+
for (const [f, t] of fields) {
|
|
309
|
+
const re = /\\[xu]/g
|
|
310
|
+
let mm
|
|
311
|
+
while ((mm = re.exec(t))) {
|
|
312
|
+
const c = t[mm.index + 1]
|
|
313
|
+
const need = c === "u" ? 4 : 2
|
|
314
|
+
const after = t.slice(mm.index + 2, mm.index + 2 + need)
|
|
315
|
+
if (!new RegExp(`^[0-9a-fA-F]{${need}}$`).test(after)) {
|
|
316
|
+
hits.push({ i, role: m.role, field: f, ctx: t.slice(Math.max(0, mm.index - 40), mm.index + 12) })
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
console.error(`[debug-body] messages=${msgs.length} bodyLen=${raw.length} suspicious=${hits.length}`)
|
|
322
|
+
for (const h of hits.slice(0, 20)) console.error("[debug-body] hit", JSON.stringify(h))
|
|
323
|
+
if (!hits.length && msgs[1151]) {
|
|
324
|
+
console.error("[debug-body] no suspicious hit; messages[1151] =", JSON.stringify({ role: msgs[1151].role, contentLen: msgs[1151].content?.length, contentHead: String(msgs[1151].content).slice(0, 150) }))
|
|
325
|
+
}
|
|
326
|
+
} catch (e) {
|
|
327
|
+
console.error("[debug-body] diag failed:", e.message)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
228
330
|
let lastError
|
|
229
331
|
let lastStatus = 0
|
|
230
332
|
let lastWas429 = false
|
|
231
333
|
let rateLimitHits = 0
|
|
232
334
|
const totalAttempts = MAX_RETRIES + 1
|
|
233
335
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
234
|
-
if (attempt > 0 && !lastWas429) await
|
|
336
|
+
if (attempt > 0 && !lastWas429) await sleepInterruptible(2 ** (attempt - 1) * 1000, signal)
|
|
235
337
|
lastWas429 = false
|
|
236
338
|
|
|
237
339
|
let response
|
|
@@ -246,6 +348,10 @@ async function requestWithRetry(provider, body, signal, onWait) {
|
|
|
246
348
|
},
|
|
247
349
|
body: JSON.stringify(body),
|
|
248
350
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) : AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
351
|
+
// 2026-08-31 会诊 #4:代理路径响应头超时对齐直连语义(原 15s 与直连 600s 割裂,
|
|
352
|
+
// DeepSeek 排队 TTFB>15s 即误报)— 仅 _ 前缀内部字段,proxyFetch 消费
|
|
353
|
+
_headerTimeoutMs: FETCH_TIMEOUT_MS,
|
|
354
|
+
_bodyIdleMs: 120_000,
|
|
249
355
|
}
|
|
250
356
|
response = provider.proxyUri
|
|
251
357
|
? await proxyFetch(url, opts, provider.proxyUri)
|
|
@@ -260,10 +366,10 @@ async function requestWithRetry(provider, body, signal, onWait) {
|
|
|
260
366
|
|
|
261
367
|
const text = await response.text().catch(() => "")
|
|
262
368
|
let message = `LLM API error ${response.status}: ${text}`
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
if (response.status === 401) {
|
|
369
|
+
// 401 双平台提示 + 诊断回显(2026-08-31 会诊 #15):
|
|
370
|
+
// Kimi 双平台 key 不互通的提示保留;通用加 baseURL host + key 前 6 位掩码,
|
|
371
|
+
// 帮用户快速分辨"配错平台还是配错账号"。
|
|
372
|
+
if (response.status === 401 || response.status === 403) {
|
|
267
373
|
const key = String(provider.apiKey ?? "").trim()
|
|
268
374
|
const base = String(provider.baseURL ?? "").toLowerCase()
|
|
269
375
|
const kimiCodeKey = /^sk-kimi-/i.test(key)
|
|
@@ -271,20 +377,20 @@ async function requestWithRetry(provider, body, signal, onWait) {
|
|
|
271
377
|
if (kimiCodeKey || kimiCodeUrl) {
|
|
272
378
|
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
379
|
}
|
|
380
|
+
const host = (() => { try { return new URL(provider.baseURL).host } catch { return provider.baseURL ?? "(unknown)" } })()
|
|
381
|
+
const masked = key.length > 8 ? key.slice(0, 6) + "…" + key.slice(-4) : (key ? key.slice(0, 4) + "…" : "(empty)")
|
|
382
|
+
message += ` [auth diag: baseURL=${host} key=${masked} status=${response.status}]`
|
|
274
383
|
}
|
|
275
384
|
lastStatus = response.status
|
|
276
385
|
if (isNonRetryableError(response.status, text)) throw new Error(message)
|
|
277
386
|
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)]
|
|
387
|
+
const waitMs = parseRetryAfter(response.headers.get("retry-after"), rateLimitHits)
|
|
388
|
+
rateLimitHits++
|
|
283
389
|
lastError = new Error(message)
|
|
284
390
|
lastWas429 = true
|
|
285
391
|
if (attempt < MAX_RETRIES) {
|
|
286
392
|
onWait?.({ phase: "retry", seconds: Math.ceil(waitMs / 1000) })
|
|
287
|
-
await
|
|
393
|
+
await sleepInterruptible(waitMs, signal)
|
|
288
394
|
}
|
|
289
395
|
continue
|
|
290
396
|
}
|
|
@@ -299,7 +405,28 @@ async function requestWithRetry(provider, body, signal, onWait) {
|
|
|
299
405
|
: lastStatus >= 500 ? "Server error persisted"
|
|
300
406
|
: lastStatus > 0 ? "Request failed"
|
|
301
407
|
: "Network error"
|
|
302
|
-
|
|
408
|
+
// 会诊 #8:undici "fetch failed" 真因(ENOTFOUND/TLS/DNS/代理)藏在 error.cause —
|
|
409
|
+
// 拼进去,全链路同一文案不再掩盖根因
|
|
410
|
+
const causeText = lastError?.cause
|
|
411
|
+
? ` (${lastError.cause.code ?? lastError.cause.message ?? String(lastError.cause)})`
|
|
412
|
+
: ""
|
|
413
|
+
throw new Error(`${verb} after ${totalAttempts} attempts${lastStatus ? ` (${lastStatus})` : ""}: ${lastError?.message ?? "unknown"}${causeText}`)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Parse Retry-After: 秒数 or HTTP-date;上限 300s(会诊 #11)— 异常头不得让 CLI 睡数小时。
|
|
417
|
+
* header 缺失/非法时退回指数退避表(rateLimitHits 计数取档)。 */
|
|
418
|
+
export function parseRetryAfter(header, rateLimitHits = 0) {
|
|
419
|
+
const fallback = RATE_LIMIT_BACKOFF_MS[Math.min(rateLimitHits, RATE_LIMIT_BACKOFF_MS.length - 1)]
|
|
420
|
+
if (header == null) return fallback
|
|
421
|
+
let waitMs = 0
|
|
422
|
+
const numeric = Number(header.trim())
|
|
423
|
+
if (Number.isFinite(numeric) && numeric >= 0) waitMs = numeric * 1000
|
|
424
|
+
else {
|
|
425
|
+
const date = Date.parse(header.trim())
|
|
426
|
+
if (Number.isFinite(date)) waitMs = Math.max(0, date - Date.now())
|
|
427
|
+
}
|
|
428
|
+
if (waitMs <= 0) return fallback
|
|
429
|
+
return Math.min(waitMs, 300_000)
|
|
303
430
|
}
|
|
304
431
|
|
|
305
432
|
/**
|
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
|