@qadanm/opencode-smart-router 0.0.0-stage → 0.6.1

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 (62) hide show
  1. package/CHANGELOG.md +286 -0
  2. package/CONTRIBUTING.md +103 -0
  3. package/LICENSE +21 -0
  4. package/README.md +425 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +107 -0
  7. package/dist/npm/index.js +40 -0
  8. package/docs/ARCHITECTURE.md +104 -0
  9. package/docs/DEBUGGING-GUI.md +70 -0
  10. package/docs/INSTALL.md +151 -0
  11. package/docs/LIMITATIONS.md +230 -0
  12. package/docs/MODELS.md +124 -0
  13. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  14. package/docs/QA.md +142 -0
  15. package/docs/SHARED-CORE.md +114 -0
  16. package/install +75 -0
  17. package/package.json +94 -4
  18. package/scripts/install.sh +324 -0
  19. package/scripts/uninstall.sh +91 -0
  20. package/src/allowance.ts +135 -0
  21. package/src/core/classify/classifier.ts +305 -0
  22. package/src/core/config-writer.ts +167 -0
  23. package/src/core/config.ts +1047 -0
  24. package/src/core/context/strategy.ts +188 -0
  25. package/src/core/context-health.ts +39 -0
  26. package/src/core/failures.ts +398 -0
  27. package/src/core/history/opencode.ts +173 -0
  28. package/src/core/models/protocol.ts +22 -0
  29. package/src/core/models/registry.ts +204 -0
  30. package/src/core/models/seed.ts +781 -0
  31. package/src/core/offline/mode.ts +321 -0
  32. package/src/core/plan-page.ts +390 -0
  33. package/src/core/plans/catalog.ts +487 -0
  34. package/src/core/policy/calibration.ts +259 -0
  35. package/src/core/policy/eligibility.ts +630 -0
  36. package/src/core/providers/enablement.ts +143 -0
  37. package/src/core/providers/opendesign-credits.ts +206 -0
  38. package/src/core/providers/opendesign-models.ts +654 -0
  39. package/src/core/providers/opendesign.ts +474 -0
  40. package/src/core/quota/calibration.ts +166 -0
  41. package/src/core/quota/commandcode.ts +471 -0
  42. package/src/core/quota/credentials.ts +490 -0
  43. package/src/core/quota/ledger.ts +1304 -0
  44. package/src/core/quota/opencode-go.ts +148 -0
  45. package/src/core/quota/opendesign.ts +1043 -0
  46. package/src/core/quota/reconcile.ts +155 -0
  47. package/src/core/quota/report.ts +261 -0
  48. package/src/core/quota/service.ts +273 -0
  49. package/src/core/reprompt.ts +48 -0
  50. package/src/core/routing/engine.ts +978 -0
  51. package/src/core/types.ts +866 -0
  52. package/src/core/ui.ts +236 -0
  53. package/src/core/util.ts +114 -0
  54. package/src/gui/index.tsx +1675 -0
  55. package/src/gui/viewmodel.ts +456 -0
  56. package/src/index.ts +33 -0
  57. package/src/rpc.ts +204 -0
  58. package/src/server/containment.ts +131 -0
  59. package/src/server/controller.ts +3405 -0
  60. package/src/server/route-tracker.ts +60 -0
  61. package/src/server/shared-core.ts +701 -0
  62. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # OpenCode Smart Router — one-command installer.
4
+ #
5
+ # WHAT THIS IS FOR. A person (or an LLM) who has just cloned this repo should be able to
6
+ # run ONE command and end up with a working router, without reading docs first, without
7
+ # jq, and without knowing how OpenCode loads plugins.
8
+ #
9
+ # ./scripts/install.sh
10
+ #
11
+ # It is idempotent: running it again replaces the installed copy and leaves exactly one
12
+ # config entry. It never touches conversations, credentials or project files.
13
+ #
14
+ # WHY IT BUILDS. The live install is a BUNDLED dist/index.js, not a src/ tree. An earlier
15
+ # version of this script copied `src/` plus a TypeScript shim, which produced an install
16
+ # that could not load — `@opencode/plugin` is external to the bundle and resolved by
17
+ # OpenCode at runtime, and the .ts shim needed a transpiler OpenCode does not provide.
18
+ # So this script builds, and installs the build.
19
+ #
20
+ # REQUIREMENTS: node >= 20.12, npm, git. Nothing else. No jq.
21
+ #
22
+ # OPTIONS
23
+ # --dry-run Print every step, change nothing.
24
+ # --yes Do not prompt (for automation / LLMs).
25
+ # --no-verify Skip the post-install load check (not recommended).
26
+ # --config-dir X Override the OpenCode config directory.
27
+ #
28
+ set -euo pipefail
29
+
30
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
31
+ CONFIG_DIR="${OPENCODE_CONFIG_DIR:-$HOME/.config/opencode}"
32
+ CONFIG="$CONFIG_DIR/opencode.json"
33
+ INSTALL_ROOT="$CONFIG_DIR/plugins/opencode-smart-router"
34
+ INSTALL="$INSTALL_ROOT" # resolved to a versioned directory at install time
35
+ STAMP="$(date +%Y%m%d-%H%M%S)"
36
+
37
+ DRY_RUN=false
38
+ ASSUME_YES=false
39
+ VERIFY=true
40
+
41
+ while [[ $# -gt 0 ]]; do
42
+ case "$1" in
43
+ --dry-run) DRY_RUN=true; shift ;;
44
+ --yes|-y) ASSUME_YES=true; shift ;;
45
+ --no-verify) VERIFY=false; shift ;;
46
+ --config-dir) CONFIG_DIR="${2:?--config-dir needs a path}"; CONFIG="$CONFIG_DIR/opencode.json"; INSTALL_ROOT="$CONFIG_DIR/plugins/opencode-smart-router"; INSTALL="$INSTALL_ROOT"; shift 2 ;;
47
+ -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
48
+ *) echo "Unknown option: $1 (try --help)" >&2; exit 2 ;;
49
+ esac
50
+ done
51
+
52
+ say() { printf '%s\n' "$*"; }
53
+ step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
54
+ warn() { printf '\033[33m ! %s\033[0m\n' "$*"; }
55
+ die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; }
56
+
57
+ run() {
58
+ if $DRY_RUN; then
59
+ printf ' [dry-run] %s\n' "$*"
60
+ else
61
+ "$@"
62
+ fi
63
+ }
64
+
65
+ # ── 1. Preflight ─────────────────────────────────────────────────────────────
66
+ # Everything is checked BEFORE anything is modified, so a failed check cannot leave a
67
+ # half-installed machine.
68
+ step "1/6 Checking prerequisites"
69
+
70
+ command -v node >/dev/null || die "node not found. Install Node 20.12 or newer: https://nodejs.org"
71
+ command -v npm >/dev/null || die "npm not found (it ships with Node)."
72
+ command -v git >/dev/null || die "git not found."
73
+
74
+ NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
75
+ NODE_MINOR="$(node -p 'process.versions.node.split(".")[1]')"
76
+ if (( NODE_MAJOR < 20 || (NODE_MAJOR == 20 && NODE_MINOR < 12) )); then
77
+ die "Node $(node -v) is too old. The router needs >= 20.12."
78
+ fi
79
+ say " node $(node -v) npm $(npm -v)"
80
+
81
+ [[ -f "$ROOT/package.json" ]] || die "package.json not found — run this from the repo."
82
+
83
+ if [[ -f "$CONFIG" ]]; then
84
+ say " OpenCode config: $CONFIG"
85
+ else
86
+ # A first-time OpenCode user has no config yet. Creating one is friendlier than
87
+ # failing, and OpenCode accepts a minimal object.
88
+ warn "no OpenCode config at $CONFIG — one will be created"
89
+ if ! $DRY_RUN; then
90
+ mkdir -p "$CONFIG_DIR"
91
+ printf '{}\n' > "$CONFIG"
92
+ fi
93
+ fi
94
+
95
+ # ── 2. Dependencies ──────────────────────────────────────────────────────────
96
+ # A fresh clone has no node_modules, and the build needs esbuild from devDependencies.
97
+ step "2/6 Installing dependencies"
98
+ # The dependency check keys on the BUILD TOOL that is actually needed, not on the
99
+ # presence of a directory. A fresh clone has no node_modules at all; a half-installed one
100
+ # can have the directory but be missing esbuild. Checking the tool is honest about both.
101
+ if [[ -x "$ROOT/node_modules/.bin/esbuild" ]] && [[ -x "$ROOT/node_modules/.bin/tsc" ]]; then
102
+ say " dependencies present — skipping (delete node_modules to force a clean install)"
103
+ else
104
+ # `npm ci` is exact and fast, but it REFUSES a lockfile that does not match
105
+ # package.json. Fall back to `npm install` so a drift in the lockfile is repaired
106
+ # rather than dead-ending the user.
107
+ if ! run npm --prefix "$ROOT" ci --no-audit --no-fund; then
108
+ warn "npm ci rejected the lockfile — falling back to npm install (it will repair it)"
109
+ run npm --prefix "$ROOT" install --no-audit --no-fund
110
+ fi
111
+ fi
112
+ if ! $DRY_RUN; then
113
+ [[ -x "$ROOT/node_modules/.bin/esbuild" ]] || die "esbuild missing after install — the build cannot run"
114
+ [[ -x "$ROOT/node_modules/.bin/tsc" ]] || die "typescript missing after install — the check cannot run"
115
+ fi
116
+
117
+ # ── 3. Verify the source ─────────────────────────────────────────────────────
118
+ # Typecheck + tests, because installing a router that cannot route is worse than
119
+ # not installing it. `--no-verify` is for emergencies only.
120
+ if $VERIFY; then
121
+ step "3/6 Verifying the source (typecheck + tests)"
122
+ run npm --prefix "$ROOT" run check
123
+ else
124
+ step "3/6 Verification SKIPPED (--no-verify)"
125
+ warn "the build may be broken; you asked to skip the check"
126
+ fi
127
+
128
+ # ── 4. Build the plugin ──────────────────────────────────────────────────────
129
+ step "4/6 Building the plugin bundle"
130
+ run npm --prefix "$ROOT" run build:server
131
+
132
+ # The desktop GUI is a separate CommonJS bundle. It is optional: a terminal-only
133
+ # machine has no use for it, and vite is a devDependency that a `--omit=dev`
134
+ # install will not have. Build it only when the tool is present, and never fail
135
+ # the install because the GUI could not be built.
136
+ GUI_BUNDLE=""
137
+ if ! $DRY_RUN; then
138
+ if [[ -x "$ROOT/node_modules/.bin/vite" ]]; then
139
+ if run npm --prefix "$ROOT" run build:gui >/dev/null 2>&1; then
140
+ GUI_BUNDLE="$ROOT/dist/gui-experimental.cjs"
141
+ say " dist/gui-experimental.cjs $(wc -c < "$GUI_BUNDLE" | tr -d ' ') bytes"
142
+ else
143
+ warn "the desktop GUI failed to build; the router still works without it"
144
+ fi
145
+ else
146
+ warn "vite not installed — skipping the desktop GUI (npm install to include it)"
147
+ fi
148
+ fi
149
+
150
+ if ! $DRY_RUN; then
151
+ [[ -f "$ROOT/dist/index.js" ]] || die "build produced no dist/index.js"
152
+ SHA="$(sha256sum "$ROOT/dist/index.js" 2>/dev/null | cut -d' ' -f1 || shasum -a 256 "$ROOT/dist/index.js" | cut -d' ' -f1)"
153
+ say " dist/index.js $(wc -c < "$ROOT/dist/index.js" | tr -d ' ') bytes sha256 ${SHA:0:12}…"
154
+ fi
155
+
156
+ # ── 5. Install ───────────────────────────────────────────────────────────────
157
+ step "5/6 Installing into OpenCode"
158
+
159
+ # If OpenCode is RUNNING, replacing the plugin under it can break the live session.
160
+ # The files are replaced atomically (rename), which the watcher reads as a content
161
+ # change rather than a disappearance -- but a running session can still be mid-step,
162
+ # so say so plainly instead of letting it surprise the user.
163
+ if pgrep -qi "opencode" 2>/dev/null; then
164
+ warn "OpenCode appears to be running. The plugin files are replaced atomically, but"
165
+ warn "a session that is MID-STEP when this finishes may still fail once."
166
+ warn "Restart OpenCode when convenient."
167
+ fi
168
+ run cp "$CONFIG" "$CONFIG.bak-smart-router-$STAMP"
169
+ say " config backup: $CONFIG.bak-smart-router-$STAMP"
170
+
171
+ # INSTALL TO A FRESH PATH, THEN REPOINT THE CONFIG.
172
+ #
173
+ # Earlier versions replaced files INSIDE the plugin directory. First with `rm -rf`,
174
+ # then with an atomic `mv` over the old file. Both were wrong, and the log proves it:
175
+ # an atomic rename still fires the directory watcher, because the watcher sees a
176
+ # change in the directory it is watching. On 2026-11-03 a rename-based install
177
+ # produced a ModelUnavailableError 0.4s later, exactly like the `rm -rf` version.
178
+ #
179
+ # The only way not to disturb a running server is to not touch the directory it is
180
+ # watching. So each install lands in its own versioned directory, and the config
181
+ # pointer is what changes. A running server keeps its already-loaded module; the new
182
+ # path takes effect on the next start.
183
+ #
184
+ # The pointer is written to a temp file and renamed, which is atomic for the config
185
+ # file. OpenCode watches the plugins DIRECTORY, not the config, so that rename does
186
+ # not invalidate a loaded plugin module.
187
+ STAMP_DIR="$(date +%Y%m%d-%H%M%S)"
188
+ TARGET="$INSTALL_ROOT/$STAMP_DIR"
189
+
190
+ run mkdir -p "$TARGET"
191
+ run cp "$ROOT/dist/index.js" "$TARGET/index.js"
192
+ run cp "$ROOT/config.example.json" "$TARGET/config.example.json"
193
+
194
+ # The desktop app loads a GUI entry from the plugin directory. Without a
195
+ # package.json it looks for exactly `gui-experimental.cjs` at the root, so the
196
+ # filename here is part of the contract, not a convention.
197
+ if [[ -n "$GUI_BUNDLE" && -f "$GUI_BUNDLE" ]]; then
198
+ run cp "$GUI_BUNDLE" "$TARGET/gui-experimental.cjs"
199
+ say " desktop GUI: installed"
200
+ fi
201
+
202
+ # The terminal part. Optional for the same reason as the GUI: a headless server
203
+ # has no terminal to draw in, and a failed TUI build must not fail the install.
204
+ if ! $DRY_RUN; then
205
+ if [[ -x "$ROOT/node_modules/.bin/esbuild" ]] && npm --prefix "$ROOT" run build:tui >/dev/null 2>&1; then
206
+ run cp "$ROOT/dist/tui.js" "$TARGET/tui.js"
207
+ say " terminal UI: installed"
208
+ else
209
+ warn "terminal UI not built; the desktop panel and slash commands still work"
210
+ fi
211
+ fi
212
+
213
+ # The router resolves @opencode/plugin at runtime, so the host's copy must be reachable
214
+ # from the plugin directory. A symlink to the repo's node_modules keeps the install
215
+ # small and lets OpenCode resolve it the same way it resolves any other plugin dep.
216
+ if ! $DRY_RUN; then
217
+ ln -s "$ROOT/node_modules" "$TARGET/node_modules"
218
+ fi
219
+
220
+ # Point the config at the new version. `INSTALL` is what gets written into
221
+ # cfg.plugins, so the running server is never asked to reload the old path.
222
+ INSTALL="$TARGET"
223
+ say " installed: $INSTALL/index.js"
224
+
225
+ # Record what was installed, so a later `--rollback` or audit can tell versions apart.
226
+ if ! $DRY_RUN; then
227
+ node -e '
228
+ const fs=require("fs"),cp=require("child_process"),crypto=require("crypto");
229
+ const dir=process.argv[1], root=process.argv[2], sha=process.argv[3];
230
+ const git=(args)=>cp.execSync("git "+args,{cwd:root}).toString().trim();
231
+ const commit=git("rev-parse HEAD");
232
+ // A clean hash is not enough to reproduce a build. Three installs were
233
+ // attributed to the wrong commit because the tree had uncommitted changes
234
+ // when the artifact was built. Record that, and hash the diff itself, so a
235
+ // "which code is running" question has an answer that cannot be stale.
236
+ let dirty=false, treeDigest=null, changedFiles=0;
237
+ try {
238
+ const status=git("status --porcelain --untracked-files=no");
239
+ changedFiles=status?status.split("\n").filter(Boolean).length:0;
240
+ dirty=changedFiles>0;
241
+ if (dirty) {
242
+ const diff=cp.execSync("git diff HEAD",{cwd:root,maxBuffer:64*1024*1024});
243
+ treeDigest=crypto.createHash("sha256").update(diff).digest("hex");
244
+ }
245
+ } catch(e) { dirty=null; }
246
+ const guiPath=dir+"/gui-experimental.cjs";
247
+ const guiSha=fs.existsSync(guiPath)
248
+ ? crypto.createHash("sha256").update(fs.readFileSync(guiPath)).digest("hex") : null;
249
+ let version=null;
250
+ try { version=JSON.parse(fs.readFileSync(root+"/package.json","utf8")).version; } catch(e) {}
251
+ fs.writeFileSync(dir+"/DEPLOYMENT.json", JSON.stringify({
252
+ deployedAt:new Date().toISOString(),
253
+ artifactSha256:sha,
254
+ codeCommit:commit,
255
+ // null means the tree state could not be determined (not a git checkout).
256
+ dirty,
257
+ dirtyDiffSha256:treeDigest,
258
+ changedFiles,
259
+ packageVersion:version,
260
+ guiSha256:guiSha,
261
+ installedBy:"scripts/install.sh"
262
+ },null,2)+"\n");
263
+ if (dirty) console.log(" ! built from a DIRTY tree ("+changedFiles+" modified file(s)) — recorded in DEPLOYMENT.json");
264
+ ' "$INSTALL" "$ROOT" "${SHA:-unknown}"
265
+ fi
266
+
267
+ # Config edit WITHOUT jq: parse and rewrite with node so this works on a machine that
268
+ # has never installed a JSON tool. Always leaves exactly one entry, never duplicates.
269
+ if ! $DRY_RUN; then
270
+ node -e '
271
+ const fs=require("fs");
272
+ const [file,entry]=process.argv.slice(1);
273
+ let cfg={};
274
+ try{ cfg=JSON.parse(fs.readFileSync(file,"utf8")); }catch(e){
275
+ console.error(" x existing config is not valid JSON: "+e.message); process.exit(1);
276
+ }
277
+ const list=Array.isArray(cfg.plugins)?cfg.plugins:[];
278
+ // Drop EVERY entry under the install root, not just an exact match on the
279
+ // new path. A versioned install leaves the previous path (and any legacy
280
+ // non-versioned path) behind, and two entries would load the plugin twice.
281
+ const root=process.argv[3];
282
+ const under=(p)=>typeof p==="string" && (p===root || p.startsWith(root+"/"));
283
+ cfg.plugins=[...list.filter(p=>!under(p)),entry];
284
+ fs.writeFileSync(file,JSON.stringify(cfg,null,2)+"\n");
285
+ fs.chmodSync(file,0o600);
286
+ console.log(" config now lists "+cfg.plugins.length+" plugin(s)");
287
+ ' "$CONFIG" "$INSTALL" "$INSTALL_ROOT"
288
+ fi
289
+
290
+ # ── 6. Verify it loads ───────────────────────────────────────────────────────
291
+ step "6/6 Verifying the install"
292
+ if $DRY_RUN; then
293
+ say " [dry-run] would verify the bundle loads"
294
+ elif $VERIFY; then
295
+ node --input-type=module -e "
296
+ const mod = await import('$INSTALL/index.js');
297
+ if (!mod || !mod.default) { console.error(' x the bundle did not export a plugin'); process.exit(1); }
298
+ console.log(' bundle loads and exports a plugin');
299
+ " || die "the installed bundle failed to load — run ./scripts/uninstall.sh to roll back"
300
+ else
301
+ warn "load check skipped"
302
+ fi
303
+
304
+ # ── Done ─────────────────────────────────────────────────────────────────────
305
+ if $DRY_RUN; then
306
+ step "Dry run complete — nothing was changed"
307
+ exit 0
308
+ fi
309
+
310
+ step "Installed"
311
+ cat <<EOF
312
+
313
+ Next steps:
314
+ 1. Restart OpenCode (or reload the desktop app) so it picks up the plugin.
315
+ 2. Run /router-status to see the active plan and remaining allowance.
316
+ 3. Run /router-why on any prompt to see which model it chose and why.
317
+
318
+ Remove it any time:
319
+ $ROOT/scripts/uninstall.sh
320
+
321
+ Config: $CONFIG
322
+ Plugin: $INSTALL
323
+ Backup: $CONFIG.bak-smart-router-$STAMP
324
+ EOF
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # OpenCode Smart Router — uninstaller.
4
+ #
5
+ # Removes the router from OpenCode and leaves everything else alone. Conversations,
6
+ # credentials, provider connections and project files are never touched.
7
+ #
8
+ # ./scripts/uninstall.sh remove the plugin and its config entry
9
+ # ./scripts/uninstall.sh --keep-files drop the config entry, keep the plugin files
10
+ # ./scripts/uninstall.sh --restore-backup <path> also restore a config backup
11
+ # ./scripts/uninstall.sh --dry-run show what would happen
12
+ #
13
+ set -euo pipefail
14
+
15
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
16
+ CONFIG_DIR="${OPENCODE_CONFIG_DIR:-$HOME/.config/opencode}"
17
+ CONFIG="$CONFIG_DIR/opencode.json"
18
+ # Uninstall removes the whole plugin directory, versioned installs included.
19
+ # A newer installer puts each version underneath this path, so removing the
20
+ # parent is what actually clears it.
21
+ INSTALL="$CONFIG_DIR/plugins/opencode-smart-router"
22
+ STAMP="$(date +%Y%m%d-%H%M%S)"
23
+
24
+ KEEP_FILES=false
25
+ RESTORE=""
26
+ DRY_RUN=false
27
+
28
+ while [[ $# -gt 0 ]]; do
29
+ case "$1" in
30
+ --keep-files) KEEP_FILES=true; shift ;;
31
+ --restore-backup) RESTORE="${2:?--restore-backup needs a path}"; shift 2 ;;
32
+ --dry-run) DRY_RUN=true; shift ;;
33
+ --config-dir) CONFIG_DIR="${2:?}"; CONFIG="$CONFIG_DIR/opencode.json"; INSTALL="$CONFIG_DIR/plugins/opencode-smart-router"; shift 2 ;;
34
+ -h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
35
+ *) echo "Unknown option: $1 (try --help)" >&2; exit 2 ;;
36
+ esac
37
+ done
38
+
39
+ run() { if $DRY_RUN; then printf ' [dry-run] %s\n' "$*"; else "$@"; fi; }
40
+
41
+ [[ -f "$CONFIG" ]] || { echo "OpenCode config not found at $CONFIG" >&2; exit 1; }
42
+
43
+ run cp "$CONFIG" "$CONFIG.bak-smart-router-uninstall-$STAMP"
44
+ echo " config backup: $CONFIG.bak-smart-router-uninstall-$STAMP"
45
+
46
+ # Drop every entry that points at this install, without jq.
47
+ if ! $DRY_RUN; then
48
+ node -e '
49
+ const fs=require("fs");
50
+ const [file,entry]=process.argv.slice(1);
51
+ let cfg={};
52
+ try{ cfg=JSON.parse(fs.readFileSync(file,"utf8")); }catch(e){
53
+ console.error(" x config is not valid JSON: "+e.message); process.exit(1);
54
+ }
55
+ const before=(cfg.plugins||[]).length;
56
+ // Remove EVERY entry under the install root, not just an exact match. The
57
+ // installer writes a versioned path underneath it, so exact matching here
58
+ // left a dangling entry pointing at a directory that no longer existed.
59
+ const under=(p)=>typeof p==="string" && (p===entry || p.startsWith(entry+"/"));
60
+ cfg.plugins=(cfg.plugins||[]).filter(p=>!under(p));
61
+ fs.writeFileSync(file,JSON.stringify(cfg,null,2)+"\n");
62
+ fs.chmodSync(file,0o600);
63
+ console.log(" removed "+(before-cfg.plugins.length)+" entry/entries; "+cfg.plugins.length+" plugin(s) remain");
64
+ ' "$CONFIG" "$INSTALL"
65
+ fi
66
+
67
+ if ! $KEEP_FILES; then
68
+ run rm -rf "$INSTALL"
69
+ echo " removed $INSTALL"
70
+ else
71
+ echo " kept $INSTALL (--keep-files)"
72
+ fi
73
+
74
+ if [[ -n "$RESTORE" ]]; then
75
+ [[ -f "$RESTORE" ]] || { echo "Backup not found: $RESTORE" >&2; exit 1; }
76
+ run cp "$RESTORE" "$CONFIG"
77
+ run chmod 600 "$CONFIG"
78
+ echo " restored config from $RESTORE"
79
+ fi
80
+
81
+ if $DRY_RUN; then
82
+ echo " [dry-run] nothing changed"
83
+ exit 0
84
+ fi
85
+
86
+ echo
87
+ echo "Uninstalled. Restart OpenCode to unload it."
88
+ if [[ -z "$RESTORE" ]]; then
89
+ echo "To restore the exact pre-removal config:"
90
+ echo " $0 --restore-backup $CONFIG.bak-smart-router-uninstall-$STAMP"
91
+ fi
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Portable allowance core: public entry point.
3
+ *
4
+ * This module is the part of the router that is useful *without* the router.
5
+ * It answers one question that every coding-agent user now has and that no
6
+ * existing library models:
7
+ *
8
+ * > Given a set of prepaid subscriptions with nested usage windows and
9
+ * > multiple credit pools, how much of each allowance is left, what will it
10
+ * > cost to spend more, and when does it run out?
11
+ *
12
+ * Every mainstream LLM router (`litellm`, `claude-code-router`, `Portkey`,
13
+ * `RouteLLM`) prices a request as `tokens × $/M`. That model assumes
14
+ * pay-as-you-go. Subscriptions broke it: a plan has a five-hour window nested
15
+ * inside a seven-day window nested inside a monthly ceiling, allowances that
16
+ * are metered per model and do not pool, and sometimes two credit pools with
17
+ * different draw rules and different multipliers.
18
+ *
19
+ * Nothing here knows about OpenCode, a plugin API, a specific provider's HTTP
20
+ * surface, or any particular user's tier. That is the point: the same model
21
+ * can serve a Claude Code wrapper, a Codex proxy, or a spreadsheet of your own
22
+ * usage.
23
+ *
24
+ * ## What is deliberately *not* here
25
+ *
26
+ * - **No network access.** This package never probes a provider.
27
+ * - **No credentials.** It consumes token counts and dollar figures that the
28
+ * caller obtained however they chose.
29
+ * - **No host API.** No plugin hooks, no filesystem, no environment reads.
30
+ * - **No secrets in errors.** Nothing in this surface accepts a secret.
31
+ *
32
+ * The consequence is that the whole module is pure, deterministic and
33
+ * trivially testable, which is what makes it safe to publish.
34
+ *
35
+ * ## Typical use
36
+ *
37
+ * ```ts
38
+ * import { resolvePlan, modelWindowCaps, forecast } from "@qadanm/opencode-smart-router/allowance"
39
+ *
40
+ * const plan = resolvePlan("opencode-go", "go")!
41
+ * const caps = modelWindowCaps(plan, "deepseek-v4.1-flash")
42
+ * // { monthlyUsd: 80, weeklyUsd: 40, fiveHourUsd: 16 }
43
+ *
44
+ * const projection = forecast({
45
+ * windowMs: 7 * 24 * 60 * 60 * 1000,
46
+ * now: Date.now(),
47
+ * spendInWindowUsd: 12.4,
48
+ * activityStart,
49
+ * remainingUsd: caps.weeklyUsd,
50
+ * label: "DeepSeek V4.1 Flash weekly",
51
+ * })
52
+ * // projection.willExhaust === true, projection.exhaustsAt === <timestamp>
53
+ * ```
54
+ */
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Plan model
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export {
61
+ /** Every plan this build knows about. */
62
+ PLAN_CATALOG,
63
+ /** The OpenDesign models funded from the shared Design Plan allowance. */
64
+ OPENDESIGN_DESIGN_PLAN_MODELS,
65
+ OPENDESIGN_DESIGN_PLAN_POOL,
66
+ OPENDESIGN_PLAN_MODEL_POOL,
67
+ /** Provider defaults, reported as `resolution: "default"`. */
68
+ DEFAULT_PLAN_ID,
69
+ defaultPlanIdFor,
70
+ /** Which pool funds a model, and in what order. */
71
+ fundingPoolFor,
72
+ isEscalationOnlyPool,
73
+ modelMonthlyAllowanceUsd,
74
+ /** The nested window caps for one model, derived from its allowance. */
75
+ modelWindowCaps,
76
+ planById,
77
+ planSummaryLine,
78
+ poolDrawSequence,
79
+ resolvePlan,
80
+ /** Resolve a plan id and report *how* it resolved. */
81
+ resolvePlanWithReason,
82
+ type CreditPoolDefinition,
83
+ type PerModelWindowRule,
84
+ type PlanDefinition,
85
+ type PlanResolution,
86
+ type PoolResetKind,
87
+ type ResolvedPlan,
88
+ } from "./core/plans/catalog.js"
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Context policy
92
+ // ---------------------------------------------------------------------------
93
+
94
+ export {
95
+ CONTEXT_ELIGIBILITY_MARGIN_TOKENS,
96
+ CONTEXT_SAFE_WINDOW_FRACTION,
97
+ /** Decide whether a transcript must be compacted, and why. */
98
+ decideCompaction,
99
+ describeMode,
100
+ /** The window a gateway actually accepts, not the vendor maximum. */
101
+ practicalWindow,
102
+ type CompactionContext,
103
+ type CompactionDecision,
104
+ } from "./core/context/strategy.js"
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Usage accounting and forecasting
108
+ // ---------------------------------------------------------------------------
109
+
110
+ export {
111
+ /** Project consumption from a representative recent window. */
112
+ forecast,
113
+ formatRelative,
114
+ /** Assemble a per-provider report from durable aggregates. */
115
+ buildProviderUsageReport,
116
+ type Forecast,
117
+ type ProviderUsageReport,
118
+ type UsageSource,
119
+ type WindowTotals,
120
+ } from "./core/quota/report.js"
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Supporting types
124
+ // ---------------------------------------------------------------------------
125
+
126
+ export type {
127
+ ContextManagementMode,
128
+ ContextManagementSettings,
129
+ CreditPoolSnapshot,
130
+ ModelMetrics,
131
+ PeakPricing,
132
+ UsageBucket,
133
+ UsageEvent,
134
+ UsageRollup,
135
+ } from "./core/types.js"