oc-agent-router 0.1.1 → 0.1.2
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 +17 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +139 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,11 +33,27 @@ All configured models must already be available in OpenCode. Restart OpenCode af
|
|
|
33
33
|
|
|
34
34
|
`TYPESAFE_API_KEY` is read only from the environment of the OpenCode process. Do not put it in `opencode.json`.
|
|
35
35
|
|
|
36
|
+
## Local Testing
|
|
37
|
+
|
|
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.
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npm run build
|
|
42
|
+
TYPESAFE_API_KEY=... opencode
|
|
43
|
+
```
|
|
44
|
+
|
|
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
|
+
|
|
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
|
+
|
|
36
51
|
## Options
|
|
37
52
|
|
|
38
53
|
| Option | Default | Description |
|
|
39
54
|
| --- | --- | --- |
|
|
40
55
|
| `models` | Required | Non-empty allowed model list in `provider/model` form. |
|
|
56
|
+
| `instructions` | Built-in routing guidance | Instructions sent to Jev to guide its model choice. |
|
|
41
57
|
| `fallbackModel` | First model | Used when the API key is missing, Jev errors, times out, or returns an invalid choice. Must be in `models`. |
|
|
42
58
|
| `jevModel` | `jev-latest` | TypeSafe model ID. Pin a version if routing behavior must be stable. |
|
|
43
59
|
| `apiKeyEnv` | `TYPESAFE_API_KEY` | Environment variable containing the TypeSafe API key. |
|
|
@@ -45,7 +61,7 @@ All configured models must already be available in OpenCode. Restart OpenCode af
|
|
|
45
61
|
|
|
46
62
|
## How It Works
|
|
47
63
|
|
|
48
|
-
The plugin
|
|
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.
|
|
49
65
|
|
|
50
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.
|
|
51
67
|
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Plugin } from '@opencode-ai/plugin';
|
|
|
3
3
|
interface RouterOptions {
|
|
4
4
|
/** Models eligible to receive subagent tasks, in OpenCode provider/model form. */
|
|
5
5
|
models: string[];
|
|
6
|
+
/** Routing guidance sent to Jev. */
|
|
7
|
+
instructions?: string;
|
|
6
8
|
/** TypeSafe model ID used for routing. Defaults to jev-latest. */
|
|
7
9
|
jevModel?: string;
|
|
8
10
|
/** Environment variable containing the TypeSafe API key. Defaults to TYPESAFE_API_KEY. */
|
|
@@ -24,9 +26,11 @@ interface FetchResponse {
|
|
|
24
26
|
json(): Promise<unknown>;
|
|
25
27
|
}
|
|
26
28
|
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;
|
|
27
31
|
declare function parseOptions(value: unknown): Required<RouterOptions>;
|
|
28
|
-
declare function selectModel(options: Required<RouterOptions>, args: TaskArgs, apiKey: string | undefined, fetcher?: Fetcher): Promise<string>;
|
|
32
|
+
declare function selectModel(options: Required<RouterOptions>, args: TaskArgs, apiKey: string | undefined, fetcher?: Fetcher, log?: Logger): Promise<string>;
|
|
29
33
|
|
|
30
34
|
declare const plugin: Plugin;
|
|
31
35
|
|
|
32
|
-
export { type RouterOptions, plugin as default, parseOptions, selectModel };
|
|
36
|
+
export { type RouterOptions, plugin as default, parseOptions, routedAgentName, selectModel };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { appendFile } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
1
5
|
// 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
|
+
}
|
|
2
18
|
function parseOptions(value) {
|
|
3
19
|
if (!isRecord(value) || !Array.isArray(value.models)) {
|
|
4
20
|
throw new Error("oc-agent-router requires a non-empty models array");
|
|
@@ -11,42 +27,57 @@ function parseOptions(value) {
|
|
|
11
27
|
if (!models.includes(fallbackModel)) throw new Error("oc-agent-router fallbackModel must appear in models");
|
|
12
28
|
return {
|
|
13
29
|
models,
|
|
30
|
+
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.",
|
|
14
31
|
jevModel: typeof value.jevModel === "string" ? value.jevModel : "jev-latest",
|
|
15
32
|
apiKeyEnv: typeof value.apiKeyEnv === "string" ? value.apiKeyEnv : "TYPESAFE_API_KEY",
|
|
16
33
|
timeoutMs: typeof value.timeoutMs === "number" && value.timeoutMs >= 100 && value.timeoutMs <= 3e4 ? value.timeoutMs : 5e3,
|
|
17
34
|
fallbackModel
|
|
18
35
|
};
|
|
19
36
|
}
|
|
20
|
-
async function selectModel(options, args, apiKey, fetcher = fetch) {
|
|
21
|
-
|
|
37
|
+
async function selectModel(options, args, apiKey, fetcher = fetch, log = () => {
|
|
38
|
+
}) {
|
|
39
|
+
if (!apiKey) {
|
|
40
|
+
log("routing.skipped", { reason: "missing_api_key", fallbackModel: options.fallbackModel });
|
|
41
|
+
return options.fallbackModel;
|
|
42
|
+
}
|
|
22
43
|
const controller = new AbortController();
|
|
23
44
|
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
24
45
|
try {
|
|
46
|
+
const body = {
|
|
47
|
+
model: options.jevModel,
|
|
48
|
+
state: {
|
|
49
|
+
task: typeof args.prompt === "string" ? args.prompt : "",
|
|
50
|
+
description: typeof args.description === "string" ? args.description : "",
|
|
51
|
+
requested_agent: typeof args.subagent_type === "string" ? args.subagent_type : ""
|
|
52
|
+
},
|
|
53
|
+
questions: {
|
|
54
|
+
model: {
|
|
55
|
+
type: "choice",
|
|
56
|
+
instructions: options.instructions,
|
|
57
|
+
criteria: Object.fromEntries(options.models.map((model2) => [model2, `Use the configured OpenCode model ${model2}.`]))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
log("routing.request", body);
|
|
25
62
|
const response = await fetcher("https://api.typesafe.ai/v1/systemone", {
|
|
26
63
|
method: "POST",
|
|
27
64
|
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
28
|
-
body: JSON.stringify(
|
|
29
|
-
model: options.jevModel,
|
|
30
|
-
state: {
|
|
31
|
-
task: typeof args.prompt === "string" ? args.prompt : "",
|
|
32
|
-
description: typeof args.description === "string" ? args.description : "",
|
|
33
|
-
requested_agent: typeof args.subagent_type === "string" ? args.subagent_type : ""
|
|
34
|
-
},
|
|
35
|
-
questions: {
|
|
36
|
-
model: {
|
|
37
|
-
type: "choice",
|
|
38
|
-
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.",
|
|
39
|
-
criteria: Object.fromEntries(options.models.map((model) => [model, `Use the configured OpenCode model ${model}.`]))
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}),
|
|
65
|
+
body: JSON.stringify(body),
|
|
43
66
|
signal: controller.signal
|
|
44
67
|
});
|
|
68
|
+
log("routing.response.status", { ok: response.ok, status: response.status });
|
|
69
|
+
const responseBody = await response.json();
|
|
70
|
+
log("routing.response.body", responseBody);
|
|
45
71
|
if (!response.ok) return options.fallbackModel;
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
});
|
|
50
81
|
return options.fallbackModel;
|
|
51
82
|
} finally {
|
|
52
83
|
clearTimeout(timeout);
|
|
@@ -56,20 +87,101 @@ function isModel(value) {
|
|
|
56
87
|
const separator = value.indexOf("/");
|
|
57
88
|
return separator > 0 && separator < value.length - 1;
|
|
58
89
|
}
|
|
90
|
+
function encode(value) {
|
|
91
|
+
return Array.from(value, (character) => character.codePointAt(0).toString(36)).join("-");
|
|
92
|
+
}
|
|
59
93
|
function isRecord(value) {
|
|
60
94
|
return typeof value === "object" && value !== null;
|
|
61
95
|
}
|
|
62
96
|
|
|
63
97
|
// src/index.ts
|
|
64
|
-
var plugin = async (
|
|
98
|
+
var plugin = async (input, rawOptions) => {
|
|
65
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 });
|
|
66
111
|
return {
|
|
67
|
-
|
|
68
|
-
|
|
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;
|
|
69
136
|
const args = output.args;
|
|
70
137
|
if (typeof args.subagent_type !== "string" || typeof args.prompt !== "string" || args.task_id) return;
|
|
71
|
-
|
|
72
|
-
|
|
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 });
|
|
157
|
+
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
|
+
}
|
|
73
185
|
}
|
|
74
186
|
};
|
|
75
187
|
};
|
|
@@ -77,5 +189,6 @@ var index_default = plugin;
|
|
|
77
189
|
export {
|
|
78
190
|
index_default as default,
|
|
79
191
|
parseOptions,
|
|
192
|
+
routedAgentName,
|
|
80
193
|
selectModel
|
|
81
194
|
};
|