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