rovecode 0.4.0-beta.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -69
- package/THIRD_PARTY_NOTICES.md +0 -44
- package/bin/rovecode.ts +21 -0
- package/package.json +16 -37
- package/src/account/keys.ts +97 -0
- package/src/account/login.ts +158 -0
- package/src/account/provision.ts +47 -0
- package/src/account/store.ts +63 -0
- package/src/acp/server.ts +373 -0
- package/src/cli/account-cmd.ts +116 -0
- package/src/cli/connect.ts +244 -0
- package/src/cli/context-cmd.ts +199 -0
- package/src/cli/dispatch.ts +109 -0
- package/src/cli/doctor.ts +324 -0
- package/src/cli/export.ts +278 -0
- package/src/cli/help.ts +240 -0
- package/src/cli/is-tui-invocation.ts +8 -0
- package/src/cli/main.ts +599 -0
- package/src/cli/market-cmd.ts +658 -0
- package/src/cli/mcp-market-cmd.ts +299 -0
- package/src/cli/output.ts +382 -0
- package/src/cli/repl.ts +172 -0
- package/src/cli/resume.ts +32 -0
- package/src/cli/run-limits.ts +78 -0
- package/src/cli/runtime.ts +792 -0
- package/src/cli/setup.ts +187 -0
- package/src/cli/update-cmd.ts +78 -0
- package/src/cli/workflow-cmd.ts +100 -0
- package/src/coding/checkpoints.ts +270 -0
- package/src/coding/diff.ts +136 -0
- package/src/coding/files.ts +339 -0
- package/src/coding/hashline.ts +319 -0
- package/src/coding/lsp.ts +406 -0
- package/src/coding/repomap-cache.ts +99 -0
- package/src/coding/repomap-files.ts +110 -0
- package/src/coding/repomap.ts +392 -0
- package/src/core/compaction.ts +399 -0
- package/src/core/config.ts +289 -0
- package/src/core/context-report.ts +228 -0
- package/src/core/context.ts +60 -0
- package/src/core/count-remote.ts +107 -0
- package/src/core/execpolicy-rules.ts +196 -0
- package/src/core/execpolicy.ts +385 -0
- package/src/core/executor.ts +397 -0
- package/src/core/guardrails.ts +400 -0
- package/src/core/hooks.ts +398 -0
- package/src/core/images.ts +230 -0
- package/src/core/intro.ts +236 -0
- package/src/core/loop.ts +621 -0
- package/src/core/modes.ts +372 -0
- package/src/core/orchestrator.ts +207 -0
- package/src/core/reflection.ts +165 -0
- package/src/core/sandbox-config.ts +167 -0
- package/src/core/session-images.ts +73 -0
- package/src/core/session.ts +398 -0
- package/src/core/settings.ts +98 -0
- package/src/core/stuck-detector.ts +273 -0
- package/src/core/tasks.ts +374 -0
- package/src/core/token-scale.ts +108 -0
- package/src/core/tool-output-budget.ts +166 -0
- package/src/core/tools.ts +288 -0
- package/src/core/types.ts +330 -0
- package/src/core/update-check.ts +171 -0
- package/src/core/update.ts +158 -0
- package/src/core/usage.ts +204 -0
- package/src/core/validate.ts +121 -0
- package/src/core/verify-gate.ts +159 -0
- package/src/core/verify.ts +237 -0
- package/src/core/voice.ts +158 -0
- package/src/core/win-job.ts +183 -0
- package/src/design/audit.ts +797 -0
- package/src/design/direction.ts +190 -0
- package/src/design/rules.ts +157 -0
- package/src/eval/bench.ts +150 -0
- package/src/eval/gauntlet-runner.ts +218 -0
- package/src/eval/gauntlet.ts +226 -0
- package/src/eval/grader.ts +186 -0
- package/src/eval/record.ts +202 -0
- package/src/eval/redact.ts +141 -0
- package/src/eval/replay.ts +147 -0
- package/src/eval/trajectory.ts +373 -0
- package/src/index.ts +17 -0
- package/src/market/catalogs/mcp-docs.json +111 -0
- package/src/market/catalogs/plugins.json +111 -0
- package/src/market/catalogs/skills.json +478 -0
- package/src/market/clone.ts +72 -0
- package/src/market/context-cost.ts +121 -0
- package/src/market/digest.ts +106 -0
- package/src/market/index.ts +22 -0
- package/src/market/install.ts +578 -0
- package/src/market/manifest.ts +187 -0
- package/src/market/prereq.ts +145 -0
- package/src/market/registry.ts +363 -0
- package/src/market/resolve.ts +111 -0
- package/src/market/types.ts +236 -0
- package/src/market/validate.ts +227 -0
- package/src/mcp/client.ts +431 -0
- package/src/mcp/config.ts +239 -0
- package/src/mcp/local-package.ts +211 -0
- package/src/mcp/market-catalog.ts +84 -0
- package/src/mcp/market-install.ts +289 -0
- package/src/mcp/market.ts +0 -0
- package/src/mcp/tools.ts +131 -0
- package/src/mcp/trust.ts +49 -0
- package/src/memory/blocks.ts +175 -0
- package/src/memory/recall.ts +355 -0
- package/src/memory/store.ts +105 -0
- package/src/memory/tools.ts +99 -0
- package/src/plugins/cli.ts +123 -0
- package/src/plugins/discover.ts +108 -0
- package/src/plugins/index.ts +50 -0
- package/src/plugins/init.ts +140 -0
- package/src/plugins/install.ts +184 -0
- package/src/plugins/load.ts +149 -0
- package/src/plugins/manifest.ts +106 -0
- package/src/plugins/state.ts +83 -0
- package/src/providers/auth.ts +293 -0
- package/src/providers/cache.ts +223 -0
- package/src/providers/catalog-local.ts +160 -0
- package/src/providers/catalog.ts +408 -0
- package/src/providers/middleware-context.ts +86 -0
- package/src/providers/middleware.ts +373 -0
- package/src/providers/profile-glm53.ts +111 -0
- package/src/providers/profile-sonnet5-persona.ts +65 -0
- package/src/providers/profile-sonnet5-voice.ts +23 -0
- package/src/providers/profiles.ts +156 -0
- package/src/providers/provider-config.ts +311 -0
- package/src/providers/registry.ts +302 -0
- package/src/providers/response-validation.ts +80 -0
- package/src/providers/retry.ts +234 -0
- package/src/providers/router.ts +294 -0
- package/src/providers/sse.ts +26 -0
- package/src/providers/stream-errors.ts +117 -0
- package/src/providers/stream.ts +569 -0
- package/src/providers/thinking.ts +189 -0
- package/src/providers/wire-messages.ts +129 -0
- package/src/sdk/client.ts +225 -0
- package/src/sdk/index.ts +3 -0
- package/src/server/dashboard.ts +144 -0
- package/src/server/http.ts +343 -0
- package/src/server/openapi.ts +246 -0
- package/src/sextant/card-hits.ts +102 -0
- package/src/sextant/card-keys.ts +55 -0
- package/src/sextant/context-source.ts +157 -0
- package/src/sextant/draw-agents.ts +273 -0
- package/src/sextant/draw-code.ts +388 -0
- package/src/sextant/draw-context.ts +222 -0
- package/src/sextant/draw-frame.ts +164 -0
- package/src/sextant/draw-market.ts +573 -0
- package/src/sextant/draw-messages.ts +386 -0
- package/src/sextant/draw-pet.ts +230 -0
- package/src/sextant/draw-plan.ts +159 -0
- package/src/sextant/draw-tabs.ts +85 -0
- package/src/sextant/draw-util.ts +65 -0
- package/src/sextant/engine.ts +230 -0
- package/src/sextant/frame-hits.ts +25 -0
- package/src/sextant/frame.ts +101 -0
- package/src/sextant/git-status.ts +197 -0
- package/src/sextant/grid.ts +59 -0
- package/src/sextant/input.ts +119 -0
- package/src/sextant/keys.ts +488 -0
- package/src/sextant/layout.ts +86 -0
- package/src/sextant/local-commands.ts +156 -0
- package/src/sextant/market-source.ts +287 -0
- package/src/sextant/mentions.ts +141 -0
- package/src/sextant/message-hits.ts +26 -0
- package/src/sextant/model.ts +387 -0
- package/src/sextant/overlays.ts +451 -0
- package/src/sextant/panel-hits.ts +38 -0
- package/src/sextant/pet.ts +399 -0
- package/src/sextant/screen.ts +324 -0
- package/src/sextant/scroll-hits.ts +66 -0
- package/src/sextant/scrollbar.ts +82 -0
- package/src/sextant/selection.ts +123 -0
- package/src/sextant/sextant-bridge.ts +174 -0
- package/src/sextant/sextant-cards.ts +142 -0
- package/src/sextant/sextant-diff-base.ts +63 -0
- package/src/sextant/sextant-files.ts +154 -0
- package/src/sextant/sextant-frame-loop.ts +314 -0
- package/src/sextant/sextant-renderer.ts +478 -0
- package/src/sextant/sextant-repo.ts +131 -0
- package/src/sextant/theme.ts +66 -0
- package/src/sextant/tool-rows.ts +189 -0
- package/src/sextant/types.ts +473 -0
- package/src/skills/index.ts +306 -0
- package/src/skills/tools.ts +69 -0
- package/src/skills/versioned.ts +227 -0
- package/src/telemetry/otel.ts +353 -0
- package/src/telemetry/otlp.ts +68 -0
- package/src/tools/ask-user.ts +156 -0
- package/src/tools/design.ts +151 -0
- package/src/tools/evalcell.ts +338 -0
- package/src/tools/html-text.ts +139 -0
- package/src/tools/provider.ts +149 -0
- package/src/tools/task.ts +216 -0
- package/src/tools/todo.ts +320 -0
- package/src/tools/webfetch.ts +331 -0
- package/src/tui/app.ts +608 -0
- package/src/tui/attach.ts +127 -0
- package/src/tui/checkpoints-cmd.ts +70 -0
- package/src/tui/clipboard-image.ts +81 -0
- package/src/tui/commands.ts +277 -0
- package/src/tui/cost.ts +108 -0
- package/src/tui/info-cmd.ts +144 -0
- package/src/tui/mcp-cmd.ts +128 -0
- package/src/tui/modes-cmd.ts +45 -0
- package/src/tui/overlays.ts +97 -0
- package/src/tui/pi-renderer.ts +424 -0
- package/src/tui/providers-cmd.ts +366 -0
- package/src/tui/renderer.ts +101 -0
- package/src/tui/replay-marker.ts +29 -0
- package/src/tui/session-cmd.ts +146 -0
- package/src/tui/sextant-attach.ts +68 -0
- package/src/tui/sextant-io.ts +184 -0
- package/src/tui/sextant-smoke.ts +110 -0
- package/src/tui/smoke.ts +72 -0
- package/src/tui/theme.ts +59 -0
- package/src/tui/todo-label.ts +7 -0
- package/src/workflow/engine.ts +266 -0
- package/tsconfig.json +30 -0
- package/vendor/pi-tui/LICENSE +21 -0
- package/vendor/pi-tui/PATCHES.md +12 -0
- package/vendor/pi-tui/PROVENANCE.md +12 -0
- package/vendor/pi-tui/README.upstream.md +854 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
- package/vendor/pi-tui/src/autocomplete.ts +827 -0
- package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
- package/vendor/pi-tui/src/components/box.ts +138 -0
- package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
- package/vendor/pi-tui/src/components/editor.ts +2364 -0
- package/vendor/pi-tui/src/components/h-stack.ts +45 -0
- package/vendor/pi-tui/src/components/image.ts +128 -0
- package/vendor/pi-tui/src/components/input.ts +448 -0
- package/vendor/pi-tui/src/components/loader.ts +93 -0
- package/vendor/pi-tui/src/components/markdown.ts +1016 -0
- package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
- package/vendor/pi-tui/src/components/select-list.ts +230 -0
- package/vendor/pi-tui/src/components/settings-list.ts +277 -0
- package/vendor/pi-tui/src/components/spacer.ts +29 -0
- package/vendor/pi-tui/src/components/stack.ts +155 -0
- package/vendor/pi-tui/src/components/text.ts +108 -0
- package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
- package/vendor/pi-tui/src/components/v-stack.ts +34 -0
- package/vendor/pi-tui/src/editor-component.ts +75 -0
- package/vendor/pi-tui/src/fuzzy.ts +138 -0
- package/vendor/pi-tui/src/index.ts +149 -0
- package/vendor/pi-tui/src/keybindings.ts +321 -0
- package/vendor/pi-tui/src/keys.ts +1402 -0
- package/vendor/pi-tui/src/kill-ring.ts +47 -0
- package/vendor/pi-tui/src/latex.ts +1381 -0
- package/vendor/pi-tui/src/layout-node.ts +52 -0
- package/vendor/pi-tui/src/layout.ts +411 -0
- package/vendor/pi-tui/src/native-modifiers.ts +60 -0
- package/vendor/pi-tui/src/native-module-path.ts +32 -0
- package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
- package/vendor/pi-tui/src/terminal-colors.ts +74 -0
- package/vendor/pi-tui/src/terminal-image.ts +701 -0
- package/vendor/pi-tui/src/terminal.ts +554 -0
- package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
- package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
- package/vendor/pi-tui/src/tui.ts +1264 -0
- package/vendor/pi-tui/src/undo-stack.ts +29 -0
- package/vendor/pi-tui/src/utils.ts +1327 -0
- package/vendor/pi-tui/src/word-navigation.ts +118 -0
- package/vendor/pi-tui/test/test-themes.ts +39 -0
- package/vendor/pi-tui/test/virtual-terminal.ts +219 -0
- package/CHANGELOG.md +0 -512
- package/bin/rovecode.js +0 -24
- package/dist/cli/app-dybnr56b.js +0 -2
- package/dist/cli/ask-user-p8hq4xgj.js +0 -2
- package/dist/cli/auth-login-ewpgw5sm.js +0 -2
- package/dist/cli/auth-m8p9grty.js +0 -2
- package/dist/cli/bench-xv3ypwev.js +0 -9
- package/dist/cli/catalog-737wb2s0.js +0 -2
- package/dist/cli/cli-arhg40m0.js +0 -2
- package/dist/cli/client-cf2pxx8q.js +0 -2
- package/dist/cli/commands-3p7e4xxs.js +0 -2
- package/dist/cli/connect-3q93d7cb.js +0 -2
- package/dist/cli/context-cmd-eqxmxhzq.js +0 -2
- package/dist/cli/context-report-hbw9zfes.js +0 -2
- package/dist/cli/count-remote-mby98cd0.js +0 -2
- package/dist/cli/design-122y0axd.js +0 -2
- package/dist/cli/dispatch-b4egzvvh.js +0 -2
- package/dist/cli/doctor-x4jkv72e.js +0 -3
- package/dist/cli/executor-ftvg6tsy.js +0 -2
- package/dist/cli/export-pdgdhkch.js +0 -2
- package/dist/cli/files-cez9a96p.js +0 -2
- package/dist/cli/gauntlet-r3xxaszc.js +0 -2
- package/dist/cli/gauntlet-runner-r515m7kk.js +0 -10
- package/dist/cli/gauntlet-wave3-bnkjk2v2.js +0 -5
- package/dist/cli/gauntlet-wave4-acs9s60q.js +0 -14
- package/dist/cli/hashline-ewg5hbe3.js +0 -2
- package/dist/cli/http-n0kehsk8.js +0 -5
- package/dist/cli/index-z5qt1s76.js +0 -2
- package/dist/cli/install-80mp63kx.js +0 -2
- package/dist/cli/loop-12twjcat.js +0 -2
- package/dist/cli/main-01pv9206.js +0 -4
- package/dist/cli/main-0jys2ccn.js +0 -3
- package/dist/cli/main-1ztz6fkj.js +0 -10
- package/dist/cli/main-23q7cmww.js +0 -9
- package/dist/cli/main-2rzbexn2.js +0 -3
- package/dist/cli/main-2wyax8k9.js +0 -9
- package/dist/cli/main-2yeveeve.js +0 -6
- package/dist/cli/main-2z3dek0b.js +0 -3
- package/dist/cli/main-2zgsknth.js +0 -3
- package/dist/cli/main-45ejth3a.js +0 -4
- package/dist/cli/main-45rn3trk.js +0 -22
- package/dist/cli/main-4p4e2w7x.js +0 -4
- package/dist/cli/main-4y0tnfpa.js +0 -16
- package/dist/cli/main-5py0rkmc.js +0 -4
- package/dist/cli/main-6dtqmbt6.js +0 -7
- package/dist/cli/main-6h9x282m.js +0 -4
- package/dist/cli/main-6vjeds42.js +0 -3
- package/dist/cli/main-78gq4bt9.js +0 -6
- package/dist/cli/main-7jd5vh3x.js +0 -4
- package/dist/cli/main-7kt6r53y.js +0 -4
- package/dist/cli/main-8c1tbazx.js +0 -58
- package/dist/cli/main-9a9rnh47.js +0 -19
- package/dist/cli/main-9ht36z12.js +0 -3
- package/dist/cli/main-a2yfvcy9.js +0 -7
- package/dist/cli/main-a3f51n0x.js +0 -5
- package/dist/cli/main-b8zq261k.js +0 -3
- package/dist/cli/main-bxtvnf6d.js +0 -13
- package/dist/cli/main-edxc3yzt.js +0 -4
- package/dist/cli/main-evgz4mp5.js +0 -21
- package/dist/cli/main-f33fc5je.js +0 -9
- package/dist/cli/main-fvnpq46y.js +0 -12
- package/dist/cli/main-gbbty4d4.js +0 -3
- package/dist/cli/main-gth53dnt.js +0 -25
- package/dist/cli/main-hqbz10aw.js +0 -9
- package/dist/cli/main-hrrvcfan.js +0 -38
- package/dist/cli/main-hzwtsb2m.js +0 -5
- package/dist/cli/main-j7ttv0sd.js +0 -34
- package/dist/cli/main-jak598k9.js +0 -5
- package/dist/cli/main-kba6zeyd.js +0 -6
- package/dist/cli/main-kwwsz6rq.js +0 -3
- package/dist/cli/main-m8vm17zq.js +0 -3
- package/dist/cli/main-mg4f96e1.js +0 -3
- package/dist/cli/main-mg9b20ac.js +0 -18
- package/dist/cli/main-mgb9ccnx.js +0 -3
- package/dist/cli/main-mjt2p7aj.js +0 -3
- package/dist/cli/main-n6qrdbmy.js +0 -3
- package/dist/cli/main-na7wse0x.js +0 -5
- package/dist/cli/main-nqveez48.js +0 -4
- package/dist/cli/main-ntqef02r.js +0 -10
- package/dist/cli/main-nvc3yjay.js +0 -136
- package/dist/cli/main-p0cfn6nr.js +0 -16
- package/dist/cli/main-qj2djy17.js +0 -19
- package/dist/cli/main-qsevpgsv.js +0 -3
- package/dist/cli/main-qvarybsp.js +0 -3
- package/dist/cli/main-rebtt91r.js +0 -5
- package/dist/cli/main-rpg7h8mb.js +0 -3
- package/dist/cli/main-rsy72qmw.js +0 -15
- package/dist/cli/main-rvetps99.js +0 -18
- package/dist/cli/main-s4bb0jav.js +0 -3
- package/dist/cli/main-s9v8k74e.js +0 -3
- package/dist/cli/main-tjvwmscs.js +0 -3
- package/dist/cli/main-tkgarpjj.js +0 -4
- package/dist/cli/main-v8y60bb2.js +0 -3
- package/dist/cli/main-vhrrq337.js +0 -3
- package/dist/cli/main-vp2dfb7s.js +0 -4
- package/dist/cli/main-vqbr22sz.js +0 -8
- package/dist/cli/main-vxnwe5xx.js +0 -18
- package/dist/cli/main-wgph00xf.js +0 -5
- package/dist/cli/main-wk2csfnj.js +0 -5
- package/dist/cli/main-wm997zjx.js +0 -3
- package/dist/cli/main-wpkyraxh.js +0 -3
- package/dist/cli/main-wqt32p5x.js +0 -4
- package/dist/cli/main-x9ct6y1a.js +0 -3
- package/dist/cli/main-xfekqh9m.js +0 -7
- package/dist/cli/main-xt9zc3n6.js +0 -7
- package/dist/cli/main-xx2z3zh5.js +0 -4
- package/dist/cli/main-y5c82rxr.js +0 -3
- package/dist/cli/main-yrjt2sqt.js +0 -14
- package/dist/cli/main-ys6zj3yr.js +0 -3
- package/dist/cli/main-ywbxshqc.js +0 -8
- package/dist/cli/main-z13755t8.js +0 -25
- package/dist/cli/main-zc7pyrbj.js +0 -4
- package/dist/cli/main.js +0 -279
- package/dist/cli/market-cmd-bm5xvn9f.js +0 -5
- package/dist/cli/mcp-login-bthtfpt7.js +0 -2
- package/dist/cli/mcp-market-cmd-mbeshfyd.js +0 -2
- package/dist/cli/notify-54v5z9dz.js +0 -2
- package/dist/cli/oauth-g5gme95c.js +0 -2
- package/dist/cli/output-satndjap.js +0 -16
- package/dist/cli/profiles-sfhpbq3m.js +0 -2
- package/dist/cli/provider-config-hv3xtdt4.js +0 -2
- package/dist/cli/provider-kwzq6g84.js +0 -2
- package/dist/cli/registry-fh0hdnyn.js +0 -2
- package/dist/cli/registry-y1y8e94r.js +0 -2
- package/dist/cli/repl-t4z03mqq.js +0 -11
- package/dist/cli/resume-fqt4chg8.js +0 -2
- package/dist/cli/run-flags-rysbag9t.js +0 -2
- package/dist/cli/runtime-j19fjbsa.js +0 -2
- package/dist/cli/sandbox-config-g4qxd7y5.js +0 -2
- package/dist/cli/server-r0b6bksk.js +0 -5
- package/dist/cli/session-arg-txmn5g4x.js +0 -2
- package/dist/cli/session-ed250d9j.js +0 -2
- package/dist/cli/sessions-cmd-adw7svfn.js +0 -7
- package/dist/cli/settings-y9rzcqx8.js +0 -2
- package/dist/cli/setup-jmbr11j0.js +0 -2
- package/dist/cli/sextant-smoke-tcth0vea.js +0 -5
- package/dist/cli/skills-cmd-zbdy99v6.js +0 -2
- package/dist/cli/smoke-1bg937kx.js +0 -8
- package/dist/cli/start-chat-p01cdks3.js +0 -12
- package/dist/cli/stream-4wmyaypz.js +0 -2
- package/dist/cli/task-eg4s093s.js +0 -2
- package/dist/cli/tasks-12v9rr9k.js +0 -2
- package/dist/cli/thinking-a5ngvqyh.js +0 -2
- package/dist/cli/todo-1wxpcecx.js +0 -2
- package/dist/cli/tools-2ftsya7w.js +0 -2
- package/dist/cli/tools-x1tj4fxm.js +0 -2
- package/dist/cli/trust-cmd-hccxehzb.js +0 -2
- package/dist/cli/update-check-ygt3vd7m.js +0 -2
- package/dist/cli/update-cmd-v23qhr8c.js +0 -2
- package/dist/cli/voice-g1gtck92.js +0 -2
- package/dist/cli/webfetch-0nnrjgb5.js +0 -2
- package/dist/cli/websearch-f0vr2p7d.js +0 -2
- package/dist/cli/workspace-9rq1w4ta.js +0 -2
- package/dist/lib/index.js +0 -62
- package/dist/lib/models-index.json +0 -1
- package/dist/lib/plugins.js +0 -6
- package/dist/lib/providers.js +0 -17
- package/dist/lib/public-api.js +0 -20
- package/dist/rovecode.exe +0 -4
- /package/{dist/cli → src/providers}/models-index.json +0 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/** Live provider registry — the ADAPTER half of the provider layer, over provider-config.ts (data).
|
|
2
|
+
*
|
|
3
|
+
* One registry per runtime. stream() returns a DISPATCHING StreamFn: every call resolves
|
|
4
|
+
* `model.provider` against the live snapshot (hot reload — provider-config.ts), so a provider added
|
|
5
|
+
* from another terminal, by `rovecode provider add`, by `/provider add` in the TUI or by the agent's
|
|
6
|
+
* provider_edit tool serves the very next model call. No restart, no runtime rebuild. Concrete
|
|
7
|
+
* adapters (stream.ts) are cached per provider id and rebuilt when baseUrl, protocol, key or
|
|
8
|
+
* headers change — a rotated key takes effect on the next call too.
|
|
9
|
+
*
|
|
10
|
+
* Seam contract (ADR-003): the dispatcher never throws. An unknown provider or a missing key is ONE
|
|
11
|
+
* error turn whose text starts with `config: ` — router.ts classifyStreamError treats that prefix
|
|
12
|
+
* as non-retryable, so neither the same-model retry (port #23) nor a fallback chain (port #14)
|
|
13
|
+
* spins on a configuration mistake; the text tells the human exactly how to fix it.
|
|
14
|
+
*
|
|
15
|
+
* Because the dispatcher honours model.provider per call, cross-provider fallback chains
|
|
16
|
+
* (ROVECODE_MODEL_<ROLE>=a/x,b/y) now really route each candidate to its own endpoint.
|
|
17
|
+
*
|
|
18
|
+
* Secrets: this module reads keys to build adapters and to redact them from probe errors
|
|
19
|
+
* (scrubSecret). Nothing here prints or returns a key; formatProviderList shows key SOURCES. */
|
|
20
|
+
|
|
21
|
+
import type { AssistantTurn, Message, ModelRef, StreamEvent, StreamFn, StreamOptions } from "../core/types.ts";
|
|
22
|
+
import { fetchModels, providerStream, providerStreaming, wantsStreaming, type ProviderConfig as WireProviderConfig } from "./stream.ts";
|
|
23
|
+
import {
|
|
24
|
+
ProviderConfig, isConfigured, parseSelector, pickDefault, providersPathFor, readProvidersFile, validateSpec, writeProvidersFile,
|
|
25
|
+
type FileScope, type ProviderSnapshot, type ProviderSpec, type ResolvedProvider,
|
|
26
|
+
} from "./provider-config.ts";
|
|
27
|
+
import { next } from "../core/voice.ts";
|
|
28
|
+
|
|
29
|
+
export type AdapterFactory = (p: ResolvedProvider, opts: { sse: boolean }) => StreamFn;
|
|
30
|
+
|
|
31
|
+
/** stream.ts ProviderConfig for a resolved provider (a keyless provider sends an empty bearer). */
|
|
32
|
+
export function toWireConfig(p: ResolvedProvider): WireProviderConfig {
|
|
33
|
+
return {
|
|
34
|
+
id: p.id, baseUrl: p.baseUrl, apiKey: p.apiKey ?? "", protocol: p.protocol,
|
|
35
|
+
...(p.defaultModel !== undefined ? { defaultModel: p.defaultModel } : {}),
|
|
36
|
+
...(p.headers !== undefined ? { headers: p.headers } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const defaultAdapterFactory: AdapterFactory = (p, { sse }) => {
|
|
41
|
+
const wire = toWireConfig(p);
|
|
42
|
+
// both protocols stream (openai SSE chunks, anthropic Messages events); `sse` is off only when
|
|
43
|
+
// ROVECODE_STREAM asked for the one-shot JSON adapters
|
|
44
|
+
return sse ? providerStreaming(wire) : providerStream(wire);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Error-text prefix the router/retry classifier treats as non-retryable (router.ts). */
|
|
48
|
+
export const CONFIG_ERROR_PREFIX = "config: ";
|
|
49
|
+
|
|
50
|
+
export function configErrorTurn(text: string): AssistantTurn {
|
|
51
|
+
return { parts: [], stopReason: "error", usage: { input: 0, output: 0 }, error: CONFIG_ERROR_PREFIX + text };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Replace a secret wherever it leaked into text (some proxies echo request headers in error bodies). */
|
|
55
|
+
export function scrubSecret(text: string, secret: string | null | undefined): string {
|
|
56
|
+
return secret !== null && secret !== undefined && secret.length > 0 ? text.split(secret).join("…") : text;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RegistryOptions {
|
|
60
|
+
env?: NodeJS.ProcessEnv;
|
|
61
|
+
/** test seam: build adapters without touching the network */
|
|
62
|
+
adapterFactory?: AdapterFactory;
|
|
63
|
+
/** provider-config.ts reload throttle (ms); tests pass 0 */
|
|
64
|
+
throttleMs?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class ProviderRegistry {
|
|
68
|
+
readonly cwd: string;
|
|
69
|
+
private readonly env: NodeJS.ProcessEnv;
|
|
70
|
+
private readonly cfg: ProviderConfig;
|
|
71
|
+
private readonly factory: AdapterFactory;
|
|
72
|
+
private readonly adapters = new Map<string, { sig: string; fn: StreamFn }>();
|
|
73
|
+
|
|
74
|
+
constructor(cwd: string, opts: RegistryOptions = {}) {
|
|
75
|
+
this.cwd = cwd;
|
|
76
|
+
this.env = opts.env ?? process.env;
|
|
77
|
+
this.factory = opts.adapterFactory ?? defaultAdapterFactory;
|
|
78
|
+
this.cfg = new ProviderConfig(cwd, this.env, opts.throttleMs);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
snapshot(): ProviderSnapshot { return this.cfg.snapshot(); }
|
|
82
|
+
list(): ResolvedProvider[] { return this.snapshot().providers; }
|
|
83
|
+
ids(): string[] { return this.list().map((p) => p.id); }
|
|
84
|
+
get(id: string): ResolvedProvider | undefined { return this.list().find((p) => p.id === id); }
|
|
85
|
+
/** at least one provider has a key (or needs none) */
|
|
86
|
+
configured(): boolean { return this.list().some(isConfigured); }
|
|
87
|
+
warnings(): string[] { return this.snapshot().warnings; }
|
|
88
|
+
/** fires after every rebuild: external file edits (detected lazily) and in-process add/remove/setDefault */
|
|
89
|
+
onChange(cb: (snap: ProviderSnapshot) => void): () => void { return this.cfg.onChange(cb); }
|
|
90
|
+
/** re-read now (in-process writers call it right after writing) and notify listeners */
|
|
91
|
+
refresh(): ProviderSnapshot { this.cfg.invalidate(); return this.cfg.snapshot(); }
|
|
92
|
+
|
|
93
|
+
/** default provider + model with env ROVECODE_MODEL layered on top; null when nothing is configured */
|
|
94
|
+
defaultRef(): ModelRef | null {
|
|
95
|
+
const d = pickDefault(this.snapshot());
|
|
96
|
+
if (d === null) return null;
|
|
97
|
+
return { provider: d.provider.id, model: this.env["ROVECODE_MODEL"] ?? d.model ?? "" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** stream.ts-shaped config of the default provider (Runtime.provider compatibility) */
|
|
101
|
+
defaultConfig(): WireProviderConfig | null {
|
|
102
|
+
const d = pickDefault(this.snapshot());
|
|
103
|
+
if (d === null) return null;
|
|
104
|
+
return { ...toWireConfig(d.provider), ...(d.model !== undefined ? { defaultModel: d.model } : {}) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
defaultSelector(): string | undefined { return this.snapshot().defaultSelector; }
|
|
108
|
+
|
|
109
|
+
/** "provider/model" or a bare model on `currentProvider`. A leading segment counts as a provider
|
|
110
|
+
* only when it names one, so "zai-org/glm-5.3" stays a model id on the current provider. The
|
|
111
|
+
* current provider is not validated (it may be "mock" or an injected test stream): a bare model
|
|
112
|
+
* always lands there, exactly as `/model <id>` always did. */
|
|
113
|
+
resolveSelector(selector: string, currentProvider: string): ModelRef | { error: string } {
|
|
114
|
+
const s = selector.trim();
|
|
115
|
+
if (s.length === 0) return { error: "empty model selector" };
|
|
116
|
+
const i = s.indexOf("/");
|
|
117
|
+
if (i > 0) {
|
|
118
|
+
const head = s.slice(0, i);
|
|
119
|
+
const tail = s.slice(i + 1).trim();
|
|
120
|
+
if (this.get(head) !== undefined) return tail.length > 0 ? { provider: head, model: tail } : { error: `"${s}" names no model — use ${head}/<model>` };
|
|
121
|
+
}
|
|
122
|
+
return { provider: currentProvider, model: s };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
add(spec: ProviderSpec, scope: FileScope = "user"): ResolvedProvider | { error: string } {
|
|
126
|
+
const { id, ...raw } = spec;
|
|
127
|
+
const v = validateSpec(id, raw);
|
|
128
|
+
if ("error" in v) return v;
|
|
129
|
+
if (this.get(id)?.scope === "env") return { error: `"custom" is reserved for the ROVECODE_BASE_URL/ROVECODE_API_KEY pair — pick another id` };
|
|
130
|
+
const path = providersPathFor(scope, this.cwd);
|
|
131
|
+
const { data } = readProvidersFile(path);
|
|
132
|
+
const { id: _id, ...rest } = v.spec;
|
|
133
|
+
data.providers = { ...(data.providers ?? {}), [id]: rest };
|
|
134
|
+
writeProvidersFile(path, data);
|
|
135
|
+
this.refresh();
|
|
136
|
+
return this.get(id)!;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
remove(id: string): { removed: FileScope[] } | { error: string } {
|
|
140
|
+
const p = this.get(id);
|
|
141
|
+
if (p === undefined) return { error: `unknown provider "${id}" — known: ${this.ids().join(" ")}` };
|
|
142
|
+
if (p.scope === "builtin") return { error: `"${id}" is built in; drop its key with \`rovecode auth remove ${id}\` instead` };
|
|
143
|
+
if (p.scope === "env") return { error: `"custom" comes from ROVECODE_BASE_URL/ROVECODE_API_KEY — unset those env vars` };
|
|
144
|
+
const removed: FileScope[] = [];
|
|
145
|
+
for (const scope of ["user", "project"] as const) {
|
|
146
|
+
const path = providersPathFor(scope, this.cwd);
|
|
147
|
+
const { data } = readProvidersFile(path);
|
|
148
|
+
if (data.providers !== undefined && id in data.providers) {
|
|
149
|
+
delete data.providers[id];
|
|
150
|
+
if (Object.keys(data.providers).length === 0) delete data.providers;
|
|
151
|
+
writeProvidersFile(path, data);
|
|
152
|
+
removed.push(scope);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
this.refresh();
|
|
156
|
+
return { removed };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
setDefault(selector: string, scope: FileScope = "user"): ModelRef | { error: string } {
|
|
160
|
+
const sel = parseSelector(selector);
|
|
161
|
+
const p = this.get(sel.provider);
|
|
162
|
+
if (p === undefined) return { error: `unknown provider "${sel.provider}" — known: ${this.ids().join(" ")}` };
|
|
163
|
+
const model = sel.model ?? p.defaultModel;
|
|
164
|
+
if (model === undefined) return { error: `"${selector}" names no model and ${p.id} has no default — use ${p.id}/<model>` };
|
|
165
|
+
const path = providersPathFor(scope, this.cwd);
|
|
166
|
+
const { data } = readProvidersFile(path);
|
|
167
|
+
data.default = `${p.id}/${model}`;
|
|
168
|
+
writeProvidersFile(path, data);
|
|
169
|
+
this.refresh();
|
|
170
|
+
return { provider: p.id, model };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
keyHint(p: ResolvedProvider): string {
|
|
174
|
+
return `no API key for provider "${p.id}" — I can't call it without one. ${next(`rovecode auth set ${p.id}`)} (masked prompt) · or /provider key ${p.id} <key> in the TUI · or set ${p.keyEnv}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async models(id: string): Promise<{ ok: true; models: string[]; source: "file" | "endpoint" } | { ok: false; error: string }> {
|
|
178
|
+
const p = this.get(id);
|
|
179
|
+
if (p === undefined) return { ok: false, error: `unknown provider "${id}" — known: ${this.ids().join(" ")}` };
|
|
180
|
+
if (p.models !== undefined && p.models.length > 0) return { ok: true, models: p.models, source: "file" };
|
|
181
|
+
if (!isConfigured(p)) return { ok: false, error: this.keyHint(p) };
|
|
182
|
+
const list = await fetchModels(toWireConfig(p), true);
|
|
183
|
+
return { ok: true, models: list.map((m) => m.id), source: "endpoint" };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One tiny real call ("ping", ≤8 output tokens): proves url + key + model together. */
|
|
187
|
+
async probe(id: string, model?: string): Promise<{ ok: boolean; model: string; detail: string }> {
|
|
188
|
+
const p = this.get(id);
|
|
189
|
+
if (p === undefined) return { ok: false, model: model ?? "", detail: `unknown provider "${id}" — known: ${this.ids().join(" ")}` };
|
|
190
|
+
if (!isConfigured(p)) return { ok: false, model: model ?? "", detail: this.keyHint(p) };
|
|
191
|
+
const m = model ?? p.defaultModel;
|
|
192
|
+
if (m === undefined) return { ok: false, model: "", detail: `${p.id} has no default model — pass one: test ${p.id} <model>` };
|
|
193
|
+
const probeMsg: Message = { id: "probe", role: "user", parts: [{ kind: "text", text: "ping" }], parentId: null, createdAt: Date.now() };
|
|
194
|
+
const t0 = Date.now();
|
|
195
|
+
let turn: AssistantTurn | undefined;
|
|
196
|
+
try {
|
|
197
|
+
for await (const ev of this.adapterFor(p)({ provider: p.id, model: m, maxTokens: 8 }, [probeMsg], { tools: [] })) if (ev.type === "turn") turn = ev.turn;
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return { ok: false, model: m, detail: scrubSecret(e instanceof Error ? e.message : String(e), p.apiKey) };
|
|
200
|
+
}
|
|
201
|
+
if (turn === undefined) return { ok: false, model: m, detail: "stream produced no turn" };
|
|
202
|
+
if (turn.stopReason === "error") return { ok: false, model: m, detail: scrubSecret(turn.error ?? "stream error", p.apiKey) };
|
|
203
|
+
return { ok: true, model: m, detail: `ok in ${Date.now() - t0} ms (${turn.usage.input} in / ${turn.usage.output} out tokens)` };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The dispatching StreamFn (see the header). */
|
|
207
|
+
stream(): StreamFn {
|
|
208
|
+
const self = this;
|
|
209
|
+
return async function* (model: ModelRef, messages: Message[], options?: StreamOptions): AsyncGenerator<StreamEvent> {
|
|
210
|
+
const p = self.get(model.provider);
|
|
211
|
+
if (p === undefined) {
|
|
212
|
+
yield { type: "turn", turn: configErrorTurn(`provider "${model.provider}" is not configured — I can't route to it. ${next(`rovecode provider add ${model.provider} <baseUrl> [--protocol openai|anthropic]`)} (or /provider add in the TUI); known: ${self.ids().join(" ")}`) };
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (!isConfigured(p)) { yield { type: "turn", turn: configErrorTurn(self.keyHint(p)) }; return; }
|
|
216
|
+
yield* self.adapterFor(p)(model, messages, options);
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private adapterFor(p: ResolvedProvider): StreamFn {
|
|
221
|
+
const sse = wantsStreaming({ ROVECODE_STREAM: this.env["ROVECODE_STREAM"] }); // default ON
|
|
222
|
+
const sig = [p.baseUrl, p.protocol, p.apiKey ?? "", JSON.stringify(p.headers ?? {}), sse ? "sse" : "json"].join("");
|
|
223
|
+
const hit = this.adapters.get(p.id);
|
|
224
|
+
if (hit !== undefined && hit.sig === sig) return hit.fn;
|
|
225
|
+
const fn = this.factory(p, { sse });
|
|
226
|
+
this.adapters.set(p.id, { sig, fn });
|
|
227
|
+
return fn;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------- shared CLI / TUI helpers ----------
|
|
232
|
+
|
|
233
|
+
export const ADD_USAGE = "add <id> <baseUrl> [--protocol openai|anthropic] [--key-env NAME] [--model <id>] [--no-key] [--project]";
|
|
234
|
+
|
|
235
|
+
export interface AddArgs { spec: ProviderSpec; scope: FileScope; promptKey: boolean }
|
|
236
|
+
|
|
237
|
+
/** Parse `add` words (CLI argv tail or TUI slash args) into a validated spec + scope. `--key` (CLI:
|
|
238
|
+
* prompt for the secret afterwards) is recorded, never a value. Pure; never throws. */
|
|
239
|
+
export function parseAddArgs(words: readonly string[]): AddArgs | { error: string } {
|
|
240
|
+
const pos: string[] = [];
|
|
241
|
+
const raw: Record<string, unknown> = {};
|
|
242
|
+
let scope: FileScope = "user";
|
|
243
|
+
let promptKey = false;
|
|
244
|
+
for (let i = 0; i < words.length; i++) {
|
|
245
|
+
const w = words[i]!;
|
|
246
|
+
const value = (): string | { error: string } => {
|
|
247
|
+
const v = words[i + 1];
|
|
248
|
+
if (v === undefined || v.startsWith("-")) return { error: `${w} needs a value — ${ADD_USAGE}` };
|
|
249
|
+
i++;
|
|
250
|
+
return v;
|
|
251
|
+
};
|
|
252
|
+
switch (w) {
|
|
253
|
+
case "--protocol": { const v = value(); if (typeof v !== "string") return v; raw["protocol"] = v; break; }
|
|
254
|
+
case "--key-env": { const v = value(); if (typeof v !== "string") return v; raw["keyEnv"] = v; break; }
|
|
255
|
+
case "--model": { const v = value(); if (typeof v !== "string") return v; raw["defaultModel"] = v; break; }
|
|
256
|
+
case "--scope": {
|
|
257
|
+
const v = value(); if (typeof v !== "string") return v;
|
|
258
|
+
if (v !== "user" && v !== "project") return { error: "--scope must be user or project" };
|
|
259
|
+
scope = v; break;
|
|
260
|
+
}
|
|
261
|
+
case "--project": scope = "project"; break;
|
|
262
|
+
case "--user": scope = "user"; break;
|
|
263
|
+
case "--no-key": raw["noKey"] = true; break;
|
|
264
|
+
case "--key": promptKey = true; break;
|
|
265
|
+
default:
|
|
266
|
+
if (w.startsWith("-")) return { error: `unknown flag ${w} — ${ADD_USAGE}` };
|
|
267
|
+
pos.push(w);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const [id, baseUrl] = pos;
|
|
271
|
+
if (id === undefined || baseUrl === undefined) return { error: `usage: ${ADD_USAGE}` };
|
|
272
|
+
raw["baseUrl"] = baseUrl;
|
|
273
|
+
const v = validateSpec(id, raw);
|
|
274
|
+
if ("error" in v) return v;
|
|
275
|
+
return { spec: v.spec, scope, promptKey };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** One row per provider — key SOURCE only, never the value. */
|
|
279
|
+
export function formatProviderLine(p: ResolvedProvider): string {
|
|
280
|
+
const key = p.keySource === "stored" ? "key: stored"
|
|
281
|
+
: p.keySource === "env" ? `key: env ${p.keyEnv}`
|
|
282
|
+
: p.keySource === "inline" ? "key: ROVECODE_API_KEY"
|
|
283
|
+
: p.noKey === true ? "key: not needed"
|
|
284
|
+
: `key: NONE (rovecode auth set ${p.id} · or set ${p.keyEnv})`;
|
|
285
|
+
return `${p.id.padEnd(12)} ${p.protocol.padEnd(9)} ${p.baseUrl.padEnd(40)} ${key}${p.defaultModel !== undefined ? ` model: ${p.defaultModel}` : ""} [${p.scope}]`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Listing for `rovecode provider list`, `/provider list` and the provider_list tool. Built-in providers
|
|
289
|
+
* without a key are folded into a count unless `all` — sixteen dead rows hide the live ones. */
|
|
290
|
+
export function formatProviderList(reg: ProviderRegistry, opts: { all?: boolean } = {}): string {
|
|
291
|
+
const d = reg.defaultRef();
|
|
292
|
+
const all = reg.list();
|
|
293
|
+
const rows = all.filter((p) => opts.all === true || isConfigured(p) || p.scope !== "builtin");
|
|
294
|
+
const lines = [d !== null
|
|
295
|
+
? `default → ${d.provider}/${d.model || "(no model — pass one with /model or ROVECODE_MODEL)"}${reg.defaultSelector() !== undefined ? ` (providers.json default: ${reg.defaultSelector()})` : ""}`
|
|
296
|
+
: `default → none yet. ${next("rovecode setup")} (or rovecode provider add <id> <baseUrl>, then rovecode auth set <id>)`];
|
|
297
|
+
lines.push(...rows.map(formatProviderLine));
|
|
298
|
+
const hidden = all.length - rows.length;
|
|
299
|
+
if (hidden > 0) lines.push(`(+${hidden} built-in providers without a key — \`list --all\` shows them)`);
|
|
300
|
+
lines.push(...reg.warnings().map((w) => `warning: ${w}`));
|
|
301
|
+
return lines.join("\n");
|
|
302
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Fail closed on malformed provider envelopes and tool calls, before any side effect. */
|
|
2
|
+
import type { AssistantTurn, StopReason } from "../core/types.ts";
|
|
3
|
+
|
|
4
|
+
export function rejectProviderError(json: unknown): void {
|
|
5
|
+
if (json === null || typeof json !== "object") throw new Error("invalid provider response: expected an object");
|
|
6
|
+
const error = (json as { error?: unknown }).error;
|
|
7
|
+
if (error !== undefined) {
|
|
8
|
+
const message = typeof error === "object" && error !== null ? (error as { message?: unknown }).message : error;
|
|
9
|
+
throw new Error(`provider error: ${typeof message === "string" ? message : JSON.stringify(error)}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function openAiStop(reason: string | null | undefined): StopReason {
|
|
13
|
+
if (reason === "length") return "length";
|
|
14
|
+
if (reason === "tool_calls") return "tool_use";
|
|
15
|
+
if (reason === "stop") return "end_turn";
|
|
16
|
+
throw new Error(`OpenAI response has invalid or missing finish reason: ${reason ?? "missing"}`);
|
|
17
|
+
}
|
|
18
|
+
export function anthropicStop(reason: string | null | undefined): StopReason {
|
|
19
|
+
if (reason === "max_tokens") return "length";
|
|
20
|
+
if (reason === "tool_use") return "tool_use";
|
|
21
|
+
if (reason === "end_turn" || reason === "stop_sequence") return "end_turn";
|
|
22
|
+
throw new Error(`Anthropic response has invalid or missing finish reason: ${reason ?? "missing"}`);
|
|
23
|
+
}
|
|
24
|
+
/** The leading balanced {...} of `s`, or null — bracket/brace counting that respects strings and
|
|
25
|
+
* escapes. Only ever called on arguments that FAILED JSON.parse, to test one specific pathology. */
|
|
26
|
+
function leadingJsonObject(s: string): string | null {
|
|
27
|
+
const start = s.indexOf("{");
|
|
28
|
+
if (start < 0) return null;
|
|
29
|
+
let depth = 0, inStr = false, esc = false;
|
|
30
|
+
for (let i = start; i < s.length; i++) {
|
|
31
|
+
const ch = s[i]!;
|
|
32
|
+
if (inStr) {
|
|
33
|
+
if (esc) esc = false;
|
|
34
|
+
else if (ch === "\\") esc = true;
|
|
35
|
+
else if (ch === '"') inStr = false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (ch === '"') inStr = true;
|
|
39
|
+
else if (ch === "{") depth++;
|
|
40
|
+
else if (ch === "}") { depth--; if (depth === 0) return s.slice(0, i + 1); }
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseToolArgs(raw: string, truncated: boolean): unknown {
|
|
46
|
+
if (typeof raw !== "string") throw new Error("invalid tool arguments: expected JSON string");
|
|
47
|
+
try { return JSON.parse(raw || "{}"); }
|
|
48
|
+
catch {
|
|
49
|
+
// A proxy family REPEATS the whole arguments object in every SSE delta instead of streaming
|
|
50
|
+
// fragments (the same bug that repeats the tool NAME — stream.ts absorbs that half), so the
|
|
51
|
+
// accumulator holds the identical JSON two or more times: "{…}{…}". An EXACT repeat of a
|
|
52
|
+
// parseable leading object is that pathology and nothing a model can legitimately produce —
|
|
53
|
+
// rescue the first copy instead of failing the whole turn.
|
|
54
|
+
const first = leadingJsonObject(raw);
|
|
55
|
+
if (first !== null) {
|
|
56
|
+
const rest = raw.slice(first.length);
|
|
57
|
+
if (rest.length > 0 && rest.length % first.length === 0 && rest === first.repeat(rest.length / first.length)) {
|
|
58
|
+
try { return JSON.parse(first); } catch { /* not the pathology after all — fall through */ }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Length-limited calls never execute; retain fragments for the loop's failed result.
|
|
62
|
+
if (truncated) return { _raw: raw };
|
|
63
|
+
throw new Error("invalid tool arguments: expected complete JSON object");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function validateToolCalls(parts: AssistantTurn["parts"], stop: StopReason): void {
|
|
67
|
+
const ids = new Set<string>();
|
|
68
|
+
for (const p of parts) {
|
|
69
|
+
if (p.kind !== "tool_call") continue;
|
|
70
|
+
if (typeof p.id !== "string" || !p.id.trim() || typeof p.tool !== "string" || !p.tool.trim()) {
|
|
71
|
+
throw new Error("invalid tool call: missing id or name");
|
|
72
|
+
}
|
|
73
|
+
if (ids.has(p.id)) throw new Error(`invalid tool call: duplicate id ${p.id}`);
|
|
74
|
+
ids.add(p.id);
|
|
75
|
+
if (stop !== "length" && (p.args === null || typeof p.args !== "object" || Array.isArray(p.args))) {
|
|
76
|
+
throw new Error("invalid tool arguments: expected JSON object");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (stop === "tool_use" && ids.size === 0) throw new Error("provider requested tool use without any tool calls");
|
|
80
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/** Same-model retry with exponential backoff (port #23). A StreamFn wrapper — NO second agent
|
|
2
|
+
* loop (ADR-003): bounded re-invocations of the SAME (model, messages, options) inside one
|
|
3
|
+
* stream invocation, the way the router makes one bounded pass over chain candidates. Wired
|
|
4
|
+
* INSIDE the router (`router.wrap(withRetry(stream))`, cli/runtime.ts) so retries exhaust on
|
|
5
|
+
* candidate N before the chain advances to N+1, and the router's notes stay one-per-ADVANCE.
|
|
6
|
+
*
|
|
7
|
+
* Source: gemini-cli (Apache-2.0 @0bd1d43 — research/source_snapshots/google-gemini-gemini-cli,
|
|
8
|
+
* packages/core/src/utils):
|
|
9
|
+
* - retryWithBackoff loop shape: attempt counter vs maxAttempts, delay doubling up to
|
|
10
|
+
* maxDelayMs (retry.ts:296-310, :494-501, :517-524); defaults 10 attempts / 5s initial /
|
|
11
|
+
* 30s max (retry.ts:20, :42-47).
|
|
12
|
+
* - Retryable set: 429 and 5xx; "Explicitly do not retry 400" (retry.ts:193-199); transport
|
|
13
|
+
* failures without a status are retryable (retry.ts:49-62, :174-189). Shared with the router
|
|
14
|
+
* through classifyStreamError (router.ts) — one classifier, two consumers (gemini-cli's 499
|
|
15
|
+
* is excluded there, documented deviation).
|
|
16
|
+
* - Aborts pass through untouched (retry.ts:337-340); the backoff sleep itself is abortable
|
|
17
|
+
* (delay.ts:22-48: timeout + abort listener, both cleared on settle).
|
|
18
|
+
* - A server-suggested delay is a FLOOR on the next sleep (retry.ts:472-476
|
|
19
|
+
* `Math.max(currentDelay, retryDelayMs)`); a suggestion beyond the cap is terminal — no wait,
|
|
20
|
+
* immediate fallback (googleQuotaErrors.ts:120 MAX_RETRYABLE_DELAY_SECONDS, :286-289). Here
|
|
21
|
+
* the suggestion is the HTTP Retry-After header (RFC 9110 §10.2.3: delay-seconds or
|
|
22
|
+
* HTTP-date), OpenAI's retry-after-ms, or Anthropic's ratelimit-reset timestamps, all recorded
|
|
23
|
+
* by stream-errors.ts; the cap is the per-invocation total budget and the run's own deadline.
|
|
24
|
+
* - Deviations: FULL jitter — delay = U[0,1) × min(max, base·2ⁿ) (AWS "Exponential Backoff And
|
|
25
|
+
* Jitter") instead of gemini-cli's ±30% around the current delay (retry.ts:494-495) or +20%
|
|
26
|
+
* over the server floor (:478): fewer synchronized retries, and the schedule is pinnable with
|
|
27
|
+
* an injected random. A total wall-clock cap per invocation (gemini-cli has none): with a
|
|
28
|
+
* fallback chain every candidate pays the retry budget, so it is kept short. No content-based
|
|
29
|
+
* retry (:314-328), no quota classification / fallback dialog (:342-420) — the router owns
|
|
30
|
+
* model fallback. A thrown inner stream is folded into an error turn (never-throw) but NOT
|
|
31
|
+
* retried: a seam-contract violation is a harness bug, not a provider outcome.
|
|
32
|
+
* - Per-invocation state only: attempt counter and deadline live inside one generator run;
|
|
33
|
+
* nothing is remembered across calls (unlike the router's sticky switch).
|
|
34
|
+
* - IDEMPOTENCY (2026-09-04): a retry is taken only while NOTHING has been streamed to the consumer.
|
|
35
|
+
* Once a text or reasoning delta has gone out, a failure ends the turn — with the partial text kept as
|
|
36
|
+
* the turn's parts and an error that says so — because a re-drive would print a second answer under
|
|
37
|
+
* the first (the router applies the same rule to its chain advance). Deltas from a failed attempt that
|
|
38
|
+
* streamed nothing cannot exist, so the "pass through live" rule and this one never meet.
|
|
39
|
+
*
|
|
40
|
+
* Env (retryOptionsFromEnv, read once by cli/runtime.ts):
|
|
41
|
+
* - ROVECODE_RETRY_MAX retries after the first attempt; 0 disables. Default 3 (→ 4 attempts;
|
|
42
|
+
* upstream 10 attempts — a fallback chain multiplies attempts per candidate).
|
|
43
|
+
* - ROVECODE_RETRY_BASE_MS cap of the first backoff in ms. Default 1000 (upstream 5000; full jitter
|
|
44
|
+
* halves the expected wait — ~0.5 s, 1 s, 2 s, 4 s expected).
|
|
45
|
+
* Fixed: max backoff 20 s, total budget 60 s per invocation, and never past StreamOptions.deadlineAt
|
|
46
|
+
* (the run's --max-seconds clock). */
|
|
47
|
+
|
|
48
|
+
import type { AssistantTurn, ModelRef, StreamEvent, StreamFn } from "../core/types.ts";
|
|
49
|
+
import { classifyStreamError } from "./router.ts";
|
|
50
|
+
import { httpErrorMeta, providerMessage } from "./stream-errors.ts";
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_MAX_RETRIES = 3;
|
|
53
|
+
export const DEFAULT_BASE_MS = 1_000;
|
|
54
|
+
export const DEFAULT_MAX_DELAY_MS = 20_000;
|
|
55
|
+
export const DEFAULT_TOTAL_MS = 60_000;
|
|
56
|
+
|
|
57
|
+
export interface RetryNote {
|
|
58
|
+
model: ModelRef;
|
|
59
|
+
/** Attempts made so far (the one that just failed); the retry about to happen is attempt+1. */
|
|
60
|
+
attempt: number;
|
|
61
|
+
/** attempts the policy allows in total (maxRetries + 1) — for "(2/4)" */
|
|
62
|
+
maxAttempts: number;
|
|
63
|
+
delayMs: number;
|
|
64
|
+
/** The failed turn's error text, e.g. "HTTP 429: ...". */
|
|
65
|
+
reason: string;
|
|
66
|
+
/** the HTTP status when the failure was a response; undefined for a transport failure */
|
|
67
|
+
status?: number;
|
|
68
|
+
/** Parsed server wait hint (Retry-After / retry-after-ms / anthropic-ratelimit-*-reset) when the error turn carried one. */
|
|
69
|
+
retryAfterMs?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** why the wrapper stopped retrying a retryable failure */
|
|
73
|
+
export type GiveUpWhy = "attempts" | "budget" | "deadline";
|
|
74
|
+
export interface GiveUpNote extends Omit<RetryNote, "delayMs"> { why: GiveUpWhy; delayMs?: number }
|
|
75
|
+
|
|
76
|
+
export interface RetryOptions {
|
|
77
|
+
/** Retries after the first attempt (ROVECODE_RETRY_MAX). 0 = never retry. */
|
|
78
|
+
maxRetries?: number;
|
|
79
|
+
/** Cap of the first backoff, doubling per attempt (ROVECODE_RETRY_BASE_MS). */
|
|
80
|
+
baseMs?: number;
|
|
81
|
+
/** Ceiling for the doubling backoff term. */
|
|
82
|
+
maxDelayMs?: number;
|
|
83
|
+
/** Wall-clock budget per invocation incl. sleeps; a wait that would end past it is not taken. */
|
|
84
|
+
totalMs?: number;
|
|
85
|
+
/** Retry visibility — a note-style callback, not a StreamEvent (grammar is shared/untouchable). */
|
|
86
|
+
onRetry?: (note: RetryNote) => void;
|
|
87
|
+
/** the last word: a retryable failure that will not be retried (attempts, budget or the run's deadline) */
|
|
88
|
+
onGiveUp?: (note: GiveUpNote) => void;
|
|
89
|
+
/** Test seams: abortable sleep, jitter source, clock (epoch ms — also anchors HTTP-date). */
|
|
90
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
91
|
+
random?: () => number;
|
|
92
|
+
now?: () => number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Abortable sleep (delay.ts:22-48 shape) on a REF'D setTimeout — Bun unrefs AbortSignal.timeout
|
|
96
|
+
* timers, so a sleep built on one could not hold the process/test runner — plus an abort
|
|
97
|
+
* listener; whichever settles first clears the other. Resolves (never rejects) on abort: the
|
|
98
|
+
* caller re-checks signal.aborted. */
|
|
99
|
+
export function sleepMs(ms: number, signal?: AbortSignal): Promise<void> {
|
|
100
|
+
return new Promise<void>((resolve) => {
|
|
101
|
+
if (signal?.aborted) { resolve(); return; }
|
|
102
|
+
const settle = (): void => { clearTimeout(timer); signal?.removeEventListener("abort", settle); resolve(); };
|
|
103
|
+
const timer = setTimeout(settle, ms);
|
|
104
|
+
signal?.addEventListener("abort", settle, { once: true });
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** RFC 9110 §10.2.3 Retry-After: delay-seconds (non-negative; a fractional value is tolerated)
|
|
109
|
+
* or an HTTP-date, converted to ms from `now` (a past date → 0). Unparseable → undefined. */
|
|
110
|
+
export function parseRetryAfter(value: string | undefined, now: number): number | undefined {
|
|
111
|
+
const v = value?.trim();
|
|
112
|
+
if (!v) return undefined;
|
|
113
|
+
if (/^\d+(?:\.\d+)?$/.test(v)) return Math.round(Number(v) * 1000);
|
|
114
|
+
const at = Date.parse(v);
|
|
115
|
+
return Number.isNaN(at) ? undefined : Math.max(0, at - now);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** the server's wait hint for a failed turn, whichever header it used: the largest of Retry-After,
|
|
119
|
+
* retry-after-ms and the Anthropic ratelimit reset timestamps (RFC 3339) — the longest wait is the
|
|
120
|
+
* one that will actually clear the limit */
|
|
121
|
+
export function serverWaitMs(turn: AssistantTurn, now: number): number | undefined {
|
|
122
|
+
const meta = httpErrorMeta(turn);
|
|
123
|
+
if (!meta) return undefined;
|
|
124
|
+
const hints: number[] = [];
|
|
125
|
+
const ra = parseRetryAfter(meta.retryAfter, now);
|
|
126
|
+
if (ra !== undefined) hints.push(ra);
|
|
127
|
+
if (meta.retryAfterMs !== undefined && /^\d+(?:\.\d+)?$/.test(meta.retryAfterMs.trim())) hints.push(Math.round(Number(meta.retryAfterMs)));
|
|
128
|
+
if (meta.resetAt !== undefined) { const at = Date.parse(meta.resetAt); if (!Number.isNaN(at)) hints.push(Math.max(0, at - now)); }
|
|
129
|
+
return hints.length ? Math.max(...hints) : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Env knobs (header). Blank/invalid/out-of-range values fall back to the defaults;
|
|
133
|
+
* ROVECODE_RETRY_MAX=0 is honored (retry off). */
|
|
134
|
+
export function retryOptionsFromEnv(env: Record<string, string | undefined> = process.env): RetryOptions {
|
|
135
|
+
return { maxRetries: envInt(env.ROVECODE_RETRY_MAX, DEFAULT_MAX_RETRIES, 0), baseMs: envInt(env.ROVECODE_RETRY_BASE_MS, DEFAULT_BASE_MS, 1) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function envInt(raw: string | undefined, dflt: number, min: number): number {
|
|
139
|
+
if (raw === undefined || raw.trim() === "") return dflt;
|
|
140
|
+
const n = Number(raw);
|
|
141
|
+
return Number.isFinite(n) && n >= min ? Math.floor(n) : dflt;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const errorTurn = (error: string): AssistantTurn => ({ parts: [], stopReason: "error", usage: { input: 0, output: 0 }, error });
|
|
145
|
+
|
|
146
|
+
// ---------- the human's words ----------
|
|
147
|
+
|
|
148
|
+
/** one noun phrase per failure class — what the notice and the give-up line lead with */
|
|
149
|
+
export function failureWord(status: number | undefined, error: string): string {
|
|
150
|
+
if (status === 429) return "rate limited";
|
|
151
|
+
if (status === 529 || status === 503) return "overloaded";
|
|
152
|
+
if (status !== undefined && status >= 500) return `server error (HTTP ${status})`;
|
|
153
|
+
if (status !== undefined) return `HTTP ${status}`;
|
|
154
|
+
if (/^no response from /.test(error)) return "no response";
|
|
155
|
+
return "connection failed";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const fmtSeconds = (ms: number): string => { const s = ms / 1000; return `${s >= 10 ? Math.round(s) : Math.round(s * 10) / 10} s`; };
|
|
159
|
+
|
|
160
|
+
/** "anthropic: overloaded — retrying in 4 s (2/4)" */
|
|
161
|
+
export function describeRetry(n: RetryNote): string {
|
|
162
|
+
return `${n.model.provider}: ${failureWord(n.status, n.reason)} — retrying in ${fmtSeconds(n.delayMs)} (${n.attempt + 1}/${n.maxAttempts})`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** "anthropic: overloaded (HTTP 529) — gave up after 4 attempts: Overloaded" */
|
|
166
|
+
export function describeGiveUp(n: GiveUpNote): string {
|
|
167
|
+
const why = n.why === "attempts" ? `gave up after ${n.attempt} attempt${n.attempt === 1 ? "" : "s"}`
|
|
168
|
+
: n.why === "deadline" ? `not retried: the run's time limit is closer than the ${fmtSeconds(n.delayMs ?? 0)} wait`
|
|
169
|
+
: `not retried: the ${fmtSeconds(n.delayMs ?? 0)} wait would pass the retry budget`;
|
|
170
|
+
const status = n.status !== undefined && !/HTTP/.test(failureWord(n.status, n.reason)) ? ` (HTTP ${n.status})` : "";
|
|
171
|
+
const msg = providerMessage(n.reason);
|
|
172
|
+
return `${n.model.provider}: ${failureWord(n.status, n.reason)}${status} — ${why}${msg ? `: ${msg}` : ""}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Wrap a StreamFn: a retryable failed turn (429 / 5xx / transport, never 400, never abort, never after
|
|
176
|
+
* a delta went out) is re-driven against the SAME model with the same args after an abortable
|
|
177
|
+
* full-jitter backoff, until it succeeds, a non-retryable outcome lands, or a cap (attempts / total
|
|
178
|
+
* budget / the run's deadline) stops it — then the LAST turn is yielded untouched so the router sees
|
|
179
|
+
* the genuine provider error. Never throws (ADR-003). */
|
|
180
|
+
export function withRetry(inner: StreamFn, opts: RetryOptions = {}): StreamFn {
|
|
181
|
+
const maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
182
|
+
const baseMs = opts.baseMs ?? DEFAULT_BASE_MS;
|
|
183
|
+
const maxDelayMs = opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
184
|
+
const totalMs = opts.totalMs ?? DEFAULT_TOTAL_MS;
|
|
185
|
+
const sleep = opts.sleep ?? sleepMs;
|
|
186
|
+
const random = opts.random ?? Math.random;
|
|
187
|
+
const now = opts.now ?? Date.now;
|
|
188
|
+
return async function* (model, messages, options): AsyncGenerator<StreamEvent> {
|
|
189
|
+
const startedAt = now();
|
|
190
|
+
for (let attempt = 1; ; attempt++) {
|
|
191
|
+
let turn: AssistantTurn | null = null;
|
|
192
|
+
let streamed = ""; // every text delta this attempt let through — the answer the consumer has already seen
|
|
193
|
+
try {
|
|
194
|
+
for await (const ev of inner(model, messages, options)) {
|
|
195
|
+
if (ev.type === "turn") turn = ev.turn;
|
|
196
|
+
else { if (ev.type === "text_delta") streamed += ev.text; else if (ev.type === "reasoning_delta") streamed ||= " "; yield ev; } // deltas pass through live (header)
|
|
197
|
+
}
|
|
198
|
+
} catch (e) {
|
|
199
|
+
yield { type: "turn", turn: errorTurn(e instanceof Error ? e.message : String(e)) }; // folded, not retried (header)
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (turn === null) { yield { type: "turn", turn: errorTurn("stream ended without a terminal turn") }; return; }
|
|
203
|
+
const meta = httpErrorMeta(turn);
|
|
204
|
+
const status = meta?.status ?? classifyStreamError(turn.error).status;
|
|
205
|
+
// retry.ts:337-340: aborts never retry; 400/4xx-non-429 and non-error turns stand as they are
|
|
206
|
+
if (turn.stopReason !== "error" || options?.signal?.aborted === true || !classifyStreamError(turn.error).retryable) {
|
|
207
|
+
yield { type: "turn", turn };
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (streamed.length > 0) {
|
|
211
|
+
// idempotency (header): part of the answer is on the screen — end the turn, keep that text as the
|
|
212
|
+
// turn's parts (the loop stores it and the summary shows it above the error), say why no retry
|
|
213
|
+
const kept = streamed.trim().length > 0 && turn.parts.length === 0 ? [{ kind: "text" as const, text: streamed }] : turn.parts;
|
|
214
|
+
yield { type: "turn", turn: { ...turn, parts: kept, error: `${turn.error ?? "provider stream failed"} — the connection dropped after part of the answer had arrived; not retried, a retry would repeat it` } };
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const note = { model, attempt, maxAttempts: maxRetries + 1, reason: turn.error ?? "error", ...(status !== undefined ? { status } : {}) };
|
|
218
|
+
// retries off (ROVECODE_RETRY_MAX=0): nothing was ever going to be retried, so there is no giving up to announce
|
|
219
|
+
if (attempt > maxRetries) { if (maxRetries > 0) opts.onGiveUp?.({ ...note, why: "attempts" }); yield { type: "turn", turn }; return; }
|
|
220
|
+
const retryAfterMs = serverWaitMs(turn, now());
|
|
221
|
+
const cap = Math.min(maxDelayMs, baseMs * 2 ** (attempt - 1));
|
|
222
|
+
const delayMs = Math.max(Math.round(random() * cap), retryAfterMs ?? 0); // full jitter, server floor (retry.ts:476)
|
|
223
|
+
const withHint = retryAfterMs !== undefined ? { retryAfterMs } : {};
|
|
224
|
+
// budget: a wait that would end past the deadline is not taken — the failure surfaces now
|
|
225
|
+
// and the router may advance at once (googleQuotaErrors.ts:286-289 shape)
|
|
226
|
+
if (now() - startedAt + delayMs > totalMs) { opts.onGiveUp?.({ ...note, ...withHint, delayMs, why: "budget" }); yield { type: "turn", turn }; return; }
|
|
227
|
+
// the run's own clock (--max-seconds): a wait that ends past it would only be cut off at the turn boundary
|
|
228
|
+
if (options?.deadlineAt !== undefined && now() + delayMs > options.deadlineAt) { opts.onGiveUp?.({ ...note, ...withHint, delayMs, why: "deadline" }); yield { type: "turn", turn }; return; }
|
|
229
|
+
opts.onRetry?.({ ...note, delayMs, ...withHint });
|
|
230
|
+
await sleep(delayMs, options?.signal);
|
|
231
|
+
if (options?.signal?.aborted) { yield { type: "turn", turn }; return; } // abort landed during backoff: last turn stands
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|