oc-agent-router 0.1.2 → 0.2.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/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  An OpenCode plugin that routes each new `task` subagent to one of your configured models. It asks [Jev](https://typesafe.ai/blog/introducing-system-one-models-and-jev) for a structured choice immediately before OpenCode creates the child session.
4
4
 
5
+ **Compatibility:** This plugin supports OpenCode v2 only. OpenCode v1 is not supported.
6
+
5
7
  ## Install
6
8
 
7
9
  ```sh
@@ -22,7 +24,8 @@ Set `TYPESAFE_API_KEY` in the environment that starts OpenCode, then configure t
22
24
  "anthropic/claude-sonnet-4-6",
23
25
  "google/gemini-3-pro"
24
26
  ],
25
- "fallbackModel": "anthropic/claude-sonnet-4-6"
27
+ "agents": ["general", "explore"],
28
+ "confidenceThreshold": 0.8
26
29
  }
27
30
  ]
28
31
  ]
@@ -35,37 +38,36 @@ All configured models must already be available in OpenCode. Restart OpenCode af
35
38
 
36
39
  ## Local Testing
37
40
 
38
- This repository includes `opencode.jsonc`, which loads the built plugin directly from `./dist/index.js`. Before starting OpenCode from this directory, update its `models` and `fallbackModel` to model IDs enabled in your OpenCode configuration.
41
+ This repository includes `.opencode/opencode.json`, which loads the built `dist/` plugin directory. Before starting OpenCode from this directory, build the plugin and update its `models` to model IDs enabled in your OpenCode configuration.
39
42
 
40
43
  ```sh
41
44
  npm run build
42
45
  TYPESAFE_API_KEY=... opencode
43
46
  ```
44
47
 
45
- Ask OpenCode to delegate work with a `task` subagent. A new task will receive the model selected by Jev; resumed tasks are intentionally left unchanged. Restart OpenCode after changing the plugin source, build output, or `opencode.jsonc`.
46
-
47
- To exercise the fallback without calling Jev, omit `TYPESAFE_API_KEY`; new tasks will use `fallbackModel`.
48
+ Ask OpenCode to delegate work with a `subagent`. A new child session will receive the model selected by Jev; resumed child sessions and calls with an explicit model are intentionally left unchanged. Restart OpenCode after changing the plugin source, build output, or `.opencode/opencode.json`.
48
49
 
49
- Routing diagnostics are appended as JSON lines to `oc-agent-router.log` in the project directory. The log records the request payload, response status and body, selected model, and failures, but never the API key.
50
+ To leave child sessions on their configured or inherited model, omit `TYPESAFE_API_KEY` or set a threshold higher than Jev's returned confidence.
50
51
 
51
52
  ## Options
52
53
 
53
54
  | Option | Default | Description |
54
55
  | --- | --- | --- |
55
56
  | `models` | Required | Non-empty allowed model list in `provider/model` form. |
57
+ | `agents` | All subagents | Optional list of subagent IDs this plugin may route. An explicit empty list routes none. |
58
+ | `confidenceThreshold` | `0.8` | Minimum Jev choice confidence from 0 to 1 required to override the child model. |
56
59
  | `instructions` | Built-in routing guidance | Instructions sent to Jev to guide its model choice. |
57
- | `fallbackModel` | First model | Used when the API key is missing, Jev errors, times out, or returns an invalid choice. Must be in `models`. |
58
60
  | `jevModel` | `jev-latest` | TypeSafe model ID. Pin a version if routing behavior must be stable. |
59
61
  | `apiKeyEnv` | `TYPESAFE_API_KEY` | Environment variable containing the TypeSafe API key. |
60
62
  | `timeoutMs` | `5000` | Jev request deadline, from 100 to 30,000 ms. |
61
63
 
62
64
  ## How It Works
63
65
 
64
- The plugin creates hidden model-specific variants of each subagent, calls `POST https://api.typesafe.ai/v1/systemone` with a Choice question whose only choices are your allowed models, then routes each new task to the matching hidden variant. Each variant retains the original agent's public name, prompt, and permissions, so users continue to see names such as `general` and `explore` rather than an internal routing name.
66
+ The plugin calls `POST https://api.typesafe.ai/v1/systemone` with a Choice question whose only choices are your allowed models, then supplies the selected model to OpenCode's native `subagent` tool immediately before it creates the child session. The original agent ID, system prompt, permissions, task, and display name remain unchanged.
65
67
 
66
- Task resumes (`task_id`) are deliberately not rerouted, so a resumed session keeps its original model. API failures never expand the configured model allowlist and use `fallbackModel` instead.
68
+ Child-session resumes (`sessionID`) are deliberately not rerouted, so a resumed session keeps its original model. Calls that explicitly select a model are also left unchanged. If Jev is unavailable, returns an invalid choice, or is below `confidenceThreshold`, the plugin leaves the model unset so OpenCode uses the configured agent model or parent session model.
67
69
 
68
- The routing request sends only the subagent type, short task description, and task prompt. It does not send session history, tool outputs, or API credentials.
70
+ The routing request sends only the subagent ID, short task description, and task prompt. It does not send session history, tool outputs, or API credentials.
69
71
 
70
72
  For example, a task that asks `general` to implement a parser sends the following payload. Its `Authorization` header is `Bearer $TYPESAFE_API_KEY`, which comes from the process environment rather than any config file.
71
73
 
@@ -91,4 +93,4 @@ For example, a task that asks `general` to implement a parser sends the followin
91
93
  }
92
94
  ```
93
95
 
94
- Every new task is eligible for routing, including configured and Markdown-defined subagents. Task resumes (`task_id`) retain their existing model.
96
+ Every new child session is eligible for routing, including configured and Markdown-defined subagents. Resumes (`sessionID`) retain their existing model.
package/dist/index.d.ts CHANGED
@@ -1,8 +1,12 @@
1
- import { Plugin } from '@opencode-ai/plugin';
1
+ import { Plugin } from '@opencode/plugin';
2
2
 
3
3
  interface RouterOptions {
4
4
  /** Models eligible to receive subagent tasks, in OpenCode provider/model form. */
5
5
  models: string[];
6
+ /** Subagent IDs eligible for routing. When omitted, all subagents are eligible. */
7
+ agents?: string[];
8
+ /** Minimum Jev choice confidence required to override the child model. Defaults to 0.8. */
9
+ confidenceThreshold?: number;
6
10
  /** Routing guidance sent to Jev. */
7
11
  instructions?: string;
8
12
  /** TypeSafe model ID used for routing. Defaults to jev-latest. */
@@ -11,14 +15,18 @@ interface RouterOptions {
11
15
  apiKeyEnv?: string;
12
16
  /** Maximum time to wait for a route decision. Defaults to 5000. */
13
17
  timeoutMs?: number;
14
- /** Model to use when Jev is unavailable or returns an invalid answer. Defaults to the first model. */
15
- fallbackModel?: string;
16
18
  }
17
- interface TaskArgs {
19
+ interface ParsedRouterOptions extends Omit<Required<RouterOptions>, "agents"> {
20
+ agents?: string[];
21
+ }
22
+ interface SubagentArgs {
18
23
  description?: unknown;
19
24
  prompt?: unknown;
20
- subagent_type?: unknown;
21
- task_id?: unknown;
25
+ agent?: unknown;
26
+ /** An explicit model is not overridden by the router. */
27
+ model?: unknown;
28
+ /** Resumed child sessions keep their existing model. */
29
+ sessionID?: unknown;
22
30
  }
23
31
  interface FetchResponse {
24
32
  ok: boolean;
@@ -26,11 +34,9 @@ interface FetchResponse {
26
34
  json(): Promise<unknown>;
27
35
  }
28
36
  type Fetcher = (input: string, init: RequestInit) => Promise<FetchResponse>;
29
- type Logger = (event: string, data?: unknown) => void;
30
- declare function routedAgentName(agent: string, model: string): string;
31
- declare function parseOptions(value: unknown): Required<RouterOptions>;
32
- declare function selectModel(options: Required<RouterOptions>, args: TaskArgs, apiKey: string | undefined, fetcher?: Fetcher, log?: Logger): Promise<string>;
37
+ declare function parseOptions(value: unknown): ParsedRouterOptions;
38
+ declare function selectModel(options: ParsedRouterOptions, args: SubagentArgs, apiKey: string | undefined, fetcher?: Fetcher): Promise<string | undefined>;
33
39
 
34
- declare const plugin: Plugin;
40
+ declare const plugin: Plugin.Plugin;
35
41
 
36
- export { type RouterOptions, plugin as default, parseOptions, routedAgentName, selectModel };
42
+ export { type RouterOptions, plugin as default, parseOptions, selectModel };
package/dist/index.js CHANGED
@@ -1,20 +1,7 @@
1
1
  // src/index.ts
2
- import { appendFile } from "fs/promises";
3
- import { join } from "path";
2
+ import { Plugin } from "@opencode/plugin";
4
3
 
5
4
  // src/router.ts
6
- function routedAgentName(agent, model) {
7
- return `oc-agent-router-${encode(agent)}-${encode(model)}`;
8
- }
9
- function routedAgentConfig(name, agent, model) {
10
- return {
11
- ...agent,
12
- name,
13
- model,
14
- mode: "subagent",
15
- hidden: true
16
- };
17
- }
18
5
  function parseOptions(value) {
19
6
  if (!isRecord(value) || !Array.isArray(value.models)) {
20
7
  throw new Error("oc-agent-router requires a non-empty models array");
@@ -23,22 +10,27 @@ function parseOptions(value) {
23
10
  if (models.length !== value.models.length || models.length === 0) {
24
11
  throw new Error("oc-agent-router models must be provider/model strings");
25
12
  }
26
- const fallbackModel = typeof value.fallbackModel === "string" ? value.fallbackModel : models[0];
27
- if (!models.includes(fallbackModel)) throw new Error("oc-agent-router fallbackModel must appear in models");
13
+ const agents = value.agents === void 0 ? void 0 : Array.isArray(value.agents) && value.agents.every((agent) => typeof agent === "string" && agent.trim()) ? value.agents : void 0;
14
+ if (value.agents !== void 0 && agents === void 0) {
15
+ throw new Error("oc-agent-router agents must be an array of non-empty agent IDs");
16
+ }
17
+ const confidenceThreshold = value.confidenceThreshold === void 0 ? 0.8 : typeof value.confidenceThreshold === "number" && Number.isFinite(value.confidenceThreshold) ? value.confidenceThreshold : void 0;
18
+ if (confidenceThreshold === void 0 || confidenceThreshold < 0 || confidenceThreshold > 1) {
19
+ throw new Error("oc-agent-router confidenceThreshold must be a number from 0 to 1");
20
+ }
28
21
  return {
29
22
  models,
23
+ agents,
24
+ confidenceThreshold,
30
25
  instructions: typeof value.instructions === "string" && value.instructions.trim() ? value.instructions : "Choose the configured model most suitable for completing this OpenCode subagent task. Prefer a capable model for implementation, debugging, and complex reasoning; prefer an efficient model for focused exploration or simple tasks.",
31
26
  jevModel: typeof value.jevModel === "string" ? value.jevModel : "jev-latest",
32
27
  apiKeyEnv: typeof value.apiKeyEnv === "string" ? value.apiKeyEnv : "TYPESAFE_API_KEY",
33
- timeoutMs: typeof value.timeoutMs === "number" && value.timeoutMs >= 100 && value.timeoutMs <= 3e4 ? value.timeoutMs : 5e3,
34
- fallbackModel
28
+ timeoutMs: typeof value.timeoutMs === "number" && value.timeoutMs >= 100 && value.timeoutMs <= 3e4 ? value.timeoutMs : 5e3
35
29
  };
36
30
  }
37
- async function selectModel(options, args, apiKey, fetcher = fetch, log = () => {
38
- }) {
31
+ async function selectModel(options, args, apiKey, fetcher = fetch) {
39
32
  if (!apiKey) {
40
- log("routing.skipped", { reason: "missing_api_key", fallbackModel: options.fallbackModel });
41
- return options.fallbackModel;
33
+ return void 0;
42
34
  }
43
35
  const controller = new AbortController();
44
36
  const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
@@ -48,37 +40,32 @@ async function selectModel(options, args, apiKey, fetcher = fetch, log = () => {
48
40
  state: {
49
41
  task: typeof args.prompt === "string" ? args.prompt : "",
50
42
  description: typeof args.description === "string" ? args.description : "",
51
- requested_agent: typeof args.subagent_type === "string" ? args.subagent_type : ""
43
+ requested_agent: typeof args.agent === "string" ? args.agent : ""
52
44
  },
53
45
  questions: {
54
46
  model: {
55
47
  type: "choice",
56
48
  instructions: options.instructions,
57
- criteria: Object.fromEntries(options.models.map((model2) => [model2, `Use the configured OpenCode model ${model2}.`]))
49
+ criteria: Object.fromEntries(options.models.map((model) => [model, `Use the configured OpenCode model ${model}.`]))
58
50
  }
59
51
  }
60
52
  };
61
- log("routing.request", body);
62
53
  const response = await fetcher("https://api.typesafe.ai/v1/systemone", {
63
54
  method: "POST",
64
55
  headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
65
56
  body: JSON.stringify(body),
66
57
  signal: controller.signal
67
58
  });
68
- log("routing.response.status", { ok: response.ok, status: response.status });
69
59
  const responseBody = await response.json();
70
- log("routing.response.body", responseBody);
71
- if (!response.ok) return options.fallbackModel;
72
- const choice = isRecord(responseBody) && isRecord(responseBody.answers) && isRecord(responseBody.answers.model) ? responseBody.answers.model.choice : void 0;
73
- const model = typeof choice === "string" && options.models.includes(choice) ? choice : options.fallbackModel;
74
- log("routing.selected", { model });
75
- return model;
76
- } catch (error) {
77
- log("routing.error", {
78
- error: error instanceof Error ? { name: error.name, message: error.message } : String(error),
79
- fallbackModel: options.fallbackModel
80
- });
81
- return options.fallbackModel;
60
+ if (!response.ok) return void 0;
61
+ const answer = isRecord(responseBody) && isRecord(responseBody.answers) && isRecord(responseBody.answers.model) ? responseBody.answers.model : void 0;
62
+ const choice = isRecord(answer) ? answer.choice : void 0;
63
+ const confidence = isRecord(answer) ? answer.confidence : void 0;
64
+ if (typeof choice !== "string" || !options.models.includes(choice) || typeof confidence !== "number" || !Number.isFinite(confidence) || confidence < options.confidenceThreshold)
65
+ return void 0;
66
+ return choice;
67
+ } catch {
68
+ return void 0;
82
69
  } finally {
83
70
  clearTimeout(timeout);
84
71
  }
@@ -87,108 +74,28 @@ function isModel(value) {
87
74
  const separator = value.indexOf("/");
88
75
  return separator > 0 && separator < value.length - 1;
89
76
  }
90
- function encode(value) {
91
- return Array.from(value, (character) => character.codePointAt(0).toString(36)).join("-");
92
- }
93
77
  function isRecord(value) {
94
78
  return typeof value === "object" && value !== null;
95
79
  }
96
80
 
97
81
  // src/index.ts
98
- var plugin = async (input, rawOptions) => {
99
- const options = parseOptions(rawOptions);
100
- const logFile = join(input.directory, "oc-agent-router.log");
101
- const log = (event, data) => {
102
- const entry = JSON.stringify({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), event, data });
103
- void appendFile(logFile, `${entry}
104
- `).catch((error) => {
105
- console.error("[oc-agent-router] failed to write log file", error);
106
- });
107
- };
108
- const routeableAgents = /* @__PURE__ */ new Set();
109
- const routedAgents = /* @__PURE__ */ new Map();
110
- log("plugin.initialized", { logFile });
111
- return {
112
- async config(config) {
113
- config.agent ??= {};
114
- const agents = config.agent;
115
- const sourceAgents = {
116
- general: agents.general ?? { mode: "subagent" },
117
- explore: agents.explore ?? { mode: "subagent" },
118
- ...Object.fromEntries(
119
- Object.entries(agents).filter(
120
- (entry) => Boolean(entry[1]) && !entry[0].startsWith("oc-agent-router-") && entry[0] !== "build" && entry[0] !== "plan" && entry[1]?.mode !== "primary" && entry[1]?.disable !== true
121
- )
122
- )
123
- };
124
- for (const [name, agent] of Object.entries(sourceAgents)) {
125
- routeableAgents.add(name);
126
- for (const model of options.models) {
127
- const routeName = routedAgentName(name, model);
128
- routedAgents.set(routeName, name);
129
- if (agents[routeName]) continue;
130
- agents[routeName] = routedAgentConfig(name, agent, model);
131
- }
132
- }
133
- },
134
- "tool.execute.before": async (input2, output) => {
135
- if (input2.tool !== "task") return;
136
- const args = output.args;
137
- if (typeof args.subagent_type !== "string" || typeof args.prompt !== "string" || args.task_id) return;
138
- if (args.subagent_type.startsWith("oc-agent-router-") || !routeableAgents.has(args.subagent_type)) return;
139
- const model = await selectModel(options, args, process.env[options.apiKeyEnv], fetch, log);
140
- const requestedAgent = args.subagent_type;
141
- const routedAgent = routedAgentName(requestedAgent, model);
142
- args.subagent_type = routedAgent;
143
- log("routing.applied", { requestedAgent, routedAgent, model });
144
- },
145
- event: async ({ event }) => {
146
- if (event.type !== "message.part.updated") return;
147
- const part = event.properties.part;
148
- if (part.type !== "tool" || part.tool !== "task") return;
149
- if (part.state.status !== "completed" && part.state.status !== "error") return;
150
- const routedAgent = part.state.input.subagent_type;
151
- if (typeof routedAgent !== "string") return;
152
- const requestedAgent = routedAgents.get(routedAgent);
153
- if (!requestedAgent) return;
154
- const client = input.client._client;
155
- if (!client) {
156
- log("routing.display_restore_failed", { reason: "missing_internal_client", requestedAgent, routedAgent });
82
+ var plugin = Plugin.define({
83
+ id: "oc-agent-router",
84
+ async setup(ctx) {
85
+ const options = parseOptions(ctx.options);
86
+ await ctx.tool.hook("execute.before", async (event) => {
87
+ if (event.tool !== "subagent") return;
88
+ const input = event.input;
89
+ if (typeof input.agent !== "string" || typeof input.prompt !== "string" || input.model !== void 0 || input.sessionID !== void 0 || options.agents !== void 0 && !options.agents.includes(input.agent))
157
90
  return;
158
- }
159
- const restoredPart = {
160
- ...part,
161
- state: {
162
- ...part.state,
163
- input: { ...part.state.input, subagent_type: requestedAgent }
164
- }
165
- };
166
- try {
167
- const result = await client.patch({
168
- url: "/session/{sessionID}/message/{messageID}/part/{partID}",
169
- path: { sessionID: part.sessionID, messageID: part.messageID, partID: part.id },
170
- body: restoredPart,
171
- headers: { "Content-Type": "application/json" }
172
- });
173
- if (result.error) {
174
- log("routing.display_restore_failed", { error: result.error, requestedAgent, routedAgent });
175
- return;
176
- }
177
- log("routing.display_restored", { requestedAgent, routedAgent });
178
- } catch (error) {
179
- log("routing.display_restore_failed", {
180
- error: error instanceof Error ? { name: error.name, message: error.message } : String(error),
181
- requestedAgent,
182
- routedAgent
183
- });
184
- }
185
- }
186
- };
187
- };
91
+ const model = await selectModel(options, input, process.env[options.apiKeyEnv], fetch);
92
+ if (model !== void 0) input.model = model;
93
+ });
94
+ }
95
+ });
188
96
  var index_default = plugin;
189
97
  export {
190
98
  index_default as default,
191
99
  parseOptions,
192
- routedAgentName,
193
100
  selectModel
194
101
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-agent-router",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Route OpenCode subagent tasks to configured models with Jev.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,10 +28,10 @@
28
28
  "verify": "npm run check && npm test && npm run build"
29
29
  },
30
30
  "peerDependencies": {
31
- "@opencode-ai/plugin": ">=1.18.0"
31
+ "@opencode/plugin": "^2.0.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@opencode-ai/plugin": "1.18.31",
34
+ "@opencode/plugin": "^2.0.15",
35
35
  "@types/node": "^24.0.0",
36
36
  "tsup": "^8.5.0",
37
37
  "tsx": "^4.20.0",