@thincoder/core 0.9.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/abort-provenance.mjs +116 -0
- package/advisor/citations.mjs +139 -0
- package/advisor/compaction.mjs +174 -0
- package/advisor/convergence.mjs +80 -0
- package/advisor/history.mjs +77 -0
- package/advisor/loop.mjs +288 -0
- package/advisor/messages.mjs +299 -0
- package/advisor/notice.mjs +141 -0
- package/advisor/project-context.mjs +197 -0
- package/advisor/repos.mjs +150 -0
- package/advisor/run.mjs +190 -0
- package/advisor/truncate.mjs +57 -0
- package/advisor.mjs +281 -0
- package/agent/child-marks.mjs +24 -0
- package/agent/completion.mjs +145 -0
- package/agent/dispatch.mjs +493 -0
- package/agent/family-tools.mjs +174 -0
- package/agent/helpers.mjs +412 -0
- package/agent/post-turn.mjs +70 -0
- package/agent/record-results.mjs +174 -0
- package/agent/relay-prefix.mjs +39 -0
- package/agent/run-stages.mjs +244 -0
- package/agent/setup-reminders.mjs +199 -0
- package/agent/setup.mjs +234 -0
- package/agent/spawn-child.mjs +258 -0
- package/agent/suspension.mjs +240 -0
- package/agent/write-gate.mjs +87 -0
- package/agent-tools/advisor-async.mjs +481 -0
- package/agent-tools/advisor-settle.mjs +240 -0
- package/agent-tools/advisor.mjs +280 -0
- package/agent-tools/async-discard.mjs +143 -0
- package/agent-tools/async-settle.mjs +299 -0
- package/agent-tools/batch-segment.mjs +265 -0
- package/agent-tools/child-permission.mjs +45 -0
- package/agent-tools/consult.mjs +471 -0
- package/agent-tools/design-token.mjs +117 -0
- package/agent-tools/digest-budget.mjs +76 -0
- package/agent-tools/eng.mjs +102 -0
- package/agent-tools/escalate-async.mjs +302 -0
- package/agent-tools/goal.mjs +119 -0
- package/agent-tools/panel-blocks.mjs +24 -0
- package/agent-tools/parent-channel.mjs +231 -0
- package/agent-tools/plan.mjs +86 -0
- package/agent-tools/read-history.mjs +309 -0
- package/agent-tools/recent-changes.mjs +24 -0
- package/agent-tools/review-facts.mjs +31 -0
- package/agent-tools/settings.mjs +268 -0
- package/agent-tools/skill.mjs +63 -0
- package/agent-tools/spawn-gates.mjs +109 -0
- package/agent-tools/subagent-actions.mjs +496 -0
- package/agent-tools/subagent-async.mjs +456 -0
- package/agent-tools/subagent-panel.mjs +160 -0
- package/agent-tools/subagent-run.mjs +208 -0
- package/agent-tools/subagent-scheduler.mjs +446 -0
- package/agent-tools/subagent-spawn.mjs +478 -0
- package/agent-tools/subagent.mjs +419 -0
- package/agent-tools/task.mjs +87 -0
- package/agent-tools/timer.mjs +46 -0
- package/agent-tools/verify.mjs +295 -0
- package/agent-tools.mjs +23 -0
- package/agent.mjs +430 -0
- package/auto-think.mjs +115 -0
- package/compress-form.mjs +24 -0
- package/config-io.mjs +277 -0
- package/config-migrate.mjs +178 -0
- package/config-presets.mjs +49 -0
- package/config.mjs +419 -0
- package/context.mjs +495 -0
- package/conventions.mjs +223 -0
- package/embedding.mjs +120 -0
- package/escape.mjs +152 -0
- package/expand-home.mjs +16 -0
- package/explore-distill.mjs +152 -0
- package/generate-title.mjs +123 -0
- package/git/checkpoint.mjs +448 -0
- package/git/gitmem.mjs +100 -0
- package/history-window.mjs +179 -0
- package/hooks.mjs +108 -0
- package/i18n.mjs +106 -0
- package/index-bin.mjs +48 -0
- package/index-discover.mjs +176 -0
- package/ledger-cmd.mjs +209 -0
- package/ledger-db.mjs +86 -0
- package/ledger-surface.mjs +76 -0
- package/ledger.mjs +202 -0
- package/log.mjs +195 -0
- package/manifest.mjs +338 -0
- package/markdown.mjs +106 -0
- package/mcp/helpers.mjs +51 -0
- package/mcp/transport-http.mjs +248 -0
- package/mcp/transport-stdio.mjs +140 -0
- package/mcp/transport-ws.mjs +122 -0
- package/mcp.mjs +295 -0
- package/memory/code-index.mjs +219 -0
- package/memory/code-sync.mjs +427 -0
- package/memory/core.mjs +318 -0
- package/memory/delete.mjs +242 -0
- package/memory/docs.mjs +431 -0
- package/memory/file-walk.mjs +109 -0
- package/memory/origin.mjs +24 -0
- package/memory/scan.mjs +177 -0
- package/memory/schema.mjs +460 -0
- package/memory.mjs +21 -0
- package/model-ref.mjs +66 -0
- package/model-specs.mjs +277 -0
- package/package.json +32 -0
- package/peer-domains.mjs +265 -0
- package/peer-instances.mjs +178 -0
- package/permission.mjs +79 -0
- package/process-probe.mjs +315 -0
- package/prompt-files.mjs +113 -0
- package/prompt-overlays.mjs +78 -0
- package/prompts/advisor-design.md +43 -0
- package/prompts/advisor-round1.md +41 -0
- package/prompts/advisor-round2.md +46 -0
- package/prompts/advisor-round3.md +42 -0
- package/prompts/common.md +158 -0
- package/prompts/consult-base.md +19 -0
- package/prompts/discipline-engineering.md +123 -0
- package/prompts/discipline-normal.md +206 -0
- package/prompts/persona-coder.md +21 -0
- package/prompts/persona-eng-coder.md +41 -0
- package/prompts/persona-eng-designer.md +80 -0
- package/prompts/persona-engineering.md +160 -0
- package/prompts/persona-explore.md +15 -0
- package/prompts/persona-normal.md +35 -0
- package/prompts/persona-plan.md +27 -0
- package/provider/anthropic.mjs +225 -0
- package/provider/core.mjs +491 -0
- package/provider/errors.mjs +101 -0
- package/provider/google.mjs +257 -0
- package/provider/index.mjs +7 -0
- package/provider/list-models.mjs +163 -0
- package/provider/normalize.mjs +81 -0
- package/provider/rate.mjs +168 -0
- package/provider/responses.mjs +495 -0
- package/provider/retry.mjs +88 -0
- package/provider/sse.mjs +264 -0
- package/provider/wait-status.mjs +59 -0
- package/proxy.mjs +274 -0
- package/rules.mjs +53 -0
- package/session-gc.mjs +248 -0
- package/session-guard.mjs +59 -0
- package/session-lifecycle.mjs +305 -0
- package/session-migrate.mjs +48 -0
- package/session-rename.mjs +38 -0
- package/session-segments.mjs +99 -0
- package/session-slot-write.mjs +168 -0
- package/session-slots-manifest.mjs +264 -0
- package/session-slots.mjs +298 -0
- package/session-store.mjs +441 -0
- package/session.mjs +244 -0
- package/skills.mjs +234 -0
- package/text-budget.mjs +79 -0
- package/token-ttl.mjs +285 -0
- package/tool-docs/apply_patch.md +15 -0
- package/tool-docs/bash.md +38 -0
- package/tool-docs/delete.md +13 -0
- package/tool-docs/edit.md +30 -0
- package/tool-docs/execute.md +21 -0
- package/tool-docs/fetch.md +12 -0
- package/tool-docs/file_ops.md +17 -0
- package/tool-docs/get_current_time.md +8 -0
- package/tool-docs/git.md +54 -0
- package/tool-docs/glob.md +11 -0
- package/tool-docs/grep.md +19 -0
- package/tool-docs/hashline_edit.md +14 -0
- package/tool-docs/insert_after.md +15 -0
- package/tool-docs/lint.md +10 -0
- package/tool-docs/ls.md +12 -0
- package/tool-docs/lsp.md +10 -0
- package/tool-docs/process.md +10 -0
- package/tool-docs/question.md +16 -0
- package/tool-docs/read.md +20 -0
- package/tool-docs/read_image.md +8 -0
- package/tool-docs/tree.md +14 -0
- package/tool-docs/wait_for.md +22 -0
- package/tool-docs/websearch.md +16 -0
- package/tool-docs/write.md +11 -0
- package/tools/bash.mjs +276 -0
- package/tools/edit-batch.mjs +204 -0
- package/tools/edit-diff.mjs +388 -0
- package/tools/exec-run.mjs +43 -0
- package/tools/execute.mjs +243 -0
- package/tools/file.mjs +464 -0
- package/tools/git-checkpoint.mjs +143 -0
- package/tools/git-ext.mjs +173 -0
- package/tools/git.mjs +415 -0
- package/tools/glob-dialect.mjs +130 -0
- package/tools/index.mjs +76 -0
- package/tools/linter.mjs +120 -0
- package/tools/lsp.mjs +335 -0
- package/tools/ops.mjs +293 -0
- package/tools/patch.mjs +290 -0
- package/tools/question.mjs +26 -0
- package/tools/repomap.mjs +314 -0
- package/tools/search.mjs +248 -0
- package/tools/shared.mjs +467 -0
- package/tools/tree.mjs +81 -0
- package/tools/web.mjs +224 -0
- package/tools/write-path.mjs +191 -0
- package/traces/trace-store.mjs +303 -0
- package/undo-stack.mjs +47 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider/google.mjs — Google Gemini API transport
|
|
3
|
+
* Endpoint: POST https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent
|
|
4
|
+
* Docs: https://ai.google.dev/gemini-api/docs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { proxyFetch } from "../proxy.mjs"
|
|
8
|
+
import { requestWithRetry } from "./retry.mjs"
|
|
9
|
+
import { effectiveFetchTimeoutMs } from "./core.mjs"
|
|
10
|
+
import { abortError, timeoutError } from "../abort-provenance.mjs"
|
|
11
|
+
|
|
12
|
+
/** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
|
|
13
|
+
function mapFunctionCallingConfig(choice) {
|
|
14
|
+
if (choice === "auto") return { mode: "AUTO" }
|
|
15
|
+
if (choice === "required") return { mode: "ANY" }
|
|
16
|
+
if (choice === "none") return { mode: "NONE" }
|
|
17
|
+
if (choice && typeof choice === "object" && choice.function?.name) return { mode: "ANY", allowedFunctionNames: [choice.function.name] }
|
|
18
|
+
throw new Error(`Invalid tool_choice for Gemini format: ${JSON.stringify(choice).slice(0, 120)}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
/** Convert OpenAI-format tools to Gemini format */
|
|
23
|
+
export function normalizeTools(tools) {
|
|
24
|
+
if (!tools?.length) return null
|
|
25
|
+
return [{
|
|
26
|
+
functionDeclarations: tools.map((t) => ({
|
|
27
|
+
name: t.function.name,
|
|
28
|
+
description: t.function.description || "",
|
|
29
|
+
parameters: t.function.parameters || { type: "object", properties: {} },
|
|
30
|
+
})),
|
|
31
|
+
}]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Convert OpenAI-format messages to Gemini contents array.
|
|
36
|
+
* Gemini: [{ role: "user"|"model", parts: [{ text }] }]
|
|
37
|
+
* system → systemInstruction (top-level in request body)
|
|
38
|
+
*/
|
|
39
|
+
export function convertMessages(messages) {
|
|
40
|
+
const contents = []
|
|
41
|
+
for (const m of messages) {
|
|
42
|
+
// system messages are hoisted to systemInstruction by the caller — check the
|
|
43
|
+
// ORIGINAL role (the remapped role below can never be "system")
|
|
44
|
+
if (m.role === "system") continue
|
|
45
|
+
const role = m.role === "assistant" ? "model" : "user"
|
|
46
|
+
|
|
47
|
+
const parts = []
|
|
48
|
+
if (typeof m.content === "string") {
|
|
49
|
+
parts.push({ text: m.content })
|
|
50
|
+
} else if (Array.isArray(m.content)) {
|
|
51
|
+
for (const part of m.content) {
|
|
52
|
+
if (part.type === "text") parts.push({ text: part.text })
|
|
53
|
+
else if (part.type === "image_url") {
|
|
54
|
+
const url = part.image_url?.url || ""
|
|
55
|
+
const mimeMatch = url.match(/^data:([^;]+);base64,(.+)$/)
|
|
56
|
+
if (mimeMatch) {
|
|
57
|
+
parts.push({ inlineData: { mimeType: mimeMatch[1], data: mimeMatch[2] } })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (parts.length === 0) continue
|
|
63
|
+
|
|
64
|
+
// Gemini doesn't allow consecutive same-role messages; merge
|
|
65
|
+
const last = contents[contents.length - 1]
|
|
66
|
+
if (last?.role === role) {
|
|
67
|
+
last.parts.push(...parts)
|
|
68
|
+
} else {
|
|
69
|
+
contents.push({ role, parts })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return contents
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build and send a Gemini chat request. Returns the same shape as core.mjs chat.
|
|
76
|
+
* 2026-08-31 会诊 #6:接入 rateGate/recordRate(原实现完全绕过 TPM/RPM 闸门)。 */
|
|
77
|
+
export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice }) {
|
|
78
|
+
const systemMessages = messages.filter((m) => m.role === "system")
|
|
79
|
+
const contents = convertMessages(messages)
|
|
80
|
+
|
|
81
|
+
const body = {
|
|
82
|
+
contents,
|
|
83
|
+
generationConfig: {
|
|
84
|
+
...(provider.temperature != null ? { temperature: provider.temperature } : {}),
|
|
85
|
+
...(provider.maxTokens ? { maxOutputTokens: provider.maxTokens } : {}),
|
|
86
|
+
},
|
|
87
|
+
safetySettings: [
|
|
88
|
+
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" },
|
|
89
|
+
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" },
|
|
90
|
+
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE" },
|
|
91
|
+
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE" },
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
if (systemMessages.length > 0) {
|
|
95
|
+
body.systemInstruction = {
|
|
96
|
+
parts: [{ text: systemMessages.map((m) => m.content).join("\n\n") }],
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (tools?.length) body.tools = tools
|
|
100
|
+
// 2026-08-31:tool_choice 能力层 → Gemini toolConfig.functionCallingConfig
|
|
101
|
+
if (toolChoice !== undefined) {
|
|
102
|
+
body.toolConfig = { functionCallingConfig: mapFunctionCallingConfig(toolChoice) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Gemini uses API key as query parameter
|
|
106
|
+
const url = `${provider.baseURL}/models/${provider.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(provider.apiKey)}`
|
|
107
|
+
|
|
108
|
+
if (signal?.aborted) throw abortError(signal, "provider", "transport-google")
|
|
109
|
+
|
|
110
|
+
// 会诊 #6:TPM/RPM 闸门 + 记账
|
|
111
|
+
const { rateGate, recordRate, estimateRequestTokens } = await import("./rate.mjs")
|
|
112
|
+
const estimated = estimateRequestTokens({ messages })
|
|
113
|
+
await rateGate(provider, estimated, onWait, signal)
|
|
114
|
+
|
|
115
|
+
// 2026-08-31:5xx/网络与 OpenAI 格式统一退避重试链(原完全无重试——Gemini 高峰
|
|
116
|
+
// 503 直接抛错崩溃整个 turn)
|
|
117
|
+
const response = await requestWithRetry(
|
|
118
|
+
() => proxyFetch(url, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { ...(provider.headers ?? {}), "Content-Type": "application/json" }, // 定制头展开(PROVIDER.md §21)——定制头在前、内置头在后:内置头胜出
|
|
121
|
+
body: JSON.stringify(body),
|
|
122
|
+
// 2026-09-01:同 core.mjs——绝对墙钟废除;响应头阶段 fetchTimeoutMs(600s 默认),body 阶段读侧 idle 管
|
|
123
|
+
signal,
|
|
124
|
+
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
125
|
+
_bodyIdleMs: 120_000,
|
|
126
|
+
}, provider.proxyUri),
|
|
127
|
+
{ signal, onWait, buildMessage: (status, text) => `Gemini API error ${status}: ${text}` },
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const result = await parseGeminiStream(response, { onToken, onReasoning, signal })
|
|
131
|
+
recordRate(provider, estimated, result.usage)
|
|
132
|
+
|
|
133
|
+
const usage = result.usage
|
|
134
|
+
if (usage) {
|
|
135
|
+
return {
|
|
136
|
+
content: result.content,
|
|
137
|
+
reasoning: result.reasoning,
|
|
138
|
+
usage: {
|
|
139
|
+
prompt_tokens: usage.prompt_tokens ?? 0,
|
|
140
|
+
completion_tokens: usage.completion_tokens ?? 0,
|
|
141
|
+
total_tokens: usage.total_tokens ?? 0,
|
|
142
|
+
},
|
|
143
|
+
toolCalls: result.toolCalls,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { content: result.content, reasoning: result.reasoning, toolCalls: result.toolCalls }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse Gemini SSE stream.
|
|
152
|
+
* Format: data: {...}\n\n (each line is a complete JSON object)
|
|
153
|
+
*/
|
|
154
|
+
async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
|
|
155
|
+
const result = { content: "", reasoning: "", toolCalls: [], usage: null }
|
|
156
|
+
const decoder = new TextDecoder()
|
|
157
|
+
let buffer = ""
|
|
158
|
+
|
|
159
|
+
const processData = (data) => {
|
|
160
|
+
let json
|
|
161
|
+
try { json = JSON.parse(data) } catch { return }
|
|
162
|
+
if (!json) return
|
|
163
|
+
|
|
164
|
+
if (json.usageMetadata) {
|
|
165
|
+
result.usage = {
|
|
166
|
+
prompt_tokens: json.usageMetadata.promptTokenCount || 0,
|
|
167
|
+
completion_tokens: json.usageMetadata.candidatesTokenCount || 0,
|
|
168
|
+
total_tokens: json.usageMetadata.totalTokenCount || 0,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const candidate = json.candidates?.[0]
|
|
173
|
+
if (!candidate) return
|
|
174
|
+
|
|
175
|
+
const parts = candidate.content?.parts || []
|
|
176
|
+
for (const part of parts) {
|
|
177
|
+
if (part.thought === true && part.text) {
|
|
178
|
+
result.reasoning += part.text
|
|
179
|
+
onReasoning?.(part.text)
|
|
180
|
+
} else if (part.text) {
|
|
181
|
+
result.content += part.text
|
|
182
|
+
onToken?.(part.text)
|
|
183
|
+
} else if (part.functionCall) {
|
|
184
|
+
const existing = result.toolCalls.find((tc) => tc.name === part.functionCall.name)
|
|
185
|
+
if (!existing) {
|
|
186
|
+
result.toolCalls.push({
|
|
187
|
+
id: part.functionCall.name + "_" + result.toolCalls.length,
|
|
188
|
+
name: part.functionCall.name,
|
|
189
|
+
arguments: JSON.stringify(part.functionCall.args || {}),
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!response.body) throw new Error("No stream response body")
|
|
197
|
+
// 2026-09-01 读侧 idle 超时(同 sse.mjs):body 有数据流动即不超时;连续 120s 无新 chunk 判死
|
|
198
|
+
const READ_IDLE_MS = 120_000
|
|
199
|
+
let idleTimer = null
|
|
200
|
+
const armIdle = () => {
|
|
201
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
202
|
+
idleTimer = setTimeout(() => {
|
|
203
|
+
try { response.body?.destroy(timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "google-sse-idle")) } catch { /* already gone */ }
|
|
204
|
+
}, READ_IDLE_MS)
|
|
205
|
+
idleTimer.unref?.()
|
|
206
|
+
}
|
|
207
|
+
armIdle()
|
|
208
|
+
try {
|
|
209
|
+
for await (const chunk of response.body) {
|
|
210
|
+
armIdle()
|
|
211
|
+
if (signal?.aborted) {
|
|
212
|
+
throw abortError(signal, "provider", "transport-google")
|
|
213
|
+
}
|
|
214
|
+
buffer += decoder.decode(chunk, { stream: true })
|
|
215
|
+
// BOM 剥除(会诊 #12):首个 chunk 可能带 \uFEFF,否则首个 data 事件静默丢失
|
|
216
|
+
if (buffer.charCodeAt(0) === 0xfeff) buffer = buffer.slice(1)
|
|
217
|
+
const lines = buffer.split("\n")
|
|
218
|
+
buffer = lines.pop()
|
|
219
|
+
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
if (!line.startsWith("data:")) continue
|
|
222
|
+
const data = line.slice(5).trim()
|
|
223
|
+
if (!data || data === "[DONE]") continue
|
|
224
|
+
processData(data)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
buffer += decoder.decode()
|
|
228
|
+
for (const line of buffer.split("\n")) {
|
|
229
|
+
if (!line.startsWith("data:")) continue
|
|
230
|
+
const data = line.slice(5).trim()
|
|
231
|
+
if (!data || data === "[DONE]") continue
|
|
232
|
+
processData(data)
|
|
233
|
+
}
|
|
234
|
+
} catch (e) {
|
|
235
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
236
|
+
if (e.name === "AbortError" && signal?.reason?.interrupt) {
|
|
237
|
+
result.interrupted = true
|
|
238
|
+
result.interruptMessage = signal.reason.message
|
|
239
|
+
return result
|
|
240
|
+
}
|
|
241
|
+
if (hasPartial(e)) {
|
|
242
|
+
result.partial = true
|
|
243
|
+
result.networkError = e.message ?? String(e)
|
|
244
|
+
return result
|
|
245
|
+
}
|
|
246
|
+
throw e
|
|
247
|
+
} finally {
|
|
248
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return result
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** google.mjs 无 hasChoices 追踪——只有流中途死且已有内容才标 partial(同 sse.mjs 语义的简化版) */
|
|
255
|
+
function hasPartial(e) {
|
|
256
|
+
return /ECONNRESET|terminated|idle timeout|network/i.test(e?.message ?? "")
|
|
257
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider/index.mjs — backward-compatible re-export
|
|
3
|
+
* import { chat } from "./provider" → resolves to this file
|
|
4
|
+
*/
|
|
5
|
+
export { chat, createProvider, stripImagesForTextModel } from "./core.mjs"
|
|
6
|
+
export { listModels } from "./list-models.mjs"
|
|
7
|
+
export { RETRYABLE_STATUS, _rateHooks, estimateText, estimateRequestTokens, rateGate, recordRate } from "./rate.mjs"
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list-models.mjs — provider 模型清单拉取(GET /models,按 provider.format 分派——PROVIDER.md §16 M1)。
|
|
3
|
+
*
|
|
4
|
+
* 2026-09-10 自 core.mjs 迁出(原实现仅 OpenAI 形状)+ 扩 anthropic / google 两分支:
|
|
5
|
+
* - openai(缺省/未知 format——与 chat 分派缺省一致):`GET {baseURL}/models` + Bearer;解析 `data[].id`
|
|
6
|
+
* - anthropic:`GET {baseURL}/models?limit=1000` + `x-api-key` / `anthropic-version`;`has_more` 时以
|
|
7
|
+
* `last_id` 作 `after_id` 翻页跟随(≤10 页——防死循环)
|
|
8
|
+
* - google:`GET {baseURL}/models?key=…&pageSize=1000`;剥 `models/` 前缀;`nextPageToken` 翻页跟随(≤10 页)
|
|
9
|
+
*
|
|
10
|
+
* URL 组合 = `{baseURL}` + 相对路径,与 chat 各 transport 同构——baseURL 自带版本段
|
|
11
|
+
* (claude 预设 `…/v1` → `…/v1/models`;gemini 预设 `…/v1beta` → `…/v1beta/models`)。
|
|
12
|
+
* 超时制度沿用原实现(整体 15s + header 15s + body idle 15s;翻页时逐页各自计时;调用方可传
|
|
13
|
+
* `signal` 短路)。HTTP 非 2xx / 网络失败**抛出**(调用方决定降级——与现实现同);
|
|
14
|
+
* 解析保持防御性(字段缺失即跳过该项)。候选不过滤非对话模型(embedding 等——§16.6 #14)。
|
|
15
|
+
*/
|
|
16
|
+
import { proxyFetch } from "../proxy.mjs"
|
|
17
|
+
|
|
18
|
+
const LIST_TIMEOUT_MS = 15_000
|
|
19
|
+
/** 翻页上限(cursor loop 防死循环——任一分页失败即整体抛出,不部分返回)。 */
|
|
20
|
+
const MAX_PAGES = 10
|
|
21
|
+
const ANTHROPIC_VERSION = "2023-06-01"
|
|
22
|
+
|
|
23
|
+
/** 单页 GET + JSON 解析(非 2xx → throw;非 JSON 响应 → throw——§2.5 #115 并入 VSC 明确报错)。 */
|
|
24
|
+
async function fetchJson(provider, url, headers, signal) {
|
|
25
|
+
const opts = {
|
|
26
|
+
headers,
|
|
27
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(LIST_TIMEOUT_MS)]) : AbortSignal.timeout(LIST_TIMEOUT_MS),
|
|
28
|
+
_headerTimeoutMs: LIST_TIMEOUT_MS,
|
|
29
|
+
_bodyIdleMs: LIST_TIMEOUT_MS,
|
|
30
|
+
}
|
|
31
|
+
const response = await (provider.proxyUri ? proxyFetch(url, opts, provider.proxyUri) : fetch(url, opts))
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const text = await response.text().catch(() => "")
|
|
34
|
+
const e = new Error(`GET /models failed ${response.status}: ${text.slice(0, 200)}`)
|
|
35
|
+
e.status = response.status // 展示面可判「不可用」
|
|
36
|
+
throw e
|
|
37
|
+
}
|
|
38
|
+
const rawText = await response.text().catch(() => "")
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(rawText)
|
|
41
|
+
} catch {
|
|
42
|
+
// 网关/代理返回 HTML 等非 JSON——拉不到清单(明确报错,不静默空清单)
|
|
43
|
+
throw new Error("GET /models failed: non-JSON response")
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 防御性收集:字段缺失/非字符串项跳过。 */
|
|
48
|
+
function collect(rows, pick) {
|
|
49
|
+
if (!Array.isArray(rows)) return []
|
|
50
|
+
const out = []
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
const v = pick(row)
|
|
53
|
+
if (typeof v === "string" && v) out.push(v)
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function listOpenai(provider, signal) {
|
|
59
|
+
const url = `${provider.baseURL}/models`
|
|
60
|
+
const data = await fetchJson(provider, url, { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey ?? ""}` }, signal)
|
|
61
|
+
return collect(data?.data, (m) => m?.id)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function listAnthropic(provider, signal) {
|
|
65
|
+
const headers = { ...(provider.headers ?? {}), "x-api-key": provider.apiKey ?? "", "anthropic-version": ANTHROPIC_VERSION }
|
|
66
|
+
const base = `${provider.baseURL}/models?limit=1000`
|
|
67
|
+
const out = []
|
|
68
|
+
let afterId = null
|
|
69
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
70
|
+
const url = afterId ? `${base}&after_id=${encodeURIComponent(afterId)}` : base
|
|
71
|
+
const data = await fetchJson(provider, url, headers, signal)
|
|
72
|
+
out.push(...collect(data?.data, (m) => m?.id))
|
|
73
|
+
if (!data?.has_more) return out
|
|
74
|
+
afterId = typeof data?.last_id === "string" && data.last_id ? data.last_id : null
|
|
75
|
+
if (!afterId) return out // has_more 但无游标——无法继续,避免死循环
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function listGoogle(provider, signal) {
|
|
81
|
+
const headers = { ...(provider.headers ?? {}) }
|
|
82
|
+
const base = `${provider.baseURL}/models?key=${encodeURIComponent(provider.apiKey ?? "")}&pageSize=1000`
|
|
83
|
+
const out = []
|
|
84
|
+
let pageToken = null
|
|
85
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
86
|
+
const url = pageToken ? `${base}&pageToken=${encodeURIComponent(pageToken)}` : base
|
|
87
|
+
const data = await fetchJson(provider, url, headers, signal)
|
|
88
|
+
out.push(...collect(data?.models, (m) => (typeof m?.name === "string" ? m.name.replace(/^models\//, "") : null)))
|
|
89
|
+
if (typeof data?.nextPageToken !== "string" || !data.nextPageToken) return out
|
|
90
|
+
pageToken = data.nextPageToken
|
|
91
|
+
}
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** List available model IDs from the provider's /models endpoint (format 分派——M1)。 */
|
|
96
|
+
export async function listModels(provider, { signal } = {}) {
|
|
97
|
+
const base = String(provider?.baseURL ?? "").replace(/\/+$/, "")
|
|
98
|
+
if (!base) throw new Error("GET /models failed: baseURL is missing") // §2.5 #115 并入(VSC 明确报错)
|
|
99
|
+
const format = provider?.format
|
|
100
|
+
const ids = format === "anthropic" ? await listAnthropic(provider, signal)
|
|
101
|
+
: format === "google" ? await listGoogle(provider, signal)
|
|
102
|
+
: await listOpenai(provider, signal)
|
|
103
|
+
// §2.5 #115 并入(VSC 排序):返回前本端排序——UI 确定性。
|
|
104
|
+
return ids.filter((id) => typeof id === "string" && id).sort()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 渠道准入失败消息(§2.5 #115 / VSC M8 并入——逐字长句 = 消息本体;状态标签由 UI 层组合)。
|
|
108
|
+
* {状态} = HTTP 状态或网络错误摘要。 */
|
|
109
|
+
export function channelUnavailableMessage(error) {
|
|
110
|
+
const status = Number.isInteger(error?.status) ? String(error.status) : String(error?.message || error)
|
|
111
|
+
return `该渠道不提供模型列表(GET /models ${status})——无法选择模型,请改用其他渠道`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── 渠道准入展示态 + 探针(§2.5 #115 / VSC M9 并入)─────────────────────────
|
|
115
|
+
// 记录 = 「最近一次探测的展示结果」,不是阻断缓存:任何配置动作/面板候选拉取都会重探,
|
|
116
|
+
// 失败也绝不阻止重探(失败不缓存语义)。
|
|
117
|
+
// F-W19(`PROVIDER.md` §6.16 M8/M9 补):落账形态 = 成功 `{ ok: true, ts }` /
|
|
118
|
+
// 失败 `{ ok: false, reason, failure, ts }`——`failure` ∈ { timeout, malformed, hostBusy }。
|
|
119
|
+
const _admission = new Map() // name → { ok: true, ts } | { ok: false, reason, failure, ts }
|
|
120
|
+
|
|
121
|
+
/** 探针失败分类(核侧两档——`PROVIDER.md` §6.16 M8/M9 补):
|
|
122
|
+
* `timeout` = 超时族(`AbortSignal.timeout` 的 TimeoutError / 代理读侧 abort `trigger"timeout"` /
|
|
123
|
+
* 消息含 timeout / AbortError);其余(HTTP 非 2xx / 载荷畸形 / 连接类)一律 `malformed`。
|
|
124
|
+
* `hostBusy` 不由核判(核零宿主事件循环观测)——端侧采样器以证据覆盖(`SETTINGS.md` §2.12)。 */
|
|
125
|
+
export function classifyProbeFailure(error) {
|
|
126
|
+
if (error?.name === "TimeoutError" || error?.name === "AbortError") return "timeout"
|
|
127
|
+
if (error?.abortInfo?.trigger === "timeout") return "timeout"
|
|
128
|
+
return /timeout/i.test(String(error?.message ?? "")) ? "timeout" : "malformed"
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 记录一次渠道准入探测结果(统一盖落账时间 `ts`——调用方显式给定则尊重:测试缝注入)。 */
|
|
132
|
+
export function recordAdmission(name, result) {
|
|
133
|
+
if (!name) return
|
|
134
|
+
const rec = { ...(result ?? {}) }
|
|
135
|
+
if (!Number.isFinite(rec.ts)) rec.ts = Date.now()
|
|
136
|
+
_admission.set(name, rec)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 读取渠道准入展示态(未探过 → null)。 */
|
|
140
|
+
export function admissionOf(name) {
|
|
141
|
+
return _admission.get(name) ?? null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 测试缝(先例 rate.mjs `_rateHooks`):探针实现可注入——缺省 = 真探。
|
|
145
|
+
let _probeImpl = null
|
|
146
|
+
export function _setProbeImplForTest(fn) { _probeImpl = fn }
|
|
147
|
+
export function _resetAdmissionForTest() { _admission.clear(); _probeImpl = null }
|
|
148
|
+
|
|
149
|
+
/** 配置阶段准入探针:对目标渠道探一次 GET /models 并记录结果。
|
|
150
|
+
* 探通 → 渠道可用(探得候选可直接用);探不通 → 记录失败 + 返回失败消息。
|
|
151
|
+
* **绝不抛出**(不阻断任何配置写);失败不缓存——下次配置动作重探。 */
|
|
152
|
+
export async function probeChannelModels(name, provider) {
|
|
153
|
+
if (_probeImpl) return _probeImpl(name, provider)
|
|
154
|
+
try {
|
|
155
|
+
const models = await listModels(provider)
|
|
156
|
+
recordAdmission(name, { ok: true })
|
|
157
|
+
return { ok: true, models }
|
|
158
|
+
} catch (e) {
|
|
159
|
+
const error = channelUnavailableMessage(e)
|
|
160
|
+
recordAdmission(name, { ok: false, reason: error, failure: classifyProbeFailure(e) })
|
|
161
|
+
return { ok: false, error }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider/normalize.mjs — pre-send payload normalization (2026-08-31 extract).
|
|
3
|
+
*
|
|
4
|
+
* Split from core.mjs (TODO #2: 420 lines, past the 300 advisory). These two
|
|
5
|
+
* pure functions sanitize the message array right before it hits the wire;
|
|
6
|
+
* no dependency on chat()/retry logic. core.mjs re-exports them so
|
|
7
|
+
* provider/index.mjs and tool-pairing.test.mjs keep their import paths.
|
|
8
|
+
* The caller passes the spec (providerSpec from core.mjs — provider-aware).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const RASTER_IMAGE_URL = /^data:image\/(png|jpe?g|gif|webp);base64,/
|
|
12
|
+
|
|
13
|
+
export function stripImagesForTextModel(messages, spec) {
|
|
14
|
+
let changed = false
|
|
15
|
+
const out = messages.map((m) => {
|
|
16
|
+
if (!Array.isArray(m.content) || !m.content.some((p) => p?.type === "image_url")) return m
|
|
17
|
+
let msgChanged = false
|
|
18
|
+
const parts = m.content.map((p) => {
|
|
19
|
+
if (p?.type !== "image_url") return p
|
|
20
|
+
const url = p.image_url?.url || ""
|
|
21
|
+
if (!url.startsWith("data:")) return p
|
|
22
|
+
if (spec.multimodal && RASTER_IMAGE_URL.test(url)) return p
|
|
23
|
+
msgChanged = true
|
|
24
|
+
const reason = spec.multimodal
|
|
25
|
+
? `unsupported format ${url.match(/^data:([^;,]+)/)?.[1] || "unknown"}`
|
|
26
|
+
: "this model does not support image input"
|
|
27
|
+
return { type: "text", text: `[image omitted — ${reason}]` }
|
|
28
|
+
})
|
|
29
|
+
if (!msgChanged) return m
|
|
30
|
+
changed = true
|
|
31
|
+
return { ...m, content: parts }
|
|
32
|
+
})
|
|
33
|
+
return changed ? out : messages
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Enforce the OpenAI tool-message protocol on the outgoing payload: every tool message must
|
|
38
|
+
* immediately follow the assistant message declaring its tool_call_id, and every declared
|
|
39
|
+
* tool_call must have a result. Strict providers (DeepSeek) reject the whole request with 400
|
|
40
|
+
* ("Messages with role 'tool' must be a response to a preceding message with 'tool_calls'").
|
|
41
|
+
* History can legitimately violate this — parallel read_image injects a user message between
|
|
42
|
+
* tool results, compaction splits, interrupted sessions leave dangling tool_calls — so sanitize
|
|
43
|
+
* at send time. History itself is left untouched.
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeToolPairing(messages) {
|
|
46
|
+
// Detach all tool messages; reinsert each right after its owner assistant.
|
|
47
|
+
const toolById = new Map()
|
|
48
|
+
const rest = []
|
|
49
|
+
for (const m of messages) {
|
|
50
|
+
if (m.role === "tool") {
|
|
51
|
+
if (!toolById.has(m.tool_call_id)) toolById.set(m.tool_call_id, m)
|
|
52
|
+
} else {
|
|
53
|
+
rest.push(m)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (toolById.size === 0 && !messages.some((m) => m.role === "assistant" && m.tool_calls?.length)) {
|
|
57
|
+
return messages // no tool messages AND no tool_calls declared — nothing to enforce
|
|
58
|
+
}
|
|
59
|
+
const out = []
|
|
60
|
+
for (const m of rest) {
|
|
61
|
+
out.push(m)
|
|
62
|
+
if (m.role !== "assistant" || !m.tool_calls?.length) continue
|
|
63
|
+
for (const tc of m.tool_calls) {
|
|
64
|
+
const t = toolById.get(tc.id)
|
|
65
|
+
if (t) {
|
|
66
|
+
toolById.delete(tc.id)
|
|
67
|
+
out.push(t)
|
|
68
|
+
} else {
|
|
69
|
+
// Declared tool_call with no recorded result (interrupted session / compaction split)
|
|
70
|
+
out.push({
|
|
71
|
+
role: "tool",
|
|
72
|
+
tool_call_id: tc.id,
|
|
73
|
+
content: "[Tool result missing: the call was interrupted or its result was dropped by context compaction]",
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Leftovers in toolById are orphans (owner assistant compacted away or never recorded) — dropped
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider/rate.mjs — TPM/RPM proactive throttling gate
|
|
3
|
+
* Sliding-window accounting; pre-check budget before sending requests; sleep until window frees space when over budget.
|
|
4
|
+
*/
|
|
5
|
+
import { abortError } from "../abort-provenance.mjs"
|
|
6
|
+
import { specForModel } from "../model-specs.mjs"
|
|
7
|
+
|
|
8
|
+
export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
|
|
9
|
+
export const MAX_RETRIES = 3
|
|
10
|
+
export const MAX_CONTINUATIONS = 3
|
|
11
|
+
export const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Test hooks: sleep/clock/window length are replaceable (offline tests can't really wait 60s).
|
|
15
|
+
* Production code should never call setTimeout/sleep directly — always go through these.
|
|
16
|
+
*/
|
|
17
|
+
export const _rateHooks = {
|
|
18
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
19
|
+
now: () => Date.now(),
|
|
20
|
+
windowMs: 60_000,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Abort-aware sleep(§2.5 #114 并入——VSC 侧 `abortableSleep` 归位):用户按 Stop 时立即
|
|
25
|
+
* 中断(拒绝 AbortError)——重试退避与限流等待不得在中断后再扣住回合最长 60s。
|
|
26
|
+
*/
|
|
27
|
+
export async function abortableSleep(ms, signal) {
|
|
28
|
+
if (!signal) return _rateHooks.sleep(ms)
|
|
29
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError")
|
|
30
|
+
await new Promise((resolve, reject) => {
|
|
31
|
+
const t = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve() }, ms)
|
|
32
|
+
function onAbort() { clearTimeout(t); reject(new DOMException("Aborted", "AbortError")) }
|
|
33
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const rateWindows = new Map() // key → { tokens: [{ts, n}], requests: [ts] }
|
|
38
|
+
|
|
39
|
+
function rateKey(provider) {
|
|
40
|
+
// Normalize: /beta and /v1 are treated as the same account's rate-limit window (DeepSeek prefix continuation switches to /beta endpoint)
|
|
41
|
+
const base = provider.baseURL.replace(/\/beta$/, "/v1")
|
|
42
|
+
return `${base}|${provider.apiKey ?? ""}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Rough estimate of text token count.
|
|
46
|
+
* ASCII ~4 chars/token; non-ASCII (CJK/emoji) ~1 char/token (conservative; measured BPE is 1.5-2.5 chars/token).
|
|
47
|
+
* §2.5 #114 并入(token 估算取并集):数组内容按 part 分派——text 走单串估算,
|
|
48
|
+
* image_url part 计 85 tokens(VSC 侧口径)。 */
|
|
49
|
+
export function estimateText(s) {
|
|
50
|
+
if (!s) return 0
|
|
51
|
+
if (Array.isArray(s)) {
|
|
52
|
+
let tokens = 0
|
|
53
|
+
for (const part of s) {
|
|
54
|
+
if (part?.type === "text") tokens += _estimateSingle(String(part.text ?? ""))
|
|
55
|
+
else if (part?.type === "image_url") tokens += 85
|
|
56
|
+
}
|
|
57
|
+
return tokens
|
|
58
|
+
}
|
|
59
|
+
return _estimateSingle(String(s))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _estimateSingle(s) {
|
|
63
|
+
let nonAscii = 0
|
|
64
|
+
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) nonAscii++
|
|
65
|
+
return Math.ceil((s.length - nonAscii) / 4) + nonAscii
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Estimated prompt tokens for this request */
|
|
69
|
+
export function estimateRequestTokens(body) {
|
|
70
|
+
let tokens = 0
|
|
71
|
+
for (const m of body.messages ?? []) {
|
|
72
|
+
tokens += estimateText(m.content)
|
|
73
|
+
if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
|
|
74
|
+
for (const tc of m.tool_calls ?? []) {
|
|
75
|
+
tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (body.tools) tokens += estimateText(JSON.stringify(body.tools))
|
|
79
|
+
// §2.5 #114 并入(token 估算取并集):max_tokens(输出预算)计入请求估算(VSC 侧口径)。
|
|
80
|
+
if (body.max_tokens) tokens += body.max_tokens
|
|
81
|
+
return tokens
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Gate: sleep until window frees space when over budget */
|
|
85
|
+
export async function rateGate(provider, estimated, onWait, signal) {
|
|
86
|
+
// §2.5 #114 并入(默认限流口径取 VSC 的 spec 回退):provider 未显式配置 tpm/rpm 时
|
|
87
|
+
// 回退到模型规格表的限流字段(当前表内无 tpm/rpm 字段 ⇒ 回退为未配置,行为不变;
|
|
88
|
+
// 表后续补字段时自动生效)。
|
|
89
|
+
const spec = specForModel(provider.model)
|
|
90
|
+
const configuredTpm = provider.tpm ?? spec?.tpm ?? null
|
|
91
|
+
const configuredRpm = provider.rpm ?? spec?.rpm ?? null
|
|
92
|
+
// 2026-08-31 会诊 #16:单请求估算已超 tpm 时原实现静默放行(必然撞服务端 429)。
|
|
93
|
+
// 保持放行(tpm 置 null 防止 overTokens 恒正值死等),但明确告警让上层/用户知情。
|
|
94
|
+
let warned = false
|
|
95
|
+
if (configuredTpm != null && estimated > configuredTpm) {
|
|
96
|
+
onWait?.({ phase: "warn", message: `estimated ${estimated} tokens > tpm ${configuredTpm} — request proceeds and may hit a server 429` })
|
|
97
|
+
warned = true
|
|
98
|
+
}
|
|
99
|
+
const tpm = configuredTpm != null && estimated <= configuredTpm ? configuredTpm : null
|
|
100
|
+
const rpm = configuredRpm
|
|
101
|
+
if (tpm == null && rpm == null) return
|
|
102
|
+
// §2.5 #114 并入(VSC 默认维度回退——A19 影响面②):单维度配置时补另一维度默认
|
|
103
|
+
//(只配 rpm 的场景多一层 TPM 闸——限流更早触发;缺省值逐字同 VSC)。
|
|
104
|
+
const effectiveTpm = tpm ?? 1_000_000
|
|
105
|
+
const effectiveRpm = rpm ?? 500
|
|
106
|
+
// §2.5 #114 并入(VSC 防死等守护):`estimated > effectiveTpm` 时 overTokens 恒真
|
|
107
|
+
//(usedTokens ≥ 0)⇒ 窗口等待循环永不放行(sleep 循环无限空转)——告警 + 记账 +
|
|
108
|
+
//直接放行,不再进窗口等待(VSC 同款提前放行语义)。
|
|
109
|
+
if (estimated > effectiveTpm) {
|
|
110
|
+
if (!warned) onWait?.({ phase: "warn", message: `estimated ${estimated} tokens > tpm ${effectiveTpm} — request proceeds and may hit a server 429` })
|
|
111
|
+
const wOver = rateWindows.get(rateKey(provider)) ?? { tokens: [], requests: [] }
|
|
112
|
+
rateWindows.set(rateKey(provider), wOver)
|
|
113
|
+
const nowOver = _rateHooks.now()
|
|
114
|
+
wOver.tokens.push({ ts: nowOver, n: estimated })
|
|
115
|
+
wOver.requests.push(nowOver)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
const w = rateWindows.get(rateKey(provider)) ?? { tokens: [], requests: [] }
|
|
119
|
+
rateWindows.set(rateKey(provider), w)
|
|
120
|
+
for (;;) {
|
|
121
|
+
const now = _rateHooks.now()
|
|
122
|
+
const cutoff = now - _rateHooks.windowMs
|
|
123
|
+
w.tokens = w.tokens.filter((e) => e.ts > cutoff)
|
|
124
|
+
w.requests = w.requests.filter((ts) => ts > cutoff)
|
|
125
|
+
const usedTokens = w.tokens.reduce((s, e) => s + e.n, 0)
|
|
126
|
+
const overTokens = usedTokens + estimated - effectiveTpm
|
|
127
|
+
const overRequests = w.requests.length + 1 - effectiveRpm
|
|
128
|
+
if (overTokens <= 0 && overRequests <= 0) break
|
|
129
|
+
let waitMs = _rateHooks.windowMs
|
|
130
|
+
if (overTokens > 0) {
|
|
131
|
+
let freed = 0
|
|
132
|
+
for (const e of w.tokens) {
|
|
133
|
+
freed += e.n
|
|
134
|
+
if (freed >= overTokens) {
|
|
135
|
+
waitMs = Math.min(waitMs, e.ts + _rateHooks.windowMs - now)
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (overRequests > 0) {
|
|
141
|
+
waitMs = Math.min(waitMs, w.requests[overRequests - 1] + _rateHooks.windowMs - now)
|
|
142
|
+
}
|
|
143
|
+
waitMs = Math.max(waitMs, 50)
|
|
144
|
+
onWait?.({ phase: "gate", seconds: Math.ceil(waitMs / 1000) })
|
|
145
|
+
// §2.5 #114 并入:等待改为可中断实现(abortableSleep)——abort 仍走 CLI 溯源标注。
|
|
146
|
+
await abortableSleep(waitMs, signal).catch((e) => {
|
|
147
|
+
if (e?.name === "AbortError") throw abortError(signal, "provider", "rate-gate")
|
|
148
|
+
throw e
|
|
149
|
+
})
|
|
150
|
+
if (signal?.aborted) throw abortError(signal, "provider", "rate-gate")
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Accounting: record measured usage after response returns */
|
|
155
|
+
export function recordRate(provider, estimated, usage) {
|
|
156
|
+
if (provider.tpm == null && provider.rpm == null) return
|
|
157
|
+
const key = rateKey(provider)
|
|
158
|
+
const w = rateWindows.get(key) ?? { tokens: [], requests: [] }
|
|
159
|
+
const now = _rateHooks.now()
|
|
160
|
+
const cutoff = now - _rateHooks.windowMs
|
|
161
|
+
w.tokens = w.tokens.filter((e) => e.ts > cutoff)
|
|
162
|
+
w.requests = w.requests.filter((ts) => ts > cutoff)
|
|
163
|
+
w.requests.push(now)
|
|
164
|
+
w.tokens.push({ ts: now, n: usage ? (usage.prompt_tokens ?? estimated) + (usage.completion_tokens ?? 0) : estimated })
|
|
165
|
+
// Delete entry when window is empty, preventing unbounded Map growth across long-running provider configs
|
|
166
|
+
if (w.tokens.length === 0 && w.requests.length === 0) rateWindows.delete(key)
|
|
167
|
+
else rateWindows.set(key, w)
|
|
168
|
+
}
|