@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.
- package/CHANGELOG.md +286 -0
- package/CONTRIBUTING.md +103 -0
- package/LICENSE +21 -0
- package/README.md +425 -2
- package/SECURITY.md +174 -0
- package/config.example.json +107 -0
- package/dist/npm/index.js +40 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +151 -0
- package/docs/LIMITATIONS.md +230 -0
- package/docs/MODELS.md +124 -0
- package/docs/OPENDESIGN-INTEGRATION.md +635 -0
- package/docs/QA.md +142 -0
- package/docs/SHARED-CORE.md +114 -0
- package/install +75 -0
- package/package.json +94 -4
- package/scripts/install.sh +324 -0
- package/scripts/uninstall.sh +91 -0
- package/src/allowance.ts +135 -0
- package/src/core/classify/classifier.ts +305 -0
- package/src/core/config-writer.ts +167 -0
- package/src/core/config.ts +1047 -0
- package/src/core/context/strategy.ts +188 -0
- package/src/core/context-health.ts +39 -0
- package/src/core/failures.ts +398 -0
- package/src/core/history/opencode.ts +173 -0
- package/src/core/models/protocol.ts +22 -0
- package/src/core/models/registry.ts +204 -0
- package/src/core/models/seed.ts +781 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +487 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +630 -0
- package/src/core/providers/enablement.ts +143 -0
- package/src/core/providers/opendesign-credits.ts +206 -0
- package/src/core/providers/opendesign-models.ts +654 -0
- package/src/core/providers/opendesign.ts +474 -0
- package/src/core/quota/calibration.ts +166 -0
- package/src/core/quota/commandcode.ts +471 -0
- package/src/core/quota/credentials.ts +490 -0
- package/src/core/quota/ledger.ts +1304 -0
- package/src/core/quota/opencode-go.ts +148 -0
- package/src/core/quota/opendesign.ts +1043 -0
- package/src/core/quota/reconcile.ts +155 -0
- package/src/core/quota/report.ts +261 -0
- package/src/core/quota/service.ts +273 -0
- package/src/core/reprompt.ts +48 -0
- package/src/core/routing/engine.ts +978 -0
- package/src/core/types.ts +866 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1675 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +33 -0
- package/src/rpc.ts +204 -0
- package/src/server/containment.ts +131 -0
- package/src/server/controller.ts +3405 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +701 -0
- 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
|
package/src/allowance.ts
ADDED
|
@@ -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"
|