rovecode 0.3.2

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 (334) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +737 -0
  3. package/THIRD_PARTY_NOTICES.md +268 -0
  4. package/bin/rovecode.ts +21 -0
  5. package/package.json +56 -0
  6. package/src/acp/server.ts +374 -0
  7. package/src/cli/auth-login.ts +122 -0
  8. package/src/cli/connect.ts +244 -0
  9. package/src/cli/context-cmd.ts +199 -0
  10. package/src/cli/dispatch.ts +82 -0
  11. package/src/cli/doctor.ts +362 -0
  12. package/src/cli/export.ts +276 -0
  13. package/src/cli/help.ts +293 -0
  14. package/src/cli/is-tui-invocation.ts +8 -0
  15. package/src/cli/main.ts +583 -0
  16. package/src/cli/market-cmd.ts +658 -0
  17. package/src/cli/mcp-login.ts +141 -0
  18. package/src/cli/mcp-market-cmd.ts +302 -0
  19. package/src/cli/output.ts +382 -0
  20. package/src/cli/repl.ts +250 -0
  21. package/src/cli/repomap-root.ts +14 -0
  22. package/src/cli/resume.ts +57 -0
  23. package/src/cli/run-flags.ts +43 -0
  24. package/src/cli/run-limits.ts +78 -0
  25. package/src/cli/runtime.ts +931 -0
  26. package/src/cli/session-arg.ts +30 -0
  27. package/src/cli/sessions-cmd.ts +145 -0
  28. package/src/cli/setup.ts +153 -0
  29. package/src/cli/skills-cmd.ts +194 -0
  30. package/src/cli/start-chat.ts +65 -0
  31. package/src/cli/trust-cmd.ts +52 -0
  32. package/src/coding/bash.ts +148 -0
  33. package/src/coding/checkpoints.ts +327 -0
  34. package/src/coding/diff.ts +138 -0
  35. package/src/coding/files.ts +341 -0
  36. package/src/coding/hashline.ts +274 -0
  37. package/src/coding/lsp-gate.ts +254 -0
  38. package/src/coding/lsp-servers.ts +147 -0
  39. package/src/coding/lsp.ts +283 -0
  40. package/src/coding/repomap-cache.ts +99 -0
  41. package/src/coding/repomap-files.ts +192 -0
  42. package/src/coding/repomap.ts +481 -0
  43. package/src/core/agents.ts +255 -0
  44. package/src/core/compaction.ts +259 -0
  45. package/src/core/config.ts +289 -0
  46. package/src/core/context-report.ts +228 -0
  47. package/src/core/context.ts +60 -0
  48. package/src/core/count-remote.ts +107 -0
  49. package/src/core/execpolicy-rules.ts +196 -0
  50. package/src/core/execpolicy.ts +385 -0
  51. package/src/core/executor.ts +454 -0
  52. package/src/core/guardrails.ts +400 -0
  53. package/src/core/hooks.ts +411 -0
  54. package/src/core/images.ts +230 -0
  55. package/src/core/intro.ts +266 -0
  56. package/src/core/loop.ts +567 -0
  57. package/src/core/modes.ts +372 -0
  58. package/src/core/orchestrator.ts +245 -0
  59. package/src/core/proc-group.ts +48 -0
  60. package/src/core/project-trust.ts +98 -0
  61. package/src/core/reflection.ts +165 -0
  62. package/src/core/sandbox-config.ts +186 -0
  63. package/src/core/session-id.ts +24 -0
  64. package/src/core/session-images.ts +73 -0
  65. package/src/core/session-ops.ts +183 -0
  66. package/src/core/session-text.ts +29 -0
  67. package/src/core/session.ts +469 -0
  68. package/src/core/settings.ts +170 -0
  69. package/src/core/tasks.ts +646 -0
  70. package/src/core/token-scale.ts +108 -0
  71. package/src/core/tools.ts +309 -0
  72. package/src/core/trust.ts +104 -0
  73. package/src/core/types.ts +330 -0
  74. package/src/core/update-check.ts +171 -0
  75. package/src/core/usage.ts +204 -0
  76. package/src/core/validate.ts +121 -0
  77. package/src/core/verify-gate.ts +159 -0
  78. package/src/core/verify.ts +236 -0
  79. package/src/core/voice.ts +158 -0
  80. package/src/core/win-job.ts +183 -0
  81. package/src/core/workspace.ts +184 -0
  82. package/src/design/audit.ts +797 -0
  83. package/src/design/direction.ts +190 -0
  84. package/src/design/rules.ts +157 -0
  85. package/src/eval/bench.ts +150 -0
  86. package/src/eval/gauntlet-runner.ts +215 -0
  87. package/src/eval/gauntlet-support.ts +84 -0
  88. package/src/eval/gauntlet-wave3.ts +269 -0
  89. package/src/eval/gauntlet-wave4.ts +217 -0
  90. package/src/eval/gauntlet.ts +253 -0
  91. package/src/index.ts +17 -0
  92. package/src/lanes/agy.ts +95 -0
  93. package/src/lanes/approval.ts +24 -0
  94. package/src/lanes/claude.ts +129 -0
  95. package/src/lanes/codex.ts +127 -0
  96. package/src/lanes/events.ts +130 -0
  97. package/src/lanes/job.ts +142 -0
  98. package/src/lanes/opencode.ts +122 -0
  99. package/src/lanes/process.ts +184 -0
  100. package/src/lanes/progress.ts +183 -0
  101. package/src/lanes/registry.ts +178 -0
  102. package/src/lanes/runner.ts +124 -0
  103. package/src/lanes/types.ts +112 -0
  104. package/src/market/catalogs/mcp-docs.json +111 -0
  105. package/src/market/catalogs/plugins.json +111 -0
  106. package/src/market/catalogs/skills.json +478 -0
  107. package/src/market/clone.ts +72 -0
  108. package/src/market/context-cost.ts +121 -0
  109. package/src/market/digest.ts +106 -0
  110. package/src/market/index.ts +22 -0
  111. package/src/market/install.ts +578 -0
  112. package/src/market/manifest.ts +187 -0
  113. package/src/market/prereq.ts +145 -0
  114. package/src/market/registry.ts +363 -0
  115. package/src/market/resolve.ts +111 -0
  116. package/src/market/types.ts +236 -0
  117. package/src/market/validate.ts +227 -0
  118. package/src/mcp/client.ts +449 -0
  119. package/src/mcp/config.ts +252 -0
  120. package/src/mcp/local-package.ts +211 -0
  121. package/src/mcp/market-catalog.ts +84 -0
  122. package/src/mcp/market-install.ts +289 -0
  123. package/src/mcp/market.ts +362 -0
  124. package/src/mcp/oauth.ts +251 -0
  125. package/src/mcp/prompts-resources.ts +249 -0
  126. package/src/mcp/shared.ts +149 -0
  127. package/src/mcp/status.ts +67 -0
  128. package/src/mcp/tools.ts +275 -0
  129. package/src/mcp/transport.ts +122 -0
  130. package/src/mcp/trust.ts +25 -0
  131. package/src/memory/blocks.ts +278 -0
  132. package/src/memory/recall.ts +355 -0
  133. package/src/memory/scope.ts +182 -0
  134. package/src/memory/store.ts +105 -0
  135. package/src/memory/tools.ts +99 -0
  136. package/src/plugins/cli.ts +119 -0
  137. package/src/plugins/discover.ts +108 -0
  138. package/src/plugins/index.ts +50 -0
  139. package/src/plugins/install.ts +184 -0
  140. package/src/plugins/load.ts +124 -0
  141. package/src/plugins/manifest.ts +92 -0
  142. package/src/plugins/state.ts +83 -0
  143. package/src/providers/auth.ts +408 -0
  144. package/src/providers/cache.ts +223 -0
  145. package/src/providers/catalog-local.ts +160 -0
  146. package/src/providers/catalog.ts +421 -0
  147. package/src/providers/middleware-context.ts +86 -0
  148. package/src/providers/middleware.ts +373 -0
  149. package/src/providers/model-list.ts +23 -0
  150. package/src/providers/models-index.json +1 -0
  151. package/src/providers/oauth/common.ts +105 -0
  152. package/src/providers/oauth/device-code.ts +107 -0
  153. package/src/providers/oauth/github-copilot.ts +146 -0
  154. package/src/providers/oauth/loopback.ts +158 -0
  155. package/src/providers/oauth/openai.ts +163 -0
  156. package/src/providers/oauth/openrouter.ts +89 -0
  157. package/src/providers/oauth/pkce.ts +45 -0
  158. package/src/providers/oauth/registry.ts +39 -0
  159. package/src/providers/oauth/seam.ts +89 -0
  160. package/src/providers/profile-glm53.ts +111 -0
  161. package/src/providers/profile-sonnet5-persona.ts +65 -0
  162. package/src/providers/profile-sonnet5-voice.ts +23 -0
  163. package/src/providers/profiles.ts +156 -0
  164. package/src/providers/provider-config.ts +311 -0
  165. package/src/providers/registry.ts +333 -0
  166. package/src/providers/responses.ts +209 -0
  167. package/src/providers/retry.ts +234 -0
  168. package/src/providers/router.ts +294 -0
  169. package/src/providers/sse.ts +26 -0
  170. package/src/providers/stream-errors.ts +117 -0
  171. package/src/providers/stream.ts +566 -0
  172. package/src/providers/thinking.ts +189 -0
  173. package/src/providers/wire-messages.ts +129 -0
  174. package/src/providers/wire-responses.ts +79 -0
  175. package/src/providers/wire-select.ts +53 -0
  176. package/src/server/http.ts +291 -0
  177. package/src/server/openapi.ts +246 -0
  178. package/src/sextant/card-hits.ts +102 -0
  179. package/src/sextant/card-keys.ts +55 -0
  180. package/src/sextant/context-source.ts +157 -0
  181. package/src/sextant/crew-cards.ts +350 -0
  182. package/src/sextant/draw-agents.ts +273 -0
  183. package/src/sextant/draw-code.ts +388 -0
  184. package/src/sextant/draw-context.ts +222 -0
  185. package/src/sextant/draw-frame.ts +164 -0
  186. package/src/sextant/draw-market.ts +573 -0
  187. package/src/sextant/draw-messages.ts +386 -0
  188. package/src/sextant/draw-pet.ts +230 -0
  189. package/src/sextant/draw-plan.ts +187 -0
  190. package/src/sextant/draw-tabs.ts +85 -0
  191. package/src/sextant/draw-util.ts +65 -0
  192. package/src/sextant/draw-wizard.ts +378 -0
  193. package/src/sextant/engine.ts +230 -0
  194. package/src/sextant/frame-hits.ts +25 -0
  195. package/src/sextant/frame.ts +101 -0
  196. package/src/sextant/git-status.ts +197 -0
  197. package/src/sextant/grid.ts +59 -0
  198. package/src/sextant/input.ts +119 -0
  199. package/src/sextant/keys.ts +521 -0
  200. package/src/sextant/layout.ts +86 -0
  201. package/src/sextant/local-commands.ts +169 -0
  202. package/src/sextant/market-source.ts +287 -0
  203. package/src/sextant/mentions.ts +200 -0
  204. package/src/sextant/message-hits.ts +26 -0
  205. package/src/sextant/model.ts +387 -0
  206. package/src/sextant/overlays.ts +456 -0
  207. package/src/sextant/panel-hits.ts +38 -0
  208. package/src/sextant/pet.ts +399 -0
  209. package/src/sextant/screen.ts +324 -0
  210. package/src/sextant/scroll-hits.ts +66 -0
  211. package/src/sextant/scrollbar.ts +82 -0
  212. package/src/sextant/sextant-bridge.ts +174 -0
  213. package/src/sextant/sextant-cards.ts +142 -0
  214. package/src/sextant/sextant-diff-base.ts +63 -0
  215. package/src/sextant/sextant-files.ts +154 -0
  216. package/src/sextant/sextant-frame-loop.ts +335 -0
  217. package/src/sextant/sextant-renderer.ts +574 -0
  218. package/src/sextant/sextant-repo.ts +140 -0
  219. package/src/sextant/theme.ts +66 -0
  220. package/src/sextant/tool-rows.ts +189 -0
  221. package/src/sextant/types.ts +493 -0
  222. package/src/skills/index.ts +387 -0
  223. package/src/skills/pack.ts +220 -0
  224. package/src/skills/spec.ts +162 -0
  225. package/src/skills/tools.ts +69 -0
  226. package/src/skills/versioned.ts +227 -0
  227. package/src/telemetry/otel-export.ts +122 -0
  228. package/src/telemetry/otel-lanes.ts +89 -0
  229. package/src/telemetry/otel-logs.ts +131 -0
  230. package/src/telemetry/otel-metrics.ts +136 -0
  231. package/src/telemetry/otel.ts +397 -0
  232. package/src/telemetry/otlp.ts +76 -0
  233. package/src/tools/ask-user.ts +156 -0
  234. package/src/tools/bash-bg.ts +94 -0
  235. package/src/tools/bash-jobs.ts +237 -0
  236. package/src/tools/design.ts +151 -0
  237. package/src/tools/evalcell.ts +338 -0
  238. package/src/tools/html-text.ts +139 -0
  239. package/src/tools/provider.ts +149 -0
  240. package/src/tools/task.ts +250 -0
  241. package/src/tools/todo.ts +320 -0
  242. package/src/tools/webfetch.ts +332 -0
  243. package/src/tools/websearch.ts +359 -0
  244. package/src/tui/agents-cmd.ts +41 -0
  245. package/src/tui/app.ts +749 -0
  246. package/src/tui/attach.ts +127 -0
  247. package/src/tui/boot-notes.ts +41 -0
  248. package/src/tui/builtin-prompts.ts +59 -0
  249. package/src/tui/checkpoints-cmd.ts +70 -0
  250. package/src/tui/clipboard-image.ts +81 -0
  251. package/src/tui/clipboard.ts +78 -0
  252. package/src/tui/commands.ts +283 -0
  253. package/src/tui/config-view.ts +53 -0
  254. package/src/tui/context-cmds.ts +282 -0
  255. package/src/tui/cost.ts +108 -0
  256. package/src/tui/crash-guard.ts +173 -0
  257. package/src/tui/focus-terminal.ts +34 -0
  258. package/src/tui/git-cmds.ts +273 -0
  259. package/src/tui/git-plain.ts +58 -0
  260. package/src/tui/info-cmd.ts +150 -0
  261. package/src/tui/input-plain.ts +76 -0
  262. package/src/tui/mcp-cmd.ts +128 -0
  263. package/src/tui/memory-note.ts +77 -0
  264. package/src/tui/modes-cmd.ts +45 -0
  265. package/src/tui/notify-seq.ts +100 -0
  266. package/src/tui/notify.ts +318 -0
  267. package/src/tui/overlays.ts +97 -0
  268. package/src/tui/pi-renderer.ts +428 -0
  269. package/src/tui/providers-cmd.ts +377 -0
  270. package/src/tui/reasoning-view.ts +56 -0
  271. package/src/tui/renderer.ts +128 -0
  272. package/src/tui/replay-marker.ts +29 -0
  273. package/src/tui/session-cmd.ts +148 -0
  274. package/src/tui/session-manage.ts +95 -0
  275. package/src/tui/sextant-attach.ts +102 -0
  276. package/src/tui/sextant-io.ts +202 -0
  277. package/src/tui/sextant-smoke.ts +110 -0
  278. package/src/tui/shell-cmd.ts +158 -0
  279. package/src/tui/smoke.ts +72 -0
  280. package/src/tui/staged-terminal.ts +50 -0
  281. package/src/tui/startup.ts +12 -0
  282. package/src/tui/theme.ts +59 -0
  283. package/src/tui/todo-label.ts +7 -0
  284. package/src/tui/trust-card.ts +107 -0
  285. package/src/tui/tui-commands.ts +87 -0
  286. package/tsconfig.json +30 -0
  287. package/vendor/pi-tui/LICENSE +21 -0
  288. package/vendor/pi-tui/PATCHES.md +12 -0
  289. package/vendor/pi-tui/PROVENANCE.md +12 -0
  290. package/vendor/pi-tui/README.upstream.md +854 -0
  291. package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  292. package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  293. package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
  294. package/vendor/pi-tui/src/autocomplete.ts +827 -0
  295. package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
  296. package/vendor/pi-tui/src/components/box.ts +138 -0
  297. package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
  298. package/vendor/pi-tui/src/components/editor.ts +2364 -0
  299. package/vendor/pi-tui/src/components/h-stack.ts +45 -0
  300. package/vendor/pi-tui/src/components/image.ts +128 -0
  301. package/vendor/pi-tui/src/components/input.ts +448 -0
  302. package/vendor/pi-tui/src/components/loader.ts +93 -0
  303. package/vendor/pi-tui/src/components/markdown.ts +1016 -0
  304. package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
  305. package/vendor/pi-tui/src/components/select-list.ts +230 -0
  306. package/vendor/pi-tui/src/components/settings-list.ts +277 -0
  307. package/vendor/pi-tui/src/components/spacer.ts +29 -0
  308. package/vendor/pi-tui/src/components/stack.ts +155 -0
  309. package/vendor/pi-tui/src/components/text.ts +108 -0
  310. package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
  311. package/vendor/pi-tui/src/components/v-stack.ts +34 -0
  312. package/vendor/pi-tui/src/editor-component.ts +75 -0
  313. package/vendor/pi-tui/src/fuzzy.ts +138 -0
  314. package/vendor/pi-tui/src/index.ts +149 -0
  315. package/vendor/pi-tui/src/keybindings.ts +321 -0
  316. package/vendor/pi-tui/src/keys.ts +1402 -0
  317. package/vendor/pi-tui/src/kill-ring.ts +47 -0
  318. package/vendor/pi-tui/src/latex.ts +1381 -0
  319. package/vendor/pi-tui/src/layout-node.ts +52 -0
  320. package/vendor/pi-tui/src/layout.ts +411 -0
  321. package/vendor/pi-tui/src/native-modifiers.ts +60 -0
  322. package/vendor/pi-tui/src/native-module-path.ts +32 -0
  323. package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
  324. package/vendor/pi-tui/src/terminal-colors.ts +74 -0
  325. package/vendor/pi-tui/src/terminal-image.ts +701 -0
  326. package/vendor/pi-tui/src/terminal.ts +554 -0
  327. package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
  328. package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
  329. package/vendor/pi-tui/src/tui.ts +1264 -0
  330. package/vendor/pi-tui/src/undo-stack.ts +29 -0
  331. package/vendor/pi-tui/src/utils.ts +1327 -0
  332. package/vendor/pi-tui/src/word-navigation.ts +118 -0
  333. package/vendor/pi-tui/test/test-themes.ts +39 -0
  334. package/vendor/pi-tui/test/virtual-terminal.ts +219 -0
@@ -0,0 +1,566 @@
1
+ /** Providers: StreamFn adapters (ADR-003 seam). Errors cross as stopReason "error", never throws.
2
+ *
3
+ * Wire protocols supported:
4
+ * - OpenAI-compatible /chat/completions (JSON + SSE streaming), with tools
5
+ * - Anthropic /messages (x-api-key), with tools
6
+ * - Any custom base URL + key (ROVECODE_BASE_URL/ROVECODE_API_KEY or explicit config)
7
+ * Model catalogs are FETCHED from the endpoint (/v1/models) — never hard-coded (pi pattern).
8
+ * Error-turn shaping (abort vs error; HTTP status + Retry-After side-channel, port #23) lives in stream-errors.ts.
9
+ * Message lowering (harness parts → wire content, incl. port #34 image blocks) lives in wire-messages.ts. */
10
+
11
+ import type { StreamFn, Message, AssistantTurn, StreamEvent, ModelRef, StopReason } from "../core/types.ts";
12
+ import { partsText } from "../core/loop.ts";
13
+ import { applyAnthropicCacheBoundaries } from "./cache.ts";
14
+ import { normalizeUsage } from "../core/usage.ts";
15
+ import { BUILTIN_PROVIDERS, buildSnapshot, pickDefault } from "./provider-config.ts";
16
+ import { failedTurn, fetchFirstByte, httpErrorTurn } from "./stream-errors.ts";
17
+ import { supportsImages } from "./catalog.ts";
18
+ import { profileWire } from "./profiles.ts";
19
+ import { anthropicThinking, thinkingBudget, thinkingPlan, type AnthropicThinkingShape } from "./thinking.ts";
20
+ import { toOpenAiMessages, toAnthropicMessages, toOpenAiToolSchemas, asToolSchema, type WireOptions } from "./wire-messages.ts";
21
+ import { sseLines } from "./sse.ts";
22
+ import { oauthStream, resolveOAuthProviderConfig, type OAuthSeamDeps } from "./oauth/seam.ts";
23
+ import { openaiResponsesStream } from "./responses.ts"; // the /responses wire (aion port #75)
24
+ import { openAiWire } from "./wire-select.ts"; // chat vs responses, per call
25
+
26
+ export { toOpenAiMessages, toAnthropicMessages, toOpenAiToolSchemas } from "./wire-messages.ts";
27
+
28
+ /** port #34: image parts go on the wire as image blocks unless the models.dev catalog says the
29
+ * model has no image input (then wire-messages.ts substitutes a text placeholder); an unknown
30
+ * model is given the image (catalog.ts supportsImages). */
31
+ const wireOptions = (model: ModelRef): WireOptions => ({ vision: supportsImages(model) !== false });
32
+
33
+ export interface ProviderConfig {
34
+ id: string; // provider id, e.g. "kaesra"
35
+ baseUrl: string; // e.g. https://api.kaesra.tech/v1
36
+ apiKey: string;
37
+ protocol: "openai" | "anthropic";
38
+ defaultModel?: string;
39
+ /** extra request headers (proxies, org ids) — providers.json `headers`; the protocol's own auth headers win.
40
+ * Also the OAuth providers' own headers (GitHub Copilot's editor headers, OpenAI's account id) */
41
+ headers?: Record<string, string>;
42
+ /** apiKey is a stored OAuth access token — providerStream/providerStreaming refresh it per request (oauth/seam.ts) */
43
+ oauth?: true;
44
+ /** pin the OpenAI wire for every request (the OpenAI OAuth token sets "responses" — a ChatGPT token is accepted by
45
+ * the Codex Responses backend only); unset = ROVECODE_OPENAI_WIRE, else the catalog rule (wire-select.ts) */
46
+ wire?: "responses" | "chat";
47
+ }
48
+
49
+ /** What a wire adapter needs: endpoint root, key, optional extra headers (ProviderConfig.headers). */
50
+ export interface AdapterOptions {
51
+ baseUrl: string;
52
+ apiKey: string;
53
+ headers?: Record<string, string>;
54
+ }
55
+
56
+ export interface ModelCatalogEntry {
57
+ id: string;
58
+ ownedBy?: string;
59
+ type?: string;
60
+ }
61
+
62
+ // ---------- catalog (fetched, cached) ----------
63
+
64
+ const catalogCache = new Map<string, { models: ModelCatalogEntry[]; fetchedAt: number }>();
65
+ const CATALOG_TTL_MS = 5 * 60_000;
66
+
67
+ export async function fetchModels(cfg: ProviderConfig, force = false): Promise<ModelCatalogEntry[]> {
68
+ const hit = catalogCache.get(cfg.id);
69
+ if (!force && hit && Date.now() - hit.fetchedAt < CATALOG_TTL_MS) return hit.models;
70
+ const url = cfg.baseUrl.replace(/\/$/, "") + "/models";
71
+ let models: ModelCatalogEntry[] = [];
72
+ try {
73
+ const res = await fetch(url, { headers: authHeaders(cfg) });
74
+ if (res.ok) {
75
+ const json = (await res.json()) as { data?: { id: string; owned_by?: string; type?: string }[] };
76
+ models = (json.data ?? []).map((m) => ({ id: m.id, ownedBy: m.owned_by, type: m.type }));
77
+ }
78
+ } catch { /* errors are empty catalog — provider seam never throws */ }
79
+ catalogCache.set(cfg.id, { models, fetchedAt: Date.now() });
80
+ return models;
81
+ }
82
+
83
+ function authHeaders(cfg: ProviderConfig): Record<string, string> {
84
+ return {
85
+ ...(cfg.headers ?? {}),
86
+ ...(cfg.protocol === "anthropic"
87
+ ? { "x-api-key": cfg.apiKey, "anthropic-version": "2023-06-01" }
88
+ : { authorization: `Bearer ${cfg.apiKey}` }),
89
+ };
90
+ }
91
+
92
+ // ---------- factories ----------
93
+
94
+ const optsOf = (cfg: ProviderConfig): AdapterOptions => ({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, ...(cfg.headers !== undefined ? { headers: cfg.headers } : {}) });
95
+
96
+ /** `oauth`: the seam's injectable clock/fetch/store/provider list (tests drive a fake endpoint); production passes nothing */
97
+ export function providerStream(cfg: ProviderConfig, oauth: OAuthSeamDeps = {}): StreamFn {
98
+ if (cfg.oauth) return oauthStream(cfg, openAiWire(cfg, openaiCompatStream, openaiResponsesStream), oauth); // a stored OAuth token: refresh-once-if-expired, then the wire picked per call
99
+ return cfg.protocol === "anthropic" ? anthropicStream(optsOf(cfg)) : openAiWire(cfg, openaiCompatStream, openaiResponsesStream)(optsOf(cfg));
100
+ }
101
+
102
+ /** The streaming twin of providerStream: text_delta as the model writes, the same final turn.
103
+ * This is what every surface gets by default — the one-shot JSON adapters above are the fallback.
104
+ * The ONE builder the CLI's SSE sites use, so an OAuth config's headers (GitHub Copilot's editor
105
+ * headers) reach the streaming request too; a headerless `openaiCompatStreaming({ baseUrl, apiKey })`
106
+ * at a call site is the bug test/unit/auth-login-sse.test.ts pins against. */
107
+ export function providerStreaming(cfg: ProviderConfig, oauth: OAuthSeamDeps = {}): StreamFn {
108
+ if (cfg.oauth) return oauthStream(cfg, openAiWire(cfg, openaiCompatStreaming, openaiResponsesStream), oauth); // the TUI's default path: a long session outlives a 30-minute Copilot token, so it refreshes here too
109
+ return cfg.protocol === "anthropic" ? anthropicStreaming(optsOf(cfg)) : openAiWire(cfg, openaiCompatStreaming, openaiResponsesStream)(optsOf(cfg)); // the Responses wire is SSE-only, so it is the same adapter on both factories
110
+ }
111
+
112
+ /** Streaming is ON unless ROVECODE_STREAM says otherwise: `off`/`json`/`0`/`false`/`none` fall back to
113
+ * the one-shot JSON adapters (a proxy with no SSE route, a recording harness that wants one body).
114
+ * `sse` is still accepted — it used to be the opt-IN spelling and some scripts still set it. */
115
+ export function wantsStreaming(env: { ROVECODE_STREAM?: string | undefined }): boolean {
116
+ const v = (env.ROVECODE_STREAM ?? "").trim().toLowerCase();
117
+ return !(v === "off" || v === "json" || v === "0" || v === "false" || v === "none");
118
+ }
119
+
120
+ /** Streaming requests ask for an UNCOMPRESSED body. Measured against the Anthropic endpoint: with the
121
+ * default `accept-encoding: gzip, deflate, br`, the decompressor holds the whole SSE stream and every
122
+ * event lands in one burst at the end (first text delta at 11.1 s of an 11.1 s response); with
123
+ * `identity` the same prompt starts writing at 1.4 s. Streaming is the point of these two adapters, so
124
+ * the extra bytes are the right trade — the JSON adapters below keep compression. */
125
+ const SSE_HEADERS = { "accept-encoding": "identity" } as const;
126
+
127
+ /** A proxy that ignores `stream: true` and answers with one JSON body would otherwise leave the SSE
128
+ * reader with nothing to parse and the turn empty. Content-type decides: an explicit application/json
129
+ * is read whole through the same parser the one-shot adapters use (no deltas — there is nothing to
130
+ * stream), anything else is treated as an event stream. */
131
+ const isJsonBody = (res: Response): boolean => (res.headers.get("content-type") ?? "").includes("application/json");
132
+
133
+ /** Thinking, per protocol.
134
+ *
135
+ * Anthropic has TWO request shapes and no model exposes both reliably — measured against the live
136
+ * API on 2026-09-03:
137
+ *
138
+ * model output_config.effort thinking.type=enabled
139
+ * claude-opus-5 200 400 "not supported for this model"
140
+ * claude-sonnet-5 200 400
141
+ * claude-opus-4-5 200 200
142
+ * claude-sonnet-4-5 400 "does not support…" 200 → [thinking, text]
143
+ * claude-haiku-4-5 400 200
144
+ *
145
+ * So the shape is a property of the model, and guessing wrong is a hard 400, not a downgrade. There
146
+ * is no capability field to read (the /models list carries ids only), and a hard-coded table would
147
+ * rot with the next release — this file's rule is that catalogs are fetched, never hard-coded. So:
148
+ * try the newer shape, and when the endpoint says that shape is unsupported, flip and retry ONCE,
149
+ * remembering the answer per model id for the rest of the process. One wasted round trip the first
150
+ * time a model is used, never again.
151
+ *
152
+ * `off` is not "send nothing": opus-5 thinks by DEFAULT (measured — a bare request streams
153
+ * thinking_delta), so off has to say so with thinking.type=disabled.
154
+ *
155
+ * Extended thinking and tool use: the thinking blocks are NOT echoed back on the next turn, and
156
+ * measurement says they need not be — a tool_result turn that omits them answers 200 on both shapes
157
+ * and both model generations. */
158
+ // the dial itself (budgets, both Anthropic shapes, every OpenAI-compatible dialect) lives in thinking.ts;
159
+ // re-exported so the adapters' callers and tests keep one import
160
+ export { anthropicThinking, thinkingBudget, type AnthropicThinkingShape };
161
+
162
+ /** what the endpoint said when the shape was wrong — both spellings it uses. The message must ALSO name
163
+ * the dial (thinking/effort/budget): "not supported for this model" alone is how the API refuses other
164
+ * things too (a tool feature, an image), and flipping the shape on those would cost every later request
165
+ * a wasted round trip. */
166
+ const WRONG_SHAPE = /not supported for this model|does not support the effort parameter/i;
167
+ const ABOUT_THINKING = /thinking|effort|budget_tokens|output_config/i;
168
+ const wrongShape = (text: string): boolean => WRONG_SHAPE.test(text) && ABOUT_THINKING.test(text);
169
+
170
+ /** per provider+model, learned from a 400. Process-lifetime: a model's shape does not change under us.
171
+ * Keyed with the provider so a gateway that fronts the same model id differently cannot poison the
172
+ * direct endpoint's memory (or the other way round). Exported for tests only. */
173
+ export const shapeByModel = new Map<string, AnthropicThinkingShape>();
174
+ export const shapeKey = (model: ModelRef): string => `${model.provider}/${model.model}`;
175
+
176
+ /** the answer needs room BESIDE the thinking budget — never less than the caller asked for */
177
+ export function anthropicMaxTokens(model: ModelRef): number {
178
+ const base = model.maxTokens ?? 8192; // buildDef normally sets maxTokens from the catalog; 8192 is the floor every current Claude accepts
179
+ const budget = thinkingBudget(model.effort);
180
+ return budget === null ? base : Math.max(base, budget + 4096);
181
+ }
182
+
183
+ /** the shape the next request for this model will use (for /effort and `model show` to say the truth) */
184
+ export function anthropicShapeFor(model: ModelRef): AnthropicThinkingShape { return shapeByModel.get(shapeKey(model)) ?? "effort"; }
185
+
186
+ /** POST to Anthropic, learning the model's thinking shape from a wrong-shape 400 and retrying ONCE.
187
+ * `build` is called per attempt because the shape changes the body. A failure that is NOT about the
188
+ * shape is returned as-is — the adapters turn it into an error turn (httpErrorTurn). Bounded: at most
189
+ * two sends per request, and the flipped shape is remembered only when the retry was not the same
190
+ * refusal — two wrong-shape 400s in a row (a model that takes neither) leave no memory, so the next
191
+ * request does not oscillate between two guaranteed failures. */
192
+ async function anthropicPost(url: string, headers: Record<string, string>, model: ModelRef, build: (extra: Record<string, unknown>) => unknown, signal: AbortSignal | undefined): Promise<Response> {
193
+ const wanted = model.effort;
194
+ const key = shapeKey(model);
195
+ let shape: AnthropicThinkingShape = shapeByModel.get(key) ?? "effort";
196
+ const send = (): Promise<Response> => fetchFirstByte(url, { method: "POST", headers, body: JSON.stringify(build(anthropicThinking(wanted, shape))), ...(signal ? { signal } : {}) });
197
+ const res = await send();
198
+ // nothing to learn when the request carried no level (auto/off/unset use one shape-free field), or when it worked
199
+ if (res.ok || wanted === undefined || wanted === "auto" || wanted === "off" || res.status !== 400) return res;
200
+ const text = await res.clone().text().catch(() => "");
201
+ if (!wrongShape(text)) return res;
202
+ shape = shape === "effort" ? "budget" : "effort";
203
+ const retry = await send();
204
+ if (retry.status === 400 && wrongShape(await retry.clone().text().catch(() => ""))) shapeByModel.delete(key);
205
+ else shapeByModel.set(key, shape);
206
+ return retry;
207
+ }
208
+
209
+ /** OpenAI-compatible endpoints name the dial in a dozen vocabularies (thinking.ts): the plan's fields are
210
+ * spread into the body after the profile's own wire fields (profiles.ts), so the dial wins a clash. */
211
+ function reasoningEffort(model: ModelRef): Record<string, unknown> {
212
+ return thinkingPlan(model, "openai").fields;
213
+ }
214
+
215
+ export function openaiCompatStream(opts: AdapterOptions): StreamFn {
216
+ return async function* (model: ModelRef, messages: Message[], options?: { signal?: AbortSignal; tools?: unknown[] }): AsyncGenerator<StreamEvent> {
217
+ let turn: AssistantTurn;
218
+ try {
219
+ const res = await fetchFirstByte(opts.baseUrl.replace(/\/$/, "") + "/chat/completions", {
220
+ method: "POST",
221
+ headers: { ...(opts.headers ?? {}), "content-type": "application/json", authorization: `Bearer ${opts.apiKey}` },
222
+ body: JSON.stringify({
223
+ model: model.model,
224
+ messages: toOpenAiMessages(messages, wireOptions(model)),
225
+ ...(options?.tools?.length ? { tools: toOpenAiToolSchemas(options.tools) } : {}),
226
+ stream: false,
227
+ ...profileWire(model, false), // per-family endpoint fields (providers/profiles.ts); the effort word below wins a clash
228
+ ...reasoningEffort(model),
229
+ ...(model.maxTokens ? { max_tokens: model.maxTokens } : {}),
230
+ }),
231
+ signal: options?.signal,
232
+ });
233
+ if (!res.ok) {
234
+ turn = await httpErrorTurn(res); // status + Retry-After recorded for withRetry (stream-errors.ts)
235
+ } else {
236
+ turn = parseOpenAiResponse(await res.json());
237
+ }
238
+ } catch (e) {
239
+ turn = failedTurn(e, options?.signal);
240
+ }
241
+ yield { type: "turn", turn };
242
+ };
243
+ }
244
+
245
+ /** Streaming variant: emits text_delta events as they arrive, then the final turn. */
246
+ export function openaiCompatStreaming(opts: AdapterOptions): StreamFn {
247
+ return async function* (model: ModelRef, messages: Message[], options?: { signal?: AbortSignal; tools?: unknown[] }): AsyncGenerator<StreamEvent> {
248
+ let turn: AssistantTurn;
249
+ let buffer = "";
250
+ const toolArgs = new Map<number, { id: string; name: string; args: string }>();
251
+ try {
252
+ const res = await fetchFirstByte(opts.baseUrl.replace(/\/$/, "") + "/chat/completions", {
253
+ method: "POST",
254
+ headers: { ...(opts.headers ?? {}), ...SSE_HEADERS, "content-type": "application/json", authorization: `Bearer ${opts.apiKey}` },
255
+ body: JSON.stringify({
256
+ model: model.model,
257
+ messages: toOpenAiMessages(messages, wireOptions(model)),
258
+ ...(options?.tools?.length ? { tools: toOpenAiToolSchemas(options.tools) } : {}),
259
+ stream: true,
260
+ // ask for the final usage chunk — without it most OpenAI-compat SSE streams omit usage
261
+ stream_options: { include_usage: true },
262
+ ...profileWire(model, true), // per-family endpoint fields, streaming variant (tool_stream for GLM)
263
+ ...reasoningEffort(model),
264
+ ...(model.maxTokens ? { max_tokens: model.maxTokens } : {}),
265
+ }),
266
+ signal: options?.signal,
267
+ });
268
+ if (!res.ok || !res.body) {
269
+ turn = await httpErrorTurn(res); // status + Retry-After recorded for withRetry (stream-errors.ts)
270
+ yield { type: "turn", turn };
271
+ return;
272
+ }
273
+ if (isJsonBody(res)) { yield { type: "turn", turn: parseOpenAiResponse(await res.json()) }; return; }
274
+ let finish: StopReason = "end_turn";
275
+ let usage: AssistantTurn["usage"] = { input: 0, output: 0 };
276
+ for await (const line of sseLines(res.body)) {
277
+ const ev = JSON.parse(line) as {
278
+ choices?: { delta?: { content?: string | null; reasoning_content?: string | null; tool_calls?: { index?: number; id?: string; function?: { name?: string; arguments?: string } }[] }; finish_reason?: string | null }[];
279
+ usage?: unknown;
280
+ };
281
+ const c = ev.choices?.[0];
282
+ if (c?.delta?.content) { buffer += c.delta.content; yield { type: "text_delta", text: c.delta.content }; }
283
+ // reasoning slices (GLM / DeepSeek-style `reasoning_content`): surfaced exactly like Anthropic's
284
+ // thinking_delta — the live status line counts them, the turn's parts never carry them
285
+ if (c?.delta?.reasoning_content) yield { type: "reasoning_delta", text: c.delta.reasoning_content };
286
+ for (const tc of c?.delta?.tool_calls ?? []) {
287
+ const idx = tc.index ?? 0;
288
+ const cur = toolArgs.get(idx) ?? { id: tc.id ?? `tc${idx}`, name: tc.function?.name ?? "", args: "" };
289
+ if (tc.id) cur.id = tc.id;
290
+ if (tc.function?.name) cur.name += tc.function.name;
291
+ if (tc.function?.arguments) cur.args += tc.function.arguments;
292
+ toolArgs.set(idx, cur);
293
+ }
294
+ if (c?.finish_reason) finish = c.finish_reason === "tool_calls" ? "tool_use" : c.finish_reason === "length" ? "length" : "end_turn";
295
+ if (ev.usage) {
296
+ // same normalization as the JSON adapters (parseOpenAiResponse/parseAnthropicResponse):
297
+ // cached_tokens subtracted from the inclusive prompt count, cacheRead/Write carried
298
+ const u = normalizeUsage(ev.usage);
299
+ usage = { input: u.input, output: u.output, cacheRead: u.cacheRead || undefined, cacheWrite: u.cacheWrite || undefined };
300
+ }
301
+ }
302
+ const parts: AssistantTurn["parts"] = [];
303
+ if (buffer) parts.push({ kind: "text", text: buffer });
304
+ for (const [, tc] of [...toolArgs].sort((a, b) => a[0] - b[0])) {
305
+ let args: unknown = {};
306
+ try { args = JSON.parse(tc.args || "{}"); } catch { args = { _raw: tc.args }; }
307
+ parts.push({ kind: "tool_call", id: tc.id, tool: tc.name, args });
308
+ }
309
+ turn = { parts, stopReason: finish, usage };
310
+ } catch (e) {
311
+ turn = failedTurn(e, options?.signal, buffer); // mid-stream abort: the deltas already streamed survive as the turn's text
312
+ }
313
+ yield { type: "turn", turn };
314
+ };
315
+ }
316
+
317
+ /** Streaming variant of the Anthropic adapter: emits text_delta as the model writes, then the final
318
+ * turn. The Messages SSE stream is a sequence of numbered content blocks — `content_block_start`
319
+ * opens one (text or tool_use), `content_block_delta` carries `text_delta` for prose and
320
+ * `input_json_delta` for a tool call's arguments (streamed as JSON text, one fragment at a time),
321
+ * `content_block_stop` closes it. Usage arrives in two halves: the input side on `message_start`,
322
+ * the output count on the final `message_delta` — they are merged so /cost sees the same shape the
323
+ * JSON adapter produces. Block ORDER is preserved: parts are emitted by block index, so a text
324
+ * block before a tool_use stays before it. */
325
+ export function anthropicStreaming(opts: AdapterOptions): StreamFn {
326
+ return async function* (model: ModelRef, messages: Message[], options?: { signal?: AbortSignal; tools?: unknown[] }): AsyncGenerator<StreamEvent> {
327
+ let turn: AssistantTurn;
328
+ let buffer = "";
329
+ /** open + finished blocks by wire index; `json` accumulates an input_json_delta */
330
+ const blocks = new Map<number, { kind: "text"; text: string } | { kind: "tool"; id: string; name: string; json: string } | { kind: "thinking" }>();
331
+ try {
332
+ const system = messages.filter((m) => m.role === "system").map((m) => partsText(m.parts)).join("\n");
333
+ const rest: Record<string, unknown> = {};
334
+ if (options?.tools?.length) rest.tools = options.tools.map((t) => { const sc = asToolSchema(t); return { name: sc.name, description: sc.description, input_schema: sc.args }; });
335
+ if (system) rest.system = system;
336
+ const body = (extra: Record<string, unknown>): Record<string, unknown> => ({
337
+ model: model.model,
338
+ max_tokens: anthropicMaxTokens(model),
339
+ messages: toAnthropicMessages(messages, wireOptions(model)),
340
+ stream: true,
341
+ ...rest,
342
+ ...extra,
343
+ });
344
+ const res = await anthropicPost(
345
+ opts.baseUrl.replace(/\/$/, "") + "/messages",
346
+ { ...(opts.headers ?? {}), ...SSE_HEADERS, "content-type": "application/json", "x-api-key": opts.apiKey, "anthropic-version": "2023-06-01" },
347
+ model,
348
+ (extra) => applyAnthropicCacheBoundaries(body(extra)),
349
+ options?.signal,
350
+ );
351
+ if (!res.ok || !res.body) {
352
+ turn = await httpErrorTurn(res); // status + Retry-After recorded for withRetry (stream-errors.ts)
353
+ yield { type: "turn", turn };
354
+ return;
355
+ }
356
+ if (isJsonBody(res)) { yield { type: "turn", turn: parseAnthropicResponse(await res.json()) }; return; }
357
+ let stop: StopReason = "end_turn";
358
+ let usage: AssistantTurn["usage"] = { input: 0, output: 0 };
359
+ for await (const line of sseLines(res.body)) {
360
+ const ev = JSON.parse(line) as {
361
+ type?: string;
362
+ index?: number;
363
+ message?: { usage?: unknown };
364
+ content_block?: { type?: string; id?: string; name?: string };
365
+ delta?: { type?: string; text?: string; partial_json?: string; thinking?: string; stop_reason?: string | null };
366
+ usage?: unknown;
367
+ error?: { type?: string; message?: string };
368
+ };
369
+ switch (ev.type) {
370
+ case "message_start": {
371
+ const u = normalizeUsage(ev.message?.usage);
372
+ usage = { input: u.input, output: u.output, cacheRead: u.cacheRead || undefined, cacheWrite: u.cacheWrite || undefined };
373
+ break;
374
+ }
375
+ case "content_block_start": {
376
+ const i = ev.index ?? 0;
377
+ if (ev.content_block?.type === "tool_use") blocks.set(i, { kind: "tool", id: ev.content_block.id ?? `tc${i}`, name: ev.content_block.name ?? "unknown", json: "" });
378
+ else if (ev.content_block?.type === "thinking" || ev.content_block?.type === "redacted_thinking") blocks.set(i, { kind: "thinking" });
379
+ else blocks.set(i, { kind: "text", text: "" });
380
+ break;
381
+ }
382
+ case "content_block_delta": {
383
+ const i = ev.index ?? 0;
384
+ const b = blocks.get(i);
385
+ if (ev.delta?.type === "text_delta" && ev.delta.text !== undefined) {
386
+ buffer += ev.delta.text;
387
+ if (b?.kind === "text") b.text += ev.delta.text;
388
+ else blocks.set(i, { kind: "text", text: ev.delta.text }); // a delta without its start
389
+ yield { type: "text_delta", text: ev.delta.text };
390
+ } else if (ev.delta?.type === "input_json_delta" && ev.delta.partial_json !== undefined && b?.kind === "tool") {
391
+ b.json += ev.delta.partial_json;
392
+ } else if (ev.delta?.type === "thinking_delta" && ev.delta.thinking !== undefined) {
393
+ // the model is reasoning: surface the slice so the TUI can prove work is happening, keep
394
+ // nothing — thinking is not the answer (signature_delta carries no tokens and is skipped)
395
+ yield { type: "reasoning_delta", text: ev.delta.thinking };
396
+ }
397
+ break;
398
+ }
399
+ case "message_delta": {
400
+ if (ev.delta?.stop_reason === "max_tokens") stop = "length";
401
+ // the output count only exists here; the input side stays as message_start reported it
402
+ if (ev.usage !== undefined) {
403
+ const u = normalizeUsage(ev.usage);
404
+ usage = { ...usage, output: u.output || usage.output, cacheRead: usage.cacheRead ?? (u.cacheRead || undefined), cacheWrite: usage.cacheWrite ?? (u.cacheWrite || undefined) };
405
+ }
406
+ break;
407
+ }
408
+ // a mid-stream `error` event ends the turn with what was already streamed
409
+ case "error":
410
+ throw new Error(ev.error?.message ?? "anthropic stream error");
411
+ default:
412
+ break;
413
+ }
414
+ }
415
+ const parts: AssistantTurn["parts"] = [];
416
+ for (const [, b] of [...blocks].sort((a, z) => a[0] - z[0])) {
417
+ if (b.kind === "thinking") continue; // reasoning never becomes a part
418
+ if (b.kind === "text") { if (b.text) parts.push({ kind: "text", text: b.text }); continue; }
419
+ let args: unknown = {};
420
+ try { args = JSON.parse(b.json || "{}"); } catch { args = { _raw: b.json }; }
421
+ parts.push({ kind: "tool_call", id: b.id, tool: b.name, args });
422
+ stop = "tool_use";
423
+ }
424
+ turn = { parts, stopReason: stop, usage };
425
+ } catch (e) {
426
+ turn = failedTurn(e, options?.signal, buffer); // mid-stream abort: the deltas already streamed survive as the turn's text
427
+ }
428
+ yield { type: "turn", turn };
429
+ };
430
+ }
431
+
432
+ /** Anthropic Messages protocol adapter. */
433
+ export function anthropicStream(opts: AdapterOptions): StreamFn {
434
+ return async function* (model: ModelRef, messages: Message[], options?: { signal?: AbortSignal; tools?: unknown[] }): AsyncGenerator<StreamEvent> {
435
+ let turn: AssistantTurn;
436
+ try {
437
+ const system = messages.filter((m) => m.role === "system").map((m) => partsText(m.parts)).join("\n");
438
+ const rest: Record<string, unknown> = {};
439
+ if (options?.tools?.length) rest.tools = options.tools.map((t) => { const sc = asToolSchema(t); return { name: sc.name, description: sc.description, input_schema: sc.args }; });
440
+ if (system) rest.system = system;
441
+ const body = (extra: Record<string, unknown>): Record<string, unknown> => ({
442
+ model: model.model,
443
+ max_tokens: anthropicMaxTokens(model),
444
+ messages: toAnthropicMessages(messages, wireOptions(model)),
445
+ ...rest,
446
+ ...extra,
447
+ });
448
+ // port #5: place prompt-cache breakpoints on the stable prefix (hermes pattern)
449
+ const res = await anthropicPost(
450
+ opts.baseUrl.replace(/\/$/, "") + "/messages",
451
+ { ...(opts.headers ?? {}), "content-type": "application/json", "x-api-key": opts.apiKey, "anthropic-version": "2023-06-01" },
452
+ model,
453
+ (extra) => applyAnthropicCacheBoundaries(body(extra)),
454
+ options?.signal,
455
+ );
456
+ if (!res.ok) {
457
+ turn = await httpErrorTurn(res); // status + Retry-After recorded for withRetry (stream-errors.ts)
458
+ } else {
459
+ turn = parseAnthropicResponse(await res.json());
460
+ }
461
+ } catch (e) {
462
+ turn = failedTurn(e, options?.signal);
463
+ }
464
+ yield { type: "turn", turn };
465
+ };
466
+ }
467
+
468
+ // ---------- parsing ----------
469
+
470
+ function parseOpenAiResponse(json: unknown): AssistantTurn {
471
+ const j = json as {
472
+ choices: { message: { content: string | null; tool_calls?: { id: string; function: { name: string; arguments: string } }[] }; finish_reason: string | null }[];
473
+ usage?: { prompt_tokens: number; completion_tokens: number };
474
+ };
475
+ const c = j.choices?.[0];
476
+ const parts: AssistantTurn["parts"] = [];
477
+ if (c?.message.content) parts.push({ kind: "text", text: c.message.content });
478
+ for (const tc of c?.message.tool_calls ?? []) {
479
+ let args: unknown = {};
480
+ try { args = JSON.parse(tc.function.arguments || "{}"); } catch { args = { _raw: tc.function.arguments }; }
481
+ parts.push({ kind: "tool_call", id: tc.id, tool: tc.function.name, args });
482
+ }
483
+ const stop = (c?.message.tool_calls?.length ?? 0) > 0
484
+ ? "tool_use"
485
+ : c?.finish_reason === "length" ? "length" : "end_turn";
486
+ const u = normalizeUsage(j.usage);
487
+ return { parts, stopReason: stop, usage: { input: u.input, output: u.output, cacheRead: u.cacheRead || undefined, cacheWrite: u.cacheWrite || undefined } };
488
+ }
489
+
490
+ function parseAnthropicResponse(json: unknown): AssistantTurn {
491
+ const j = json as {
492
+ content: { type: string; text?: string; id?: string; name?: string; input?: unknown }[];
493
+ stop_reason: string | null;
494
+ usage: { input_tokens: number; output_tokens: number };
495
+ };
496
+ const parts: AssistantTurn["parts"] = [];
497
+ for (const b of j.content ?? []) {
498
+ if (b.type === "text" && b.text) parts.push({ kind: "text", text: b.text });
499
+ if (b.type === "tool_use" && b.id) parts.push({ kind: "tool_call", id: b.id, tool: b.name ?? "unknown", args: b.input ?? {} });
500
+ }
501
+ const stop = (j.content ?? []).some((b) => b.type === "tool_use") ? "tool_use" : j.stop_reason === "max_tokens" ? "length" : "end_turn";
502
+ const u = normalizeUsage(j.usage);
503
+ return { parts, stopReason: stop, usage: { input: u.input, output: u.output, cacheRead: u.cacheRead || undefined, cacheWrite: u.cacheWrite || undefined } };
504
+ }
505
+
506
+ // ---------- SSE: the line reader lives in sse.ts (shared with any future wire that must not import this file) ----------
507
+
508
+ // ---------- mock (test seam) ----------
509
+
510
+ export interface MockScript { turns: AssistantTurn[] }
511
+
512
+ export function mockStream(script: MockScript): StreamFn {
513
+ let i = 0;
514
+ return async function* (_model: ModelRef, _messages: Message[]): AsyncGenerator<StreamEvent> {
515
+ const turn = script.turns[Math.min(i, script.turns.length - 1)]!;
516
+ i++;
517
+ yield { type: "turn", turn };
518
+ };
519
+ }
520
+
521
+ export function textTurn(text: string): AssistantTurn {
522
+ return { parts: [{ kind: "text", text }], stopReason: "end_turn", usage: { input: 0, output: 1 } };
523
+ }
524
+
525
+ export function toolTurn(calls: { id: string; tool: string; args: unknown }[]): AssistantTurn {
526
+ return { parts: calls.map((c) => ({ kind: "tool_call" as const, ...c })), stopReason: "tool_use", usage: { input: 0, output: 1 } };
527
+ }
528
+
529
+ // ---------- registry: named providers from env/config ----------
530
+ // The provider table and the providers.json merge live in provider-config.ts (data) and the live
531
+ // dispatcher in registry.ts. resolveProvider() is the ONE-SHOT default lookup the CLI uses at boot.
532
+
533
+ /** Resolve the default provider config: ROVECODE_BASE_URL/ROVECODE_API_KEY override, else the
534
+ * providers.json `default` selector (when that provider has a key), else stored credential
535
+ * (`rovecode auth set`, port #37) in provider order, else a named env key, else a stored OAuth
536
+ * token (`rovecode auth login`, oauth/seam.ts — "a stored token is used when no API key is set"), else null.
537
+ *
538
+ * Precedence follows opencode provider.ts @ ebece6e: stored api keys are merged AFTER env
539
+ * (provider.ts:1578-1602, later mergeProvider patch wins) so a stored credential beats a
540
+ * named env key; the explicit pair keeps its documented "always wins" rank, like opencode's
541
+ * config source re-applied last (provider.ts:1643-1651). provider-config.ts pickDefault is
542
+ * the single implementation; the live registry shares it. */
543
+ export function resolveProvider(overrides: { baseUrl?: string; apiKey?: string; id?: string; protocol?: "openai" | "anthropic"; defaultModel?: string } = {}): ProviderConfig | null {
544
+ const base = overrides.baseUrl ?? process.env.ROVECODE_BASE_URL;
545
+ const key = overrides.apiKey ?? process.env.ROVECODE_API_KEY ?? process.env.OPENAI_API_KEY;
546
+ if (base && key) {
547
+ const looksAnthropic = overrides.protocol === "anthropic" || base.includes("anthropic.com");
548
+ const defaultModel = overrides.defaultModel ?? process.env.ROVECODE_MODEL;
549
+ return { id: overrides.id ?? "custom", baseUrl: base, apiKey: key, protocol: looksAnthropic ? "anthropic" : "openai", ...(defaultModel !== undefined ? { defaultModel } : {}) };
550
+ }
551
+ const pick = pickDefault(buildSnapshot(process.cwd()));
552
+ if (pick === null) return resolveOAuthProviderConfig(); // a stored OAuth token is used when no API key is set; else null
553
+ const p = pick.provider;
554
+ return {
555
+ id: p.id, baseUrl: p.baseUrl, apiKey: p.apiKey ?? "", protocol: p.protocol,
556
+ ...(pick.model !== undefined ? { defaultModel: pick.model } : {}),
557
+ ...(p.headers !== undefined ? { headers: p.headers } : {}),
558
+ };
559
+ }
560
+
561
+ export function listBuiltinProviders(): { id: string; baseUrl: string; protocol: string; envKey: string; configured: boolean }[] {
562
+ return BUILTIN_PROVIDERS.map((p) => {
563
+ const envKey = p.keyEnv ?? `${p.id.toUpperCase()}_API_KEY`;
564
+ return { id: p.id, baseUrl: p.baseUrl, protocol: p.protocol, envKey, configured: Boolean(process.env[envKey]) };
565
+ });
566
+ }