thincoder 0.12.62 → 0.12.64
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 +27 -0
- package/README.md +46 -49
- package/bin/thincoder.mjs +68 -34
- package/package.json +7 -6
- package/src/acp/bridge.mjs +35 -15
- package/src/acp/client-caps.mjs +86 -0
- package/src/acp/ext.mjs +86 -0
- package/src/acp/handlers-session.mjs +257 -0
- package/src/acp/handlers-slots.mjs +196 -0
- package/src/acp/login.mjs +48 -0
- package/src/acp/session.mjs +6 -4
- package/src/acp.mjs +67 -379
- package/src/cli/distill-command.mjs +3 -3
- package/src/cli/make-agent.mjs +60 -17
- package/src/cli/memory-command.mjs +3 -3
- package/src/cli/permission.mjs +4 -48
- package/src/cli/setup-wizard.mjs +1 -1
- package/src/completions.mjs +3 -1
- package/src/crash-reports.mjs +1 -1
- package/src/distill.mjs +4 -4
- package/src/heap-watch.mjs +1 -1
- package/src/prompt-injections.mjs +20 -0
- package/src/tui/agent-turn.mjs +40 -9
- package/src/tui/cmd-advisor.mjs +5 -5
- package/src/tui/cmd-config.mjs +8 -8
- package/src/tui/cmd-eng.mjs +35 -9
- package/src/tui/cmd-mcp.mjs +9 -8
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +6 -5
- package/src/tui/cmd-plan.mjs +9 -0
- package/src/tui/cmd-reindex.mjs +1 -1
- package/src/tui/cmd-restore.mjs +2 -2
- package/src/tui/cmd-session.mjs +24 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +22 -9
- package/src/tui/config-helpers.mjs +1 -1
- package/src/tui/display-budget.mjs +33 -11
- package/src/tui/index.mjs +18 -10
- package/src/tui/interaction.mjs +16 -7
- package/src/tui/key-modes.mjs +9 -4
- package/src/tui/ledger-surface.mjs +22 -59
- package/src/tui/model-catalog.mjs +4 -4
- package/src/tui/model-picker.mjs +8 -7
- package/src/tui/mouse.mjs +11 -6
- package/src/tui/pickers.mjs +15 -2
- package/src/tui/render-conversation.mjs +1 -1
- package/src/tui/render-frame.mjs +15 -6
- package/src/tui/render-loop.mjs +1 -1
- package/src/tui/render-segments.mjs +3 -1
- package/src/tui/slash-commands.mjs +1 -1
- package/src/tui/startup.mjs +14 -14
- package/src/tui/subagent-blocks.mjs +20 -3
- package/src/tui/subagent-freeze.mjs +73 -2
- package/src/tui/suspension-drive.mjs +57 -23
- package/src/tui/tool-events.mjs +11 -8
- package/src/tui/tui-lifecycle.mjs +9 -0
- package/src/tui/wizard.mjs +3 -3
- package/src/tui/wrapped-spawn.mjs +6 -2
- package/src/abort-provenance.mjs +0 -116
- package/src/advisor/citations.mjs +0 -139
- package/src/advisor/compaction.mjs +0 -174
- package/src/advisor/convergence.mjs +0 -80
- package/src/advisor/history.mjs +0 -77
- package/src/advisor/loop.mjs +0 -293
- package/src/advisor/messages.mjs +0 -299
- package/src/advisor/project-context.mjs +0 -194
- package/src/advisor/repos.mjs +0 -150
- package/src/advisor/run.mjs +0 -293
- package/src/advisor/truncate.mjs +0 -57
- package/src/advisor.mjs +0 -290
- package/src/agent/completion.mjs +0 -146
- package/src/agent/dispatch.mjs +0 -489
- package/src/agent/helpers.mjs +0 -384
- package/src/agent/post-turn.mjs +0 -70
- package/src/agent/record-results.mjs +0 -174
- package/src/agent/relay-prefix.mjs +0 -39
- package/src/agent/run-stages.mjs +0 -244
- package/src/agent/setup-reminders.mjs +0 -69
- package/src/agent/setup.mjs +0 -354
- package/src/agent/spawn-child.mjs +0 -243
- package/src/agent-tools/advisor-async.mjs +0 -346
- package/src/agent-tools/advisor-settle.mjs +0 -231
- package/src/agent-tools/advisor.mjs +0 -260
- package/src/agent-tools/async-settle.mjs +0 -204
- package/src/agent-tools/batch-segment.mjs +0 -195
- package/src/agent-tools/consult.mjs +0 -473
- package/src/agent-tools/design-token.mjs +0 -117
- package/src/agent-tools/digest-budget.mjs +0 -76
- package/src/agent-tools/eng.mjs +0 -67
- package/src/agent-tools/escalate-async.mjs +0 -295
- package/src/agent-tools/goal.mjs +0 -119
- package/src/agent-tools/plan.mjs +0 -81
- package/src/agent-tools/read-history.mjs +0 -309
- package/src/agent-tools/recent-changes.mjs +0 -24
- package/src/agent-tools/review-streak.mjs +0 -93
- package/src/agent-tools/settings.mjs +0 -265
- package/src/agent-tools/skill.mjs +0 -47
- package/src/agent-tools/subagent-actions.mjs +0 -482
- package/src/agent-tools/subagent-async.mjs +0 -434
- package/src/agent-tools/subagent-panel.mjs +0 -160
- package/src/agent-tools/subagent-run.mjs +0 -205
- package/src/agent-tools/subagent-scheduler.mjs +0 -392
- package/src/agent-tools/subagent-spawn.mjs +0 -459
- package/src/agent-tools/subagent.mjs +0 -404
- package/src/agent-tools/task.mjs +0 -87
- package/src/agent-tools/timer.mjs +0 -46
- package/src/agent-tools/verify.mjs +0 -271
- package/src/agent-tools.mjs +0 -17
- package/src/agent.mjs +0 -417
- package/src/auto-think.mjs +0 -115
- package/src/config-migrate.mjs +0 -70
- package/src/config.mjs +0 -496
- package/src/context.mjs +0 -392
- package/src/conventions.mjs +0 -223
- package/src/embedding.mjs +0 -120
- package/src/escape.mjs +0 -152
- package/src/expand-home.mjs +0 -16
- package/src/explore-distill.mjs +0 -155
- package/src/generate-title.mjs +0 -88
- package/src/git/checkpoint.mjs +0 -448
- package/src/git/gitmem.mjs +0 -100
- package/src/hooks.mjs +0 -97
- package/src/ledger.mjs +0 -227
- package/src/log.mjs +0 -195
- package/src/markdown.mjs +0 -106
- package/src/mcp/helpers.mjs +0 -51
- package/src/mcp/transport-http.mjs +0 -248
- package/src/mcp/transport-stdio.mjs +0 -140
- package/src/mcp/transport-ws.mjs +0 -122
- package/src/mcp.mjs +0 -295
- package/src/memory/code-index.mjs +0 -219
- package/src/memory/code-sync.mjs +0 -415
- package/src/memory/core.mjs +0 -299
- package/src/memory/delete.mjs +0 -236
- package/src/memory/docs.mjs +0 -419
- package/src/memory/file-walk.mjs +0 -109
- package/src/memory/scan.mjs +0 -95
- package/src/memory/schema.mjs +0 -452
- package/src/memory.mjs +0 -21
- package/src/model-ref.mjs +0 -66
- package/src/model-specs.mjs +0 -179
- package/src/peer-domains.mjs +0 -265
- package/src/peer-instances.mjs +0 -231
- package/src/prompt-overlays.mjs +0 -82
- package/src/prompts/advisor-design.md +0 -41
- package/src/prompts/advisor-round1.md +0 -41
- package/src/prompts/advisor-round2.md +0 -46
- package/src/prompts/advisor-round3.md +0 -42
- package/src/prompts/common.md +0 -115
- package/src/prompts/consult-base.md +0 -19
- package/src/prompts/discipline-engineering.md +0 -258
- package/src/prompts/discipline-normal.md +0 -185
- package/src/prompts/persona-coder.md +0 -21
- package/src/prompts/persona-eng-coder.md +0 -37
- package/src/prompts/persona-eng-designer.md +0 -60
- package/src/prompts/persona-engineering.md +0 -55
- package/src/prompts/persona-explore.md +0 -15
- package/src/prompts/persona-normal.md +0 -27
- package/src/prompts/persona-plan.md +0 -26
- package/src/provider/anthropic.mjs +0 -225
- package/src/provider/core.mjs +0 -476
- package/src/provider/errors.mjs +0 -101
- package/src/provider/google.mjs +0 -257
- package/src/provider/index.mjs +0 -7
- package/src/provider/list-models.mjs +0 -93
- package/src/provider/normalize.mjs +0 -81
- package/src/provider/rate.mjs +0 -108
- package/src/provider/responses.mjs +0 -495
- package/src/provider/retry.mjs +0 -88
- package/src/provider/sse.mjs +0 -264
- package/src/proxy.mjs +0 -261
- package/src/rules.mjs +0 -53
- package/src/session-gc.mjs +0 -221
- package/src/session-guard.mjs +0 -59
- package/src/session-migrate.mjs +0 -48
- package/src/session-rename.mjs +0 -38
- package/src/session-segments.mjs +0 -100
- package/src/session-slots.mjs +0 -492
- package/src/session-store.mjs +0 -441
- package/src/session.mjs +0 -492
- package/src/skills.mjs +0 -153
- package/src/text-budget.mjs +0 -46
- package/src/token-ttl.mjs +0 -274
- package/src/tools/apply_patch.md +0 -15
- package/src/tools/bash.md +0 -37
- package/src/tools/bash.mjs +0 -268
- package/src/tools/checklist-sync.mjs +0 -181
- package/src/tools/checklist.md +0 -13
- package/src/tools/checklist.mjs +0 -299
- package/src/tools/delete.md +0 -13
- package/src/tools/edit-batch.mjs +0 -191
- package/src/tools/edit-diff.mjs +0 -348
- package/src/tools/edit.md +0 -30
- package/src/tools/execute.md +0 -21
- package/src/tools/execute.mjs +0 -228
- package/src/tools/fetch.md +0 -12
- package/src/tools/file.mjs +0 -469
- package/src/tools/file_ops.md +0 -17
- package/src/tools/get_current_time.md +0 -8
- package/src/tools/git-checkpoint.mjs +0 -143
- package/src/tools/git-ext.mjs +0 -173
- package/src/tools/git.md +0 -54
- package/src/tools/git.mjs +0 -356
- package/src/tools/glob-dialect.mjs +0 -130
- package/src/tools/glob.md +0 -11
- package/src/tools/grep.md +0 -19
- package/src/tools/hashline_edit.md +0 -14
- package/src/tools/index.mjs +0 -36
- package/src/tools/insert_after.md +0 -15
- package/src/tools/lint.md +0 -10
- package/src/tools/linter.mjs +0 -128
- package/src/tools/ls.md +0 -12
- package/src/tools/lsp.md +0 -10
- package/src/tools/lsp.mjs +0 -316
- package/src/tools/ops.mjs +0 -299
- package/src/tools/patch.mjs +0 -282
- package/src/tools/process.md +0 -10
- package/src/tools/question.md +0 -16
- package/src/tools/question.mjs +0 -26
- package/src/tools/read.md +0 -20
- package/src/tools/read_image.md +0 -8
- package/src/tools/repomap.mjs +0 -314
- package/src/tools/search.mjs +0 -236
- package/src/tools/shared.mjs +0 -446
- package/src/tools/tree.md +0 -14
- package/src/tools/tree.mjs +0 -66
- package/src/tools/wait_for.md +0 -22
- package/src/tools/web.mjs +0 -224
- package/src/tools/websearch.md +0 -16
- package/src/tools/write.md +0 -11
- package/src/traces/trace-store.mjs +0 -355
package/src/provider/google.mjs
DELETED
|
@@ -1,257 +0,0 @@
|
|
|
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
|
-
}
|
package/src/provider/index.mjs
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
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"
|
|
@@ -1,93 +0,0 @@
|
|
|
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 → null——解析侧各自防御)。 */
|
|
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
|
-
throw new Error(`GET /models failed ${response.status}: ${text}`)
|
|
35
|
-
}
|
|
36
|
-
return response.json().catch(() => null)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** 防御性收集:字段缺失/非字符串项跳过。 */
|
|
40
|
-
function collect(rows, pick) {
|
|
41
|
-
if (!Array.isArray(rows)) return []
|
|
42
|
-
const out = []
|
|
43
|
-
for (const row of rows) {
|
|
44
|
-
const v = pick(row)
|
|
45
|
-
if (typeof v === "string" && v) out.push(v)
|
|
46
|
-
}
|
|
47
|
-
return out
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function listOpenai(provider, signal) {
|
|
51
|
-
const url = `${provider.baseURL}/models`
|
|
52
|
-
const data = await fetchJson(provider, url, { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey ?? ""}` }, signal)
|
|
53
|
-
return collect(data?.data, (m) => m?.id)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function listAnthropic(provider, signal) {
|
|
57
|
-
const headers = { ...(provider.headers ?? {}), "x-api-key": provider.apiKey ?? "", "anthropic-version": ANTHROPIC_VERSION }
|
|
58
|
-
const base = `${provider.baseURL}/models?limit=1000`
|
|
59
|
-
const out = []
|
|
60
|
-
let afterId = null
|
|
61
|
-
for (let page = 0; page < MAX_PAGES; page++) {
|
|
62
|
-
const url = afterId ? `${base}&after_id=${encodeURIComponent(afterId)}` : base
|
|
63
|
-
const data = await fetchJson(provider, url, headers, signal)
|
|
64
|
-
out.push(...collect(data?.data, (m) => m?.id))
|
|
65
|
-
if (!data?.has_more) return out
|
|
66
|
-
afterId = typeof data?.last_id === "string" && data.last_id ? data.last_id : null
|
|
67
|
-
if (!afterId) return out // has_more 但无游标——无法继续,避免死循环
|
|
68
|
-
}
|
|
69
|
-
return out
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function listGoogle(provider, signal) {
|
|
73
|
-
const headers = { ...(provider.headers ?? {}) }
|
|
74
|
-
const base = `${provider.baseURL}/models?key=${encodeURIComponent(provider.apiKey ?? "")}&pageSize=1000`
|
|
75
|
-
const out = []
|
|
76
|
-
let pageToken = null
|
|
77
|
-
for (let page = 0; page < MAX_PAGES; page++) {
|
|
78
|
-
const url = pageToken ? `${base}&pageToken=${encodeURIComponent(pageToken)}` : base
|
|
79
|
-
const data = await fetchJson(provider, url, headers, signal)
|
|
80
|
-
out.push(...collect(data?.models, (m) => (typeof m?.name === "string" ? m.name.replace(/^models\//, "") : null)))
|
|
81
|
-
if (typeof data?.nextPageToken !== "string" || !data.nextPageToken) return out
|
|
82
|
-
pageToken = data.nextPageToken
|
|
83
|
-
}
|
|
84
|
-
return out
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** List available model IDs from the provider's /models endpoint (format 分派——M1)。 */
|
|
88
|
-
export async function listModels(provider, { signal } = {}) {
|
|
89
|
-
const format = provider?.format
|
|
90
|
-
if (format === "anthropic") return listAnthropic(provider, signal)
|
|
91
|
-
if (format === "google") return listGoogle(provider, signal)
|
|
92
|
-
return listOpenai(provider, signal)
|
|
93
|
-
}
|
|
@@ -1,81 +0,0 @@
|
|
|
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
|
-
|
package/src/provider/rate.mjs
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
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
|
-
|
|
7
|
-
export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
|
|
8
|
-
export const MAX_RETRIES = 3
|
|
9
|
-
export const MAX_CONTINUATIONS = 3
|
|
10
|
-
export const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Test hooks: sleep/clock/window length are replaceable (offline tests can't really wait 60s).
|
|
14
|
-
* Production code should never call setTimeout/sleep directly — always go through these.
|
|
15
|
-
*/
|
|
16
|
-
export const _rateHooks = {
|
|
17
|
-
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
18
|
-
now: () => Date.now(),
|
|
19
|
-
windowMs: 60_000,
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const rateWindows = new Map() // key → { tokens: [{ts, n}], requests: [ts] }
|
|
23
|
-
|
|
24
|
-
function rateKey(provider) {
|
|
25
|
-
// Normalize: /beta and /v1 are treated as the same account's rate-limit window (DeepSeek prefix continuation switches to /beta endpoint)
|
|
26
|
-
const base = provider.baseURL.replace(/\/beta$/, "/v1")
|
|
27
|
-
return `${base}|${provider.apiKey ?? ""}`
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Rough estimate of text token count.
|
|
31
|
-
* ASCII ~4 chars/token; non-ASCII (CJK/emoji) ~1 char/token (conservative; measured BPE is 1.5-2.5 chars/token). */
|
|
32
|
-
export function estimateText(s) {
|
|
33
|
-
let nonAscii = 0
|
|
34
|
-
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) nonAscii++
|
|
35
|
-
return Math.ceil((s.length - nonAscii) / 4) + nonAscii
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Estimated prompt tokens for this request */
|
|
39
|
-
export function estimateRequestTokens(body) {
|
|
40
|
-
let tokens = 0
|
|
41
|
-
for (const m of body.messages ?? []) {
|
|
42
|
-
if (typeof m.content === "string") tokens += estimateText(m.content)
|
|
43
|
-
if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
|
|
44
|
-
for (const tc of m.tool_calls ?? []) {
|
|
45
|
-
tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
if (body.tools) tokens += estimateText(JSON.stringify(body.tools))
|
|
49
|
-
return tokens
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Gate: sleep until window frees space when over budget */
|
|
53
|
-
export async function rateGate(provider, estimated, onWait, signal) {
|
|
54
|
-
// 2026-08-31 会诊 #16:单请求估算已超 tpm 时原实现静默放行(必然撞服务端 429)。
|
|
55
|
-
// 保持放行(tpm 置 null 防止 overTokens 恒正值死等),但明确告警让上层/用户知情。
|
|
56
|
-
if (provider.tpm != null && estimated > provider.tpm) {
|
|
57
|
-
onWait?.({ phase: "warn", message: `estimated ${estimated} tokens > tpm ${provider.tpm} — request proceeds and may hit a server 429` })
|
|
58
|
-
}
|
|
59
|
-
const tpm = provider.tpm != null && estimated <= provider.tpm ? provider.tpm : null
|
|
60
|
-
const rpm = provider.rpm ?? null
|
|
61
|
-
if (tpm == null && rpm == null) return
|
|
62
|
-
const w = rateWindows.get(rateKey(provider)) ?? { tokens: [], requests: [] }
|
|
63
|
-
rateWindows.set(rateKey(provider), w)
|
|
64
|
-
for (;;) {
|
|
65
|
-
const now = _rateHooks.now()
|
|
66
|
-
const cutoff = now - _rateHooks.windowMs
|
|
67
|
-
w.tokens = w.tokens.filter((e) => e.ts > cutoff)
|
|
68
|
-
w.requests = w.requests.filter((ts) => ts > cutoff)
|
|
69
|
-
const usedTokens = w.tokens.reduce((s, e) => s + e.n, 0)
|
|
70
|
-
const overTokens = tpm != null ? usedTokens + estimated - tpm : 0
|
|
71
|
-
const overRequests = rpm != null ? w.requests.length + 1 - rpm : 0
|
|
72
|
-
if (overTokens <= 0 && overRequests <= 0) break
|
|
73
|
-
let waitMs = _rateHooks.windowMs
|
|
74
|
-
if (overTokens > 0) {
|
|
75
|
-
let freed = 0
|
|
76
|
-
for (const e of w.tokens) {
|
|
77
|
-
freed += e.n
|
|
78
|
-
if (freed >= overTokens) {
|
|
79
|
-
waitMs = Math.min(waitMs, e.ts + _rateHooks.windowMs - now)
|
|
80
|
-
break
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
if (overRequests > 0) {
|
|
85
|
-
waitMs = Math.min(waitMs, w.requests[overRequests - 1] + _rateHooks.windowMs - now)
|
|
86
|
-
}
|
|
87
|
-
waitMs = Math.max(waitMs, 50)
|
|
88
|
-
onWait?.({ phase: "gate", seconds: Math.ceil(waitMs / 1000) })
|
|
89
|
-
await _rateHooks.sleep(waitMs)
|
|
90
|
-
if (signal?.aborted) throw abortError(signal, "provider", "rate-gate")
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Accounting: record measured usage after response returns */
|
|
95
|
-
export function recordRate(provider, estimated, usage) {
|
|
96
|
-
if (provider.tpm == null && provider.rpm == null) return
|
|
97
|
-
const key = rateKey(provider)
|
|
98
|
-
const w = rateWindows.get(key) ?? { tokens: [], requests: [] }
|
|
99
|
-
const now = _rateHooks.now()
|
|
100
|
-
const cutoff = now - _rateHooks.windowMs
|
|
101
|
-
w.tokens = w.tokens.filter((e) => e.ts > cutoff)
|
|
102
|
-
w.requests = w.requests.filter((ts) => ts > cutoff)
|
|
103
|
-
w.requests.push(now)
|
|
104
|
-
w.tokens.push({ ts: now, n: usage ? (usage.prompt_tokens ?? estimated) + (usage.completion_tokens ?? 0) : estimated })
|
|
105
|
-
// Delete entry when window is empty, preventing unbounded Map growth across long-running provider configs
|
|
106
|
-
if (w.tokens.length === 0 && w.requests.length === 0) rateWindows.delete(key)
|
|
107
|
-
else rateWindows.set(key, w)
|
|
108
|
-
}
|