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.
- package/ARCHITECTURE.md +42 -0
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/index.js +308 -0
- package/opencode.example.jsonc +10 -0
- package/package.json +42 -0
package/ARCHITECTURE.md
ADDED
|
@@ -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
|
+
}
|