opencode-adaptive-routing 0.1.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.
@@ -0,0 +1,42 @@
1
+ # Architecture
2
+
3
+ ## Phase 0 Decision
4
+
5
+ The MVP is a command/custom-tool recommendation package with a thin adapter
6
+ boundary. It does not mutate an OpenCode request. The user receives a
7
+ recommendation and copyable model-variant settings, then chooses what to do in
8
+ their normal OpenCode flow.
9
+
10
+ ## Compatibility Findings
11
+
12
+ - OpenCode plugins can register custom tools through `@opencode-ai/plugin`.
13
+ - The documented plugin hooks include `chat.message`, `chat.params`,
14
+ `command.execute.before`, and `permission.ask`.
15
+ - OpenCode configuration supports named model variants with options such as
16
+ `reasoningEffort`.
17
+ - The documented APIs do not define a safe, user-confirmed mutation of the
18
+ next request's model and variant from a custom command or tool.
19
+ - `chat.params` is a request-parameter hook, not an approved next-request
20
+ selection mechanism. This project will not use it for routing.
21
+
22
+ ## Compatibility Range
23
+
24
+ The package targets OpenCode plugin APIs available from `@opencode-ai/plugin`
25
+ 1.18.25 or newer. Runtime mutation remains disabled unless a future documented
26
+ API is explicitly reviewed and approved.
27
+
28
+ ## Boundaries
29
+
30
+ `policy.ts`, `recommendation.ts`, and `metrics.ts` are pure or filesystem-only
31
+ TypeScript. `command.ts` adapts task input and user decisions to the pure core.
32
+ `plugin.ts` is the thin documented OpenCode custom-tool bridge and exports the
33
+ default plugin entry point used by npm installation.
34
+
35
+ ## Safety Invariants
36
+
37
+ - No task text leaves the local process.
38
+ - No model or effort setting is changed automatically.
39
+ - `max`, Sol, and all classifier-generated recommendations require explicit
40
+ confirmation.
41
+ - Metrics are disabled unless the caller opts in.
42
+ - Unsupported runtime application returns manual instructions.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files, to deal in the Software
7
+ without restriction, including without limitation the rights to use, copy,
8
+ modify, merge, publish, distribute, sublicense, and/or sell copies of the
9
+ Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # OpenCode Adaptive Routing
2
+
3
+ An opt-in, local, recommendation-only extension for choosing a GPT-5.6 model
4
+ variant and reasoning effort before a task starts.
5
+
6
+ The MVP never silently changes settings, sends task content to a service, or
7
+ collects telemetry. It prints manual selection instructions when runtime
8
+ mutation is unavailable.
9
+
10
+ ## Status
11
+
12
+ The package is ready to publish and install as an OpenCode server plugin. It is
13
+ not published or installed into an active OpenCode profile by this repository.
14
+
15
+ ## OpenCode Installation
16
+
17
+ After publication, add the npm package to your OpenCode configuration, usually
18
+ `.opencode/opencode.jsonc` or `opencode.jsonc`:
19
+
20
+ ```jsonc
21
+ {
22
+ "$schema": "https://opencode.ai/config.json",
23
+ "plugin": ["opencode-adaptive-routing"],
24
+ }
25
+ ```
26
+
27
+ Restart OpenCode. The plugin registers a custom tool named `route_task`. Ask
28
+ OpenCode to use `route_task` for a task, or invoke the tool from a workflow
29
+ that supplies these arguments:
30
+
31
+ ```json
32
+ {
33
+ "task": "Debug a database connection issue.",
34
+ "action": "recommend"
35
+ }
36
+ ```
37
+
38
+ The tool returns a model, effort, confidence, rationale, tradeoffs, risk, and
39
+ confirmation requirement. To act on the result, make a second explicit call:
40
+
41
+ ```json
42
+ {
43
+ "task": "Debug a database connection issue.",
44
+ "action": "accept"
45
+ }
46
+ ```
47
+
48
+ For an edited selection, use `action: "edit"` with `model` (`Luna`, `Terra`,
49
+ or `Sol`) and `effort` (`none`, `low`, `medium`, `high`, `xhigh`, or `max`).
50
+ Use `action: "cancel"` to make no change. The plugin only returns manual
51
+ selection instructions; it never changes OpenCode's native model or effort.
52
+
53
+ ## Optional `/route` Command
54
+
55
+ OpenCode slash commands are configured separately from plugins. Copy the
56
+ `command.route` entry from `opencode.example.jsonc` into the same configuration
57
+ file as the plugin:
58
+
59
+ ```jsonc
60
+ {
61
+ "command": {
62
+ "route": {
63
+ "description": "Recommend a model and reasoning effort for a task",
64
+ "template": "Use the route_task tool with task `$ARGUMENTS` and action `recommend`. Do not perform the task. Show the recommendation and wait for confirmation.",
65
+ },
66
+ },
67
+ }
68
+ ```
69
+
70
+ Restart OpenCode, then use:
71
+
72
+ ```text
73
+ /route Debug a database connection issue.
74
+ ```
75
+
76
+ The command asks the plugin's `route_task` tool for a recommendation without
77
+ starting the task. `/route` is not created by the npm plugin automatically;
78
+ the command configuration is required.
79
+
80
+ Pin a release for reproducible installation:
81
+
82
+ ```jsonc
83
+ {
84
+ "plugin": ["opencode-adaptive-routing@0.1.0"],
85
+ }
86
+ ```
87
+
88
+ ## Development
89
+
90
+ ```text
91
+ npm install
92
+ npm run check
93
+ npm run lint
94
+ npm test
95
+ npm run build
96
+ npm pack --dry-run
97
+ ```
98
+
99
+ ## Privacy and Consent
100
+
101
+ Task classification is deterministic and local. Metrics are off by default and
102
+ must be explicitly enabled by the caller. Metrics contain only sanitized
103
+ outcome metadata, never prompts, responses, paths, identifiers, credentials,
104
+ or tool arguments.
105
+
106
+ The user can accept, edit, or cancel every recommendation. Cancellation has no
107
+ side effects. Automatic routing is intentionally out of scope.
108
+
109
+ ## Rollback
110
+
111
+ Remove the command/tool registration from the OpenCode profile. Because this
112
+ package does not mutate native request settings, ordinary OpenCode model and
113
+ effort selection remains available at all times.
114
+
115
+ ## Publishing
116
+
117
+ Publication is intentionally manual and requires npm authentication:
118
+
119
+ ```text
120
+ npm publish --access public
121
+ ```
122
+
123
+ Do not run this command unless publication has been explicitly approved.
package/dist/index.js ADDED
@@ -0,0 +1,308 @@
1
+ // src/types.ts
2
+ var MODELS = ["Luna", "Terra", "Sol"];
3
+ var EFFORTS = [
4
+ "none",
5
+ "low",
6
+ "medium",
7
+ "high",
8
+ "xhigh",
9
+ "max"
10
+ ];
11
+
12
+ // src/policy.ts
13
+ var rules = [
14
+ {
15
+ name: "long-task",
16
+ pattern: /\b.{240,}\b/s,
17
+ score: 2,
18
+ reason: "The task contains substantial context."
19
+ },
20
+ {
21
+ name: "multi-step",
22
+ pattern: /\b(first|then|next|finally|step\s+\d|and then)\b/gi,
23
+ score: 2,
24
+ reason: "The task contains multiple ordered steps."
25
+ },
26
+ {
27
+ name: "external-tools",
28
+ pattern: /\b(api|browser|deploy|database|terminal|shell|tool|github|railway)\b/gi,
29
+ score: 2,
30
+ reason: "The task likely requires external tools or integrations."
31
+ },
32
+ {
33
+ name: "security",
34
+ pattern: /\b(security|credential|secret|auth|vulnerability|threat|permission|exploit)\b/gi,
35
+ score: 4,
36
+ reason: "The task includes security-sensitive language."
37
+ },
38
+ {
39
+ name: "research",
40
+ pattern: /\b(research|compare|evaluate|sources|literature|investigate)\b/gi,
41
+ score: 3,
42
+ reason: "The task requires research or evidence gathering."
43
+ },
44
+ {
45
+ name: "long-horizon",
46
+ pattern: /\b(roadmap|migration|refactor|large-scale|end-to-end|long-running|implement.*all)\b/gi,
47
+ score: 4,
48
+ reason: "The task suggests long-horizon or broad work."
49
+ },
50
+ {
51
+ name: "imperative",
52
+ pattern: /\b(build|create|implement|fix|debug|design|review|write|add|remove|update)\b/gi,
53
+ score: 1,
54
+ reason: "The task contains an actionable request."
55
+ },
56
+ {
57
+ name: "simple",
58
+ pattern: /\b(hello|format|extract|summarize|classify|quick|simple|latency)\b/gi,
59
+ score: -2,
60
+ reason: "The task includes a simple or latency-sensitive signal."
61
+ }
62
+ ];
63
+ function matches(pattern, task) {
64
+ pattern.lastIndex = 0;
65
+ return pattern.test(task);
66
+ }
67
+ function riskFor(score) {
68
+ if (score >= 8) return "critical";
69
+ if (score >= 5) return "high";
70
+ if (score >= 2) return "medium";
71
+ return "low";
72
+ }
73
+ function classifyTask(task) {
74
+ const normalized = task.trim();
75
+ const signals = rules.filter((rule) => matches(rule.pattern, normalized)).map(({ name, score: score2, reason }) => ({ name, score: score2, reason }));
76
+ const score = signals.reduce((total, signal) => total + signal.score, 0);
77
+ const hasConflictingSignals = signals.some((signal) => signal.name === "simple") && score >= 5;
78
+ const unknown = normalized.length === 0 || signals.length === 0;
79
+ if (unknown || hasConflictingSignals) {
80
+ return {
81
+ model: "Terra",
82
+ effort: "medium",
83
+ confidence: 0.35,
84
+ risk: "medium",
85
+ signals,
86
+ requiresConfirmation: true
87
+ };
88
+ }
89
+ let model = "Luna";
90
+ let effort = "low";
91
+ if (score >= 2) {
92
+ model = "Terra";
93
+ effort = "medium";
94
+ }
95
+ if (score >= 5) {
96
+ model = signals.some(
97
+ (signal) => signal.name === "security" || signal.name === "research"
98
+ ) ? "Sol" : "Terra";
99
+ effort = "high";
100
+ }
101
+ if (score >= 8 && signals.some(
102
+ (signal) => signal.name === "research" || signal.name === "long-horizon"
103
+ )) {
104
+ model = "Sol";
105
+ effort = "xhigh";
106
+ }
107
+ return {
108
+ model,
109
+ effort,
110
+ confidence: Math.min(0.95, 0.55 + Math.min(5, Math.abs(score)) * 0.07),
111
+ risk: riskFor(score),
112
+ signals,
113
+ requiresConfirmation: true
114
+ };
115
+ }
116
+
117
+ // src/recommendation.ts
118
+ function recommend(task) {
119
+ const result = classifyTask(task);
120
+ const rationale = result.signals.length > 0 ? result.signals.map((signal) => signal.reason).join(" ") : "No reliable task signals were found, so the conservative baseline was selected.";
121
+ const tradeoffs = result.model === "Sol" ? "Higher quality and reasoning depth, with higher latency and cost." : result.model === "Terra" ? "Balanced quality, latency, and cost for general work." : "Lowest latency and cost; use only when the task is straightforward.";
122
+ return {
123
+ ...result,
124
+ rationale,
125
+ tradeoffs,
126
+ manualSettings: `Select model ${result.model} and reasoning effort ${result.effort} for the next task.`
127
+ };
128
+ }
129
+
130
+ // src/command.ts
131
+ function isModel(value) {
132
+ return typeof value === "string" && MODELS.includes(value);
133
+ }
134
+ function isEffort(value) {
135
+ return typeof value === "string" && EFFORTS.includes(value);
136
+ }
137
+ function routeTask(task, decision) {
138
+ const recommendation = recommend(task);
139
+ if (!decision)
140
+ return {
141
+ status: "recommendation",
142
+ recommendation,
143
+ message: formatRecommendation(recommendation)
144
+ };
145
+ if (decision.type === "cancel")
146
+ return {
147
+ status: "cancelled",
148
+ recommendation,
149
+ message: "Routing cancelled. No model or effort setting was changed."
150
+ };
151
+ const model = decision.type === "edit" && decision.model !== void 0 ? decision.model : recommendation.model;
152
+ const effort = decision.type === "edit" && decision.effort !== void 0 ? decision.effort : recommendation.effort;
153
+ if (!isModel(model) || !isEffort(effort))
154
+ throw new Error("Invalid model or effort selection.");
155
+ const selected = { model, effort };
156
+ const status = decision.type === "edit" ? "edited" : "accepted";
157
+ return {
158
+ status,
159
+ recommendation,
160
+ selected,
161
+ message: `Confirmed ${model} with ${effort} effort. ${manualSettings(model, effort)}`
162
+ };
163
+ }
164
+ function formatRecommendation(recommendation) {
165
+ return [
166
+ `Recommendation: ${recommendation.model} / ${recommendation.effort}`,
167
+ `Confidence: ${Math.round(recommendation.confidence * 100)}% | Risk: ${recommendation.risk}`,
168
+ `Why: ${recommendation.rationale}`,
169
+ `Tradeoffs: ${recommendation.tradeoffs}`,
170
+ `Confirmation required: ${recommendation.requiresConfirmation ? "yes" : "no"}`,
171
+ "Choose accept, edit model, edit effort, or cancel."
172
+ ].join("\n");
173
+ }
174
+ function manualSettings(model, effort) {
175
+ return `Open your normal model selector and choose model ${model} with reasoning effort ${effort}; this adapter does not change native settings.`;
176
+ }
177
+
178
+ // src/tui.ts
179
+ function renderRecommendation(recommendation) {
180
+ return formatRecommendation(recommendation);
181
+ }
182
+ function confirmRoute(task, decision) {
183
+ return routeTask(task, decision);
184
+ }
185
+
186
+ // src/metrics.ts
187
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
188
+ import { dirname } from "node:path";
189
+ var MAX_METRICS = 1e3;
190
+ var models = /* @__PURE__ */ new Set(["Luna", "Terra", "Sol"]);
191
+ var efforts = /* @__PURE__ */ new Set([
192
+ "none",
193
+ "low",
194
+ "medium",
195
+ "high",
196
+ "xhigh",
197
+ "max"
198
+ ]);
199
+ function optionalNumber(value) {
200
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
201
+ }
202
+ function sanitizeMetric(value) {
203
+ if (!value || typeof value !== "object")
204
+ throw new Error("Metric must be an object.");
205
+ const input = value;
206
+ if (typeof input.timestamp !== "string" || !models.has(input.model) || !efforts.has(input.effort)) {
207
+ throw new Error("Metric has invalid required fields.");
208
+ }
209
+ const metric = {
210
+ timestamp: input.timestamp,
211
+ model: input.model,
212
+ effort: input.effort,
213
+ confidence: optionalNumber(input.confidence) ?? 0,
214
+ overridden: input.overridden === true
215
+ };
216
+ for (const key of [
217
+ "latencyMs",
218
+ "inputTokens",
219
+ "outputTokens",
220
+ "reasoningTokens",
221
+ "cost"
222
+ ]) {
223
+ const number = optionalNumber(input[key]);
224
+ if (number !== void 0) metric[key] = number;
225
+ }
226
+ if (typeof input.success === "boolean") metric.success = input.success;
227
+ if (typeof input.reworked === "boolean") metric.reworked = input.reworked;
228
+ return metric;
229
+ }
230
+ async function recordMetric(config, metric) {
231
+ if (!config.enabled) return false;
232
+ const clean = sanitizeMetric(metric);
233
+ let entries = [];
234
+ try {
235
+ const parsed = JSON.parse(await readFile(config.filePath, "utf8"));
236
+ if (Array.isArray(parsed))
237
+ entries = parsed.flatMap((entry) => {
238
+ try {
239
+ return [sanitizeMetric(entry)];
240
+ } catch {
241
+ return [];
242
+ }
243
+ });
244
+ } catch {
245
+ }
246
+ entries.push(clean);
247
+ entries = entries.slice(-MAX_METRICS);
248
+ await mkdir(dirname(config.filePath), { recursive: true });
249
+ const temporary = `${config.filePath}.${process.pid}.tmp`;
250
+ await writeFile(temporary, `${JSON.stringify(entries)}
251
+ `, { mode: 384 });
252
+ await rename(temporary, config.filePath);
253
+ return true;
254
+ }
255
+
256
+ // src/plugin.ts
257
+ import { tool } from "@opencode-ai/plugin";
258
+ function decisionFor(action, model, effort) {
259
+ if (action === "recommend") return void 0;
260
+ if (action === "accept") return { type: "accept" };
261
+ if (action === "cancel") return { type: "cancel" };
262
+ if (action === "edit") {
263
+ const decision = { type: "edit" };
264
+ if (model !== void 0) decision.model = model;
265
+ if (effort !== void 0) decision.effort = effort;
266
+ return decision;
267
+ }
268
+ throw new Error("action must be recommend, accept, edit, or cancel");
269
+ }
270
+ var AdaptiveRoutingPlugin = async () => ({
271
+ tool: {
272
+ route_task: tool({
273
+ description: "Recommend a local OpenCode model and reasoning effort. This tool never changes native settings.",
274
+ args: {
275
+ task: tool.schema.string().describe("The task to classify locally."),
276
+ action: tool.schema.string().describe("One of: recommend, accept, edit, cancel."),
277
+ model: tool.schema.string().optional().describe("Edited model: Luna, Terra, or Sol."),
278
+ effort: tool.schema.string().optional().describe("Edited effort: none, low, medium, high, xhigh, or max.")
279
+ },
280
+ async execute(args) {
281
+ return routeTask(
282
+ args.task,
283
+ decisionFor(args.action, args.model, args.effort)
284
+ ).message;
285
+ }
286
+ })
287
+ }
288
+ });
289
+ var plugin = {
290
+ id: "adaptive-routing",
291
+ server: AdaptiveRoutingPlugin
292
+ };
293
+ var plugin_default = plugin;
294
+ export {
295
+ plugin_default as AdaptiveRoutingPlugin,
296
+ EFFORTS,
297
+ MAX_METRICS,
298
+ MODELS,
299
+ classifyTask,
300
+ confirmRoute,
301
+ plugin_default as default,
302
+ formatRecommendation,
303
+ recommend,
304
+ recordMetric,
305
+ renderRecommendation,
306
+ routeTask,
307
+ sanitizeMetric
308
+ };
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "plugin": ["opencode-adaptive-routing"],
4
+ "command": {
5
+ "route": {
6
+ "description": "Recommend a model and reasoning effort for a task",
7
+ "template": "Use the route_task tool with task `$ARGUMENTS` and action `recommend`. Do not perform the task. Show the recommendation and wait for confirmation.",
8
+ },
9
+ },
10
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "opencode-adaptive-routing",
3
+ "version": "0.1.0",
4
+ "description": "Opt-in, local, recommendation-only model routing for OpenCode",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "exports": {
9
+ ".": "./dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE",
15
+ "ARCHITECTURE.md",
16
+ "opencode.example.jsonc"
17
+ ],
18
+ "engines": {
19
+ "node": ">=20",
20
+ "opencode": ">=1.18.25 <2"
21
+ },
22
+ "peerDependencies": {
23
+ "@opencode-ai/plugin": ">=1.18.25 <2"
24
+ },
25
+ "scripts": {
26
+ "build": "esbuild src/index.ts --bundle --format=esm --platform=node --packages=external --outfile=dist/index.js",
27
+ "check": "prettier --check src tests scripts fixtures package.json tsconfig.json README.md ARCHITECTURE.md EVALUATION.md opencode.example.jsonc",
28
+ "format": "prettier --write src tests scripts fixtures package.json tsconfig.json README.md ARCHITECTURE.md EVALUATION.md opencode.example.jsonc",
29
+ "lint": "tsc --noEmit",
30
+ "evaluate": "tsx scripts/evaluate.ts",
31
+ "test": "tsx --test tests/*.test.ts",
32
+ "prepack": "npm run build"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.15.3",
36
+ "@opencode-ai/plugin": "^1.18.25",
37
+ "esbuild": "^0.25.9",
38
+ "prettier": "^3.6.2",
39
+ "tsx": "^4.19.4",
40
+ "typescript": "^5.9.2"
41
+ }
42
+ }