thincoder 0.12.61 → 0.12.63

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.
Files changed (230) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/README.md +13 -12
  3. package/bin/thincoder.mjs +63 -28
  4. package/package.json +6 -5
  5. package/src/acp/bridge.mjs +35 -15
  6. package/src/acp/client-caps.mjs +86 -0
  7. package/src/acp/ext.mjs +86 -0
  8. package/src/acp/handlers-session.mjs +240 -0
  9. package/src/acp/handlers-slots.mjs +196 -0
  10. package/src/acp/login.mjs +48 -0
  11. package/src/acp/session.mjs +6 -4
  12. package/src/acp.mjs +67 -371
  13. package/src/cli/distill-command.mjs +3 -3
  14. package/src/cli/make-agent.mjs +59 -17
  15. package/src/cli/memory-command.mjs +3 -3
  16. package/src/cli/permission.mjs +4 -48
  17. package/src/cli/setup-wizard.mjs +1 -1
  18. package/src/completions.mjs +3 -1
  19. package/src/crash-reports.mjs +32 -10
  20. package/src/distill.mjs +4 -4
  21. package/src/heap-watch.mjs +88 -0
  22. package/src/prompt-injections.mjs +20 -0
  23. package/src/tui/agent-turn.mjs +40 -9
  24. package/src/tui/cmd-advisor.mjs +5 -5
  25. package/src/tui/cmd-clear.mjs +2 -0
  26. package/src/tui/cmd-config.mjs +8 -8
  27. package/src/tui/cmd-eng.mjs +25 -9
  28. package/src/tui/cmd-mcp.mjs +9 -8
  29. package/src/tui/cmd-model.mjs +1 -1
  30. package/src/tui/cmd-new.mjs +10 -5
  31. package/src/tui/cmd-reindex.mjs +1 -1
  32. package/src/tui/cmd-restore.mjs +2 -2
  33. package/src/tui/cmd-session.mjs +31 -4
  34. package/src/tui/cmd-skills.mjs +1 -1
  35. package/src/tui/cmd-think.mjs +22 -9
  36. package/src/tui/config-helpers.mjs +1 -1
  37. package/src/tui/display-budget.mjs +206 -0
  38. package/src/tui/index.mjs +40 -12
  39. package/src/tui/interaction.mjs +16 -7
  40. package/src/tui/key-handler-search.mjs +9 -1
  41. package/src/tui/key-modes.mjs +9 -4
  42. package/src/tui/ledger-surface.mjs +85 -0
  43. package/src/tui/model-catalog.mjs +4 -4
  44. package/src/tui/model-picker.mjs +8 -7
  45. package/src/tui/mouse.mjs +11 -6
  46. package/src/tui/pickers.mjs +15 -2
  47. package/src/tui/render-conversation.mjs +1 -1
  48. package/src/tui/render-frame.mjs +17 -6
  49. package/src/tui/render-loop.mjs +1 -1
  50. package/src/tui/render-segments.mjs +3 -1
  51. package/src/tui/slash-commands.mjs +1 -1
  52. package/src/tui/startup.mjs +49 -17
  53. package/src/tui/subagent-blocks.mjs +21 -3
  54. package/src/tui/subagent-children.mjs +86 -14
  55. package/src/tui/subagent-freeze.mjs +80 -3
  56. package/src/tui/suspension-drive.mjs +48 -22
  57. package/src/tui/tool-args.mjs +5 -2
  58. package/src/tui/tool-display.mjs +16 -2
  59. package/src/tui/tool-events.mjs +64 -22
  60. package/src/tui/tui-lifecycle.mjs +9 -2
  61. package/src/tui/wizard.mjs +3 -3
  62. package/src/tui/wrapped-spawn.mjs +21 -5
  63. package/src/abort-provenance.mjs +0 -116
  64. package/src/advisor/citations.mjs +0 -139
  65. package/src/advisor/compaction.mjs +0 -174
  66. package/src/advisor/convergence.mjs +0 -80
  67. package/src/advisor/history.mjs +0 -77
  68. package/src/advisor/loop.mjs +0 -293
  69. package/src/advisor/messages.mjs +0 -299
  70. package/src/advisor/project-context.mjs +0 -194
  71. package/src/advisor/repos.mjs +0 -150
  72. package/src/advisor/run.mjs +0 -293
  73. package/src/advisor/truncate.mjs +0 -57
  74. package/src/advisor.mjs +0 -290
  75. package/src/agent/completion.mjs +0 -146
  76. package/src/agent/dispatch.mjs +0 -489
  77. package/src/agent/helpers.mjs +0 -384
  78. package/src/agent/post-turn.mjs +0 -70
  79. package/src/agent/record-results.mjs +0 -174
  80. package/src/agent/relay-prefix.mjs +0 -39
  81. package/src/agent/run-stages.mjs +0 -242
  82. package/src/agent/setup-reminders.mjs +0 -69
  83. package/src/agent/setup.mjs +0 -354
  84. package/src/agent/spawn-child.mjs +0 -228
  85. package/src/agent-tools/advisor-async.mjs +0 -346
  86. package/src/agent-tools/advisor-settle.mjs +0 -231
  87. package/src/agent-tools/advisor.mjs +0 -260
  88. package/src/agent-tools/async-settle.mjs +0 -191
  89. package/src/agent-tools/batch-segment.mjs +0 -195
  90. package/src/agent-tools/consult.mjs +0 -468
  91. package/src/agent-tools/design-token.mjs +0 -117
  92. package/src/agent-tools/digest-budget.mjs +0 -76
  93. package/src/agent-tools/eng.mjs +0 -67
  94. package/src/agent-tools/escalate-async.mjs +0 -289
  95. package/src/agent-tools/goal.mjs +0 -119
  96. package/src/agent-tools/plan.mjs +0 -81
  97. package/src/agent-tools/read-history.mjs +0 -294
  98. package/src/agent-tools/recent-changes.mjs +0 -24
  99. package/src/agent-tools/review-streak.mjs +0 -93
  100. package/src/agent-tools/settings.mjs +0 -265
  101. package/src/agent-tools/skill.mjs +0 -47
  102. package/src/agent-tools/subagent-actions.mjs +0 -479
  103. package/src/agent-tools/subagent-async.mjs +0 -434
  104. package/src/agent-tools/subagent-panel.mjs +0 -160
  105. package/src/agent-tools/subagent-run.mjs +0 -205
  106. package/src/agent-tools/subagent-scheduler.mjs +0 -392
  107. package/src/agent-tools/subagent-spawn.mjs +0 -453
  108. package/src/agent-tools/subagent.mjs +0 -404
  109. package/src/agent-tools/task.mjs +0 -87
  110. package/src/agent-tools/timer.mjs +0 -46
  111. package/src/agent-tools/verify.mjs +0 -271
  112. package/src/agent-tools.mjs +0 -17
  113. package/src/agent.mjs +0 -413
  114. package/src/auto-think.mjs +0 -115
  115. package/src/config-migrate.mjs +0 -70
  116. package/src/config.mjs +0 -496
  117. package/src/context.mjs +0 -381
  118. package/src/conventions.mjs +0 -223
  119. package/src/embedding.mjs +0 -120
  120. package/src/escape.mjs +0 -152
  121. package/src/expand-home.mjs +0 -16
  122. package/src/explore-distill.mjs +0 -155
  123. package/src/generate-title.mjs +0 -83
  124. package/src/git/checkpoint.mjs +0 -448
  125. package/src/git/gitmem.mjs +0 -100
  126. package/src/hooks.mjs +0 -97
  127. package/src/log.mjs +0 -195
  128. package/src/markdown.mjs +0 -106
  129. package/src/mcp/helpers.mjs +0 -51
  130. package/src/mcp/transport-http.mjs +0 -248
  131. package/src/mcp/transport-stdio.mjs +0 -140
  132. package/src/mcp/transport-ws.mjs +0 -122
  133. package/src/mcp.mjs +0 -295
  134. package/src/memory/code-index.mjs +0 -219
  135. package/src/memory/code-sync.mjs +0 -413
  136. package/src/memory/core.mjs +0 -300
  137. package/src/memory/delete.mjs +0 -236
  138. package/src/memory/docs.mjs +0 -417
  139. package/src/memory/file-walk.mjs +0 -109
  140. package/src/memory/schema.mjs +0 -452
  141. package/src/memory.mjs +0 -21
  142. package/src/model-ref.mjs +0 -66
  143. package/src/model-specs.mjs +0 -179
  144. package/src/peer-domains.mjs +0 -265
  145. package/src/peer-instances.mjs +0 -231
  146. package/src/prompt-overlays.mjs +0 -82
  147. package/src/prompts/advisor-design.md +0 -41
  148. package/src/prompts/advisor-round1.md +0 -41
  149. package/src/prompts/advisor-round2.md +0 -46
  150. package/src/prompts/advisor-round3.md +0 -42
  151. package/src/prompts/common.md +0 -115
  152. package/src/prompts/consult-base.md +0 -19
  153. package/src/prompts/discipline-engineering.md +0 -217
  154. package/src/prompts/discipline-normal.md +0 -179
  155. package/src/prompts/persona-coder.md +0 -21
  156. package/src/prompts/persona-eng-coder.md +0 -37
  157. package/src/prompts/persona-eng-designer.md +0 -55
  158. package/src/prompts/persona-engineering.md +0 -54
  159. package/src/prompts/persona-explore.md +0 -15
  160. package/src/prompts/persona-normal.md +0 -27
  161. package/src/prompts/persona-plan.md +0 -26
  162. package/src/provider/anthropic.mjs +0 -225
  163. package/src/provider/core.mjs +0 -476
  164. package/src/provider/errors.mjs +0 -101
  165. package/src/provider/google.mjs +0 -257
  166. package/src/provider/index.mjs +0 -7
  167. package/src/provider/list-models.mjs +0 -93
  168. package/src/provider/normalize.mjs +0 -81
  169. package/src/provider/rate.mjs +0 -108
  170. package/src/provider/responses.mjs +0 -495
  171. package/src/provider/retry.mjs +0 -88
  172. package/src/provider/sse.mjs +0 -264
  173. package/src/proxy.mjs +0 -261
  174. package/src/rules.mjs +0 -53
  175. package/src/session-gc.mjs +0 -214
  176. package/src/session-guard.mjs +0 -47
  177. package/src/session-migrate.mjs +0 -48
  178. package/src/session-rename.mjs +0 -38
  179. package/src/session-slots.mjs +0 -489
  180. package/src/session.mjs +0 -475
  181. package/src/skills.mjs +0 -153
  182. package/src/token-ttl.mjs +0 -274
  183. package/src/tools/apply_patch.md +0 -15
  184. package/src/tools/bash.md +0 -37
  185. package/src/tools/bash.mjs +0 -268
  186. package/src/tools/checklist-sync.mjs +0 -181
  187. package/src/tools/checklist.md +0 -13
  188. package/src/tools/checklist.mjs +0 -299
  189. package/src/tools/delete.md +0 -13
  190. package/src/tools/edit-batch.mjs +0 -191
  191. package/src/tools/edit-diff.mjs +0 -348
  192. package/src/tools/edit.md +0 -30
  193. package/src/tools/execute.md +0 -21
  194. package/src/tools/execute.mjs +0 -228
  195. package/src/tools/fetch.md +0 -12
  196. package/src/tools/file.mjs +0 -469
  197. package/src/tools/file_ops.md +0 -17
  198. package/src/tools/get_current_time.md +0 -8
  199. package/src/tools/git-checkpoint.mjs +0 -143
  200. package/src/tools/git-ext.mjs +0 -173
  201. package/src/tools/git.md +0 -54
  202. package/src/tools/git.mjs +0 -356
  203. package/src/tools/glob-dialect.mjs +0 -130
  204. package/src/tools/glob.md +0 -11
  205. package/src/tools/grep.md +0 -19
  206. package/src/tools/hashline_edit.md +0 -14
  207. package/src/tools/index.mjs +0 -36
  208. package/src/tools/insert_after.md +0 -15
  209. package/src/tools/lint.md +0 -10
  210. package/src/tools/linter.mjs +0 -128
  211. package/src/tools/ls.md +0 -12
  212. package/src/tools/lsp.md +0 -10
  213. package/src/tools/lsp.mjs +0 -316
  214. package/src/tools/ops.mjs +0 -299
  215. package/src/tools/patch.mjs +0 -282
  216. package/src/tools/process.md +0 -10
  217. package/src/tools/question.md +0 -16
  218. package/src/tools/question.mjs +0 -26
  219. package/src/tools/read.md +0 -20
  220. package/src/tools/read_image.md +0 -8
  221. package/src/tools/repomap.mjs +0 -314
  222. package/src/tools/search.mjs +0 -236
  223. package/src/tools/shared.mjs +0 -446
  224. package/src/tools/tree.md +0 -14
  225. package/src/tools/tree.mjs +0 -66
  226. package/src/tools/wait_for.md +0 -22
  227. package/src/tools/web.mjs +0 -224
  228. package/src/tools/websearch.md +0 -16
  229. package/src/tools/write.md +0 -11
  230. package/src/traces/trace-store.mjs +0 -224
@@ -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
- }
@@ -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
-
@@ -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
- }