@reasoning-router/opencode 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert N
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, 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 CHANGED
@@ -1,3 +1,81 @@
1
- # Temporary Holding Version
1
+ # @reasoning-router/opencode
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [OpenCode](https://opencode.ai) V2 plugin that asks a classifier how much
4
+ reasoning each step needs, then applies that effort to the outgoing model
5
+ request without breaking the prompt cache. Every classifier in
6
+ [`@reasoning-router/classifiers`](../classifiers) (Jev, Cloudflare Clef,
7
+ and Laya) is available.
8
+
9
+ > **Alpha.** This package is not published to npm yet, and OpenCode installs
10
+ > plugins by package name from a registry. The configuration below works
11
+ > after the first release. Until then, `npm run smoke:plugin:v2` installs the
12
+ > packed plugin into OpenCode 2.0.18 from a local test registry.
13
+
14
+ Ported from
15
+ [`opencode-jev-router`](https://github.com/robertn702/opencode-jev-router).
16
+
17
+ ## Requirements
18
+
19
+ - OpenCode V2 2.0.4 or newer (smoke-tested with 2.0.18).
20
+ - A classifier key. For Jev: a [TypeSafe](https://typesafe.ai/) key, or a
21
+ [Vercel AI Gateway](https://vercel.com/docs/ai-gateway/sdks-and-apis/typesafe)
22
+ key used with `baseUrl: "https://ai-gateway.vercel.sh/typesafe"`. For Clef:
23
+ a Cloudflare Workers AI API token and account ID. For Laya: a Laya server
24
+ you run (see [`docs/environment.md`](../../docs/environment.md)).
25
+ - A Responses API endpoint serving GPT-6 Astra, Luna, or Sol, or an Anthropic
26
+ Messages endpoint with the mid-conversation output-config beta.
27
+
28
+ ## Usage
29
+
30
+ ```jsonc
31
+ {
32
+ "$schema": "https://opencode.ai/config.json",
33
+ "plugins": [{ "package": "@reasoning-router/opencode", "options": {
34
+ "classifier": { "provider": "jev", "apiKey": "{env:REASONING_ROUTER_CLASSIFIER_API_KEY}" },
35
+ "wrap": { "openai": ["openai/gpt-6-astra"] },
36
+ "decisionsLogPath": "/tmp/reasoning-decisions.jsonl"
37
+ }}],
38
+ "model": "reasoning-router/gpt-6-astra"
39
+ }
40
+ ```
41
+
42
+ The plugin registers `reasoning-router/<profile>` aliases only for the source
43
+ models listed in `wrap` (`openai` and/or `anthropic` arrays of
44
+ `provider/model` refs). Source models are left untouched. Each primary request
45
+ to an alias gets one classification; if the classifier fails, the request
46
+ continues at the fallback effort (`high` by default). With
47
+ `decisionsLogPath`, each routed request appends a metadata-only
48
+ `ReasoningDecision` event that records the deciding `classifier`.
49
+
50
+ See [`examples/opencode.jsonc`](../../examples/opencode.jsonc) for a full
51
+ example.
52
+
53
+ ## Options
54
+
55
+ | Option | Default | Purpose |
56
+ | --- | --- | --- |
57
+ | `classifier.provider` | `REASONING_ROUTER_CLASSIFIER` env, then `jev` | Classifier provider: `jev`, `clef`, or `laya`. |
58
+ | `classifier.apiKey` | `REASONING_ROUTER_CLASSIFIER_API_KEY` env | Classifier key. Required for `jev` and `clef` unless `fixedEffort` is set; optional for `laya`. |
59
+ | `classifier.baseUrl` | `REASONING_ROUTER_CLASSIFIER_BASE_URL` env, then the provider default | Jev endpoint, or the Laya server (default `http://127.0.0.1:8000`). |
60
+ | `classifier.accountId` | `REASONING_ROUTER_CLASSIFIER_ACCOUNT_ID` env | Clef: Cloudflare account ID. |
61
+ | `classifier.model` | `REASONING_ROUTER_CLASSIFIER_MODEL` env | Clef: `clef` or `clef-flash` (required). |
62
+ | `classifier.timeoutMs` | `4000` | Total classification budget, including retries. |
63
+ | `wrap` | none | Required nonempty object of `openai`/`anthropic` source refs. |
64
+ | `decisionsLogPath` | off | Absolute path for `ReasoningDecision` JSONL. |
65
+ | `baseEffort` | profile default | Request-level effort reported by responses. |
66
+ | `fixedEffort` | none | Skip the classifier and always use this effort. |
67
+ | `maxRetries` | `1` | Extra attempts after transient classifier errors. |
68
+ | `fallbackMode` | `fixed` | `fixed`, `previous`, or `error`. |
69
+ | `fallbackEffort` | `high` | Effort used when classification fails. |
70
+ | `maxRequestBytes` | `1048576` | Largest request body. |
71
+ | `maxInFlight` | `32` | Concurrent requests. |
72
+ | `upstreamHeaderTimeoutMs` | `10000` | Wait for endpoint response headers. |
73
+ | `upstreamIdleTimeoutMs` | `60000` | Longest gap between streamed chunks. |
74
+
75
+ Wire behavior, logging, and limits are documented in
76
+ [`docs/behavior.md`](../../docs/behavior.md) and
77
+ [`docs/classification-policy.md`](../../docs/classification-policy.md).
78
+
79
+ ## License
80
+
81
+ [MIT](LICENSE)
@@ -0,0 +1,88 @@
1
+ import { type Provider } from "@reasoning-router/core";
2
+ import { z } from "zod";
3
+ declare const pluginOptionsSchema: z.ZodObject<{
4
+ maxRetries: z.ZodDefault<z.ZodInt>;
5
+ fallbackMode: z.ZodDefault<z.ZodEnum<{
6
+ error: "error";
7
+ fixed: "fixed";
8
+ previous: "previous";
9
+ }>>;
10
+ fallbackEffort: z.ZodDefault<z.ZodEnum<{
11
+ high: "high";
12
+ low: "low";
13
+ max: "max";
14
+ medium: "medium";
15
+ none: "none";
16
+ xhigh: "xhigh";
17
+ }>>;
18
+ classifier: z.ZodPrefault<z.ZodObject<{
19
+ provider: z.ZodOptional<z.ZodString>;
20
+ timeoutMs: z.ZodOptional<z.ZodInt>;
21
+ apiKey: z.ZodOptional<z.ZodString>;
22
+ baseUrl: z.ZodOptional<z.ZodString>;
23
+ accountId: z.ZodOptional<z.ZodString>;
24
+ model: z.ZodOptional<z.ZodString>;
25
+ }, z.core.$loose>>;
26
+ baseEffort: z.ZodOptional<z.ZodEnum<{
27
+ high: "high";
28
+ low: "low";
29
+ max: "max";
30
+ medium: "medium";
31
+ none: "none";
32
+ xhigh: "xhigh";
33
+ }>>;
34
+ fixedEffort: z.ZodOptional<z.ZodEnum<{
35
+ high: "high";
36
+ low: "low";
37
+ max: "max";
38
+ medium: "medium";
39
+ none: "none";
40
+ xhigh: "xhigh";
41
+ }>>;
42
+ maxRequestBytes: z.ZodDefault<z.ZodInt>;
43
+ maxInFlight: z.ZodDefault<z.ZodInt>;
44
+ upstreamHeaderTimeoutMs: z.ZodDefault<z.ZodInt>;
45
+ upstreamIdleTimeoutMs: z.ZodDefault<z.ZodInt>;
46
+ decisionsLogPath: z.ZodOptional<z.ZodString>;
47
+ }, z.core.$strip>;
48
+ /** Options for the OpenCode V2 plugin runtime. */
49
+ export type PluginOptions = z.input<typeof pluginOptionsSchema>;
50
+ /** A local rejection with an HTTP status for the plugin request hook. */
51
+ export declare class PluginRequestError extends Error {
52
+ readonly status: number;
53
+ readonly code: string;
54
+ constructor(status: number, code: string, message: string);
55
+ }
56
+ export interface Correlation {
57
+ session: string | null;
58
+ turnId: string | null;
59
+ }
60
+ /**
61
+ * One routed Responses exchange. After `start` resolves the caller owns it and
62
+ * must settle it with `respond`, `fail`, or `cancel`; the header timeout also
63
+ * settles it when no response ever arrives.
64
+ */
65
+ export interface Exchange {
66
+ readonly url: string;
67
+ readonly headers: Headers;
68
+ readonly body: string;
69
+ /** Aborts on caller cancellation, disposal, header timeout, or idle timeout. */
70
+ readonly signal: AbortSignal;
71
+ /** Wrap upstream response bytes for usage observation without buffering. */
72
+ respond(upstream: Response): Response;
73
+ /** Settle a transport failure before response headers. */
74
+ fail(cause: unknown): PluginRequestError;
75
+ /** Settle a caller cancellation before response headers. */
76
+ cancel(): void;
77
+ }
78
+ export interface PluginRuntime {
79
+ start(request: Request, correlation: Correlation, group?: Provider): Promise<Exchange>;
80
+ dispose(): void;
81
+ }
82
+ export declare const SESSION: RegExp;
83
+ export declare const TURN: RegExp;
84
+ export declare const valid: (value: string | null | undefined, pattern: RegExp) => string | null;
85
+ export declare const requiredString: (value: unknown, name: string) => string;
86
+ /** Per-plugin-instance state, validation, classifier selection, rewrite, and usage observation. */
87
+ export declare function createPluginRuntime(options: unknown): PluginRuntime;
88
+ export {};
@@ -0,0 +1,389 @@
1
+ import { createHash } from "node:crypto";
2
+ import { classifierProviders } from "@reasoning-router/classifiers";
3
+ import { anthropicVersion, buildPluginUpstreamRequestHeaders, classificationPolicySchema, createConfiguredSelector, createDecisionLogger, mergeAnthropicBeta, parseConfig, pickFetchResponseHeaders, ResponsesRouter, resolveModel, UnsupportedInputError, UsageObserver, universalEffort, upstreamHostname, validateRequest, wireFor, } from "@reasoning-router/core";
4
+ import { z } from "zod";
5
+ const positiveInteger = (name, fallback) => {
6
+ const message = `${name} must be a positive integer`;
7
+ return z.int(message).min(1, message).default(fallback);
8
+ };
9
+ const optionalString = (name) => z.string(`${name} must be a string`).optional();
10
+ const TIMEOUT = "timeoutMs must be a positive integer";
11
+ const pluginOptionsSchema = z.object({
12
+ ...classificationPolicySchema.shape,
13
+ /** `provider` (default `REASONING_ROUTER_CLASSIFIER`, then `jev`) plus that provider's settings. */
14
+ classifier: z
15
+ .looseObject({
16
+ provider: optionalString("classifier.provider"),
17
+ timeoutMs: z.int(TIMEOUT).min(1, TIMEOUT).optional(),
18
+ apiKey: optionalString("classifier.apiKey"),
19
+ baseUrl: optionalString("classifier.baseUrl"),
20
+ accountId: optionalString("classifier.accountId"),
21
+ model: optionalString("classifier.model"),
22
+ }, "classifier must be an object")
23
+ .prefault({}),
24
+ baseEffort: universalEffort("baseEffort is unsupported").optional(),
25
+ fixedEffort: universalEffort("fixedEffort must be supported by every model").optional(),
26
+ maxRequestBytes: positiveInteger("maxRequestBytes", 1_048_576),
27
+ maxInFlight: positiveInteger("maxInFlight", 32),
28
+ upstreamHeaderTimeoutMs: positiveInteger("upstreamHeaderTimeoutMs", 10_000),
29
+ upstreamIdleTimeoutMs: positiveInteger("upstreamIdleTimeoutMs", 60_000),
30
+ decisionsLogPath: z
31
+ .string("decisionsLogPath must be an absolute path")
32
+ .optional(),
33
+ });
34
+ /** A local rejection with an HTTP status for the plugin request hook. */
35
+ export class PluginRequestError extends Error {
36
+ status;
37
+ code;
38
+ constructor(status, code, message) {
39
+ super(message);
40
+ this.status = status;
41
+ this.code = code;
42
+ this.name = "PluginRequestError";
43
+ }
44
+ }
45
+ export const SESSION = /^ses_[A-Za-z0-9]{1,128}$/;
46
+ export const TURN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
47
+ export const valid = (value, pattern) => (value && pattern.test(value) ? value : null);
48
+ const hash = (value) => createHash("sha256")
49
+ .update(value ?? "")
50
+ .digest("hex");
51
+ export const requiredString = (value, name) => {
52
+ if (typeof value !== "string" || !value.trim())
53
+ throw new Error(`${name} is required`);
54
+ return value;
55
+ };
56
+ const HTTP_URL = "must be an HTTP(S) URL without credentials, query, or fragment";
57
+ const checkUpstreamURL = (url, name) => {
58
+ if (!/^https?:$/.test(url.protocol) ||
59
+ !url.hostname ||
60
+ url.username ||
61
+ url.password ||
62
+ url.search ||
63
+ url.hash)
64
+ throw new Error(`${name} ${HTTP_URL}`);
65
+ const loopback = ["127.0.0.1", "localhost", "::1"].includes(upstreamHostname(url));
66
+ if (url.protocol !== "https:" && !loopback)
67
+ throw new Error(`${name} requires HTTPS except for loopback endpoints`);
68
+ };
69
+ async function boundedBody(request, maxBytes, signal) {
70
+ const declared = request.headers.get("content-length");
71
+ if (declared !== null &&
72
+ (!/^\d+$/.test(declared) || Number(declared) > maxBytes))
73
+ throw new RangeError("request_too_large");
74
+ if (request.body === null)
75
+ return new Uint8Array();
76
+ const reader = request.body.getReader();
77
+ const chunks = [];
78
+ let size = 0;
79
+ const aborted = new Promise((_, reject) => signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }));
80
+ try {
81
+ for (;;) {
82
+ const next = await Promise.race([reader.read(), aborted]);
83
+ if (next.done)
84
+ break;
85
+ size += next.value.byteLength;
86
+ if (size > maxBytes)
87
+ throw new RangeError("request_too_large");
88
+ chunks.push(next.value);
89
+ }
90
+ }
91
+ finally {
92
+ if (signal.aborted || size > maxBytes)
93
+ await reader.cancel().catch(() => undefined);
94
+ }
95
+ const output = new Uint8Array(size);
96
+ let at = 0;
97
+ for (const chunk of chunks) {
98
+ output.set(chunk, at);
99
+ at += chunk.byteLength;
100
+ }
101
+ return output;
102
+ }
103
+ function classifierSelector({ classifier, maxRetries, fallbackMode, fallbackEffort, }) {
104
+ const env = process.env;
105
+ return createConfiguredSelector({
106
+ ...classifier,
107
+ provider: classifier.provider ?? (env.REASONING_ROUTER_CLASSIFIER || "jev"),
108
+ apiKey: classifier.apiKey ?? env.REASONING_ROUTER_CLASSIFIER_API_KEY,
109
+ baseUrl: classifier.baseUrl ?? env.REASONING_ROUTER_CLASSIFIER_BASE_URL,
110
+ accountId: classifier.accountId ??
111
+ (env.REASONING_ROUTER_CLASSIFIER_ACCOUNT_ID || undefined),
112
+ model: classifier.model ??
113
+ (env.REASONING_ROUTER_CLASSIFIER_MODEL || undefined),
114
+ }, classifierProviders, { maxRetries, fallbackMode, fallbackEffort });
115
+ }
116
+ /** Per-plugin-instance state, validation, classifier selection, rewrite, and usage observation. */
117
+ export function createPluginRuntime(options) {
118
+ const config = parseConfig(pluginOptionsSchema, options);
119
+ const { baseEffort, fixedEffort, decisionsLogPath, maxRequestBytes: maxBytes, maxInFlight, upstreamHeaderTimeoutMs: headerTimeoutMs, upstreamIdleTimeoutMs: idleTimeoutMs, } = config;
120
+ const selectEffort = fixedEffort === undefined
121
+ ? classifierSelector(config)
122
+ : async () => ({
123
+ effort: fixedEffort,
124
+ classifierLatencyMs: 0,
125
+ fallback: null,
126
+ });
127
+ const onEvidence = decisionsLogPath === undefined
128
+ ? undefined
129
+ : createDecisionLogger(decisionsLogPath);
130
+ const router = new ResponsesRouter({
131
+ baseEffort,
132
+ selectEffort,
133
+ onEvidence,
134
+ });
135
+ // Each open exchange's controller maps to its incoming request. Undici follows a
136
+ // Request's init signal through a weak reference, and the host drops the original
137
+ // request once the hook replaces it, so holding it here keeps session
138
+ // cancellation reaching `request.signal` until the exchange settles.
139
+ const controllers = new Map();
140
+ // Disposal settles every open exchange: V2 never sees a response for an aborted fetch.
141
+ const abandons = new Set();
142
+ let inFlight = 0;
143
+ let disposed = false;
144
+ async function start(request, correlation, group) {
145
+ if (disposed)
146
+ throw new PluginRequestError(503, "unavailable", "reasoning-router plugin is disposed");
147
+ const url = new URL(request.url);
148
+ const provider = url.pathname.endsWith("/responses")
149
+ ? "openai"
150
+ : url.pathname.endsWith("/messages")
151
+ ? "anthropic"
152
+ : null;
153
+ if (provider === null || request.method !== "POST")
154
+ throw new PluginRequestError(400, "invalid_request", "reasoning-router supports POST /v1/responses or /v1/messages only");
155
+ if (group !== undefined && provider !== group)
156
+ throw new PluginRequestError(400, "invalid_request", `alias requires ${group === "openai" ? "/responses" : "/messages"}`);
157
+ try {
158
+ checkUpstreamURL(new URL(url.origin), "upstream URL");
159
+ }
160
+ catch (cause) {
161
+ throw new PluginRequestError(400, "invalid_request", cause instanceof Error ? cause.message : String(cause));
162
+ }
163
+ if (inFlight >= maxInFlight)
164
+ throw new PluginRequestError(503, "overloaded", "router overloaded");
165
+ inFlight += 1;
166
+ const controller = new AbortController();
167
+ controllers.set(controller, request);
168
+ const signal = AbortSignal.any([request.signal, controller.signal]);
169
+ let releaseDone = false;
170
+ let prepared = null;
171
+ let handedOff = false;
172
+ let timedOut = false;
173
+ let responded = false;
174
+ let timer;
175
+ const release = () => {
176
+ if (!releaseDone) {
177
+ releaseDone = true;
178
+ clearTimeout(timer);
179
+ controllers.delete(controller);
180
+ abandons.delete(abandon);
181
+ inFlight -= 1;
182
+ }
183
+ };
184
+ const discard = (outcome) => prepared?.finish(outcome, 0, false);
185
+ const abandon = () => {
186
+ discard("failed");
187
+ release();
188
+ };
189
+ abandons.add(abandon);
190
+ const settle = (outcome) => {
191
+ if (!responded) {
192
+ discard(outcome);
193
+ release();
194
+ }
195
+ };
196
+ try {
197
+ let body;
198
+ try {
199
+ body = JSON.parse(new TextDecoder().decode(await boundedBody(request, maxBytes, signal)));
200
+ }
201
+ catch (cause) {
202
+ if (cause instanceof RangeError)
203
+ throw new PluginRequestError(413, "request_too_large", "request_too_large");
204
+ if (signal.aborted)
205
+ throw new PluginRequestError(499, "cancelled", "request cancelled");
206
+ throw new PluginRequestError(400, "invalid_request", "request body must be valid JSON");
207
+ }
208
+ // Validate before reading optional fields or calling the classifier.
209
+ const model = resolveModel(body);
210
+ const record = validateRequest(body, model, provider);
211
+ const credential = provider === "anthropic"
212
+ ? (request.headers.get("x-api-key") ??
213
+ request.headers.get("authorization"))
214
+ : request.headers.get("authorization");
215
+ const wire = wireFor(provider);
216
+ const headers = buildPluginUpstreamRequestHeaders(request.headers, "");
217
+ // OpenAI headers stay as before; only Anthropic requests drop OpenAI-only headers.
218
+ if (provider === "anthropic") {
219
+ for (const name of [...headers.keys()])
220
+ if (name.startsWith("openai-"))
221
+ headers.delete(name);
222
+ headers.set("anthropic-version", anthropicVersion(headers.get("anthropic-version")));
223
+ headers.set("anthropic-beta", mergeAnthropicBeta(headers.get("anthropic-beta")));
224
+ }
225
+ const lineageKey = wire.lineageKey(record);
226
+ const { session, turnId } = correlation;
227
+ prepared = await router.prepare(body, {
228
+ provider,
229
+ signal,
230
+ session,
231
+ turnId,
232
+ cacheScope: hash(credential),
233
+ scope: session || lineageKey
234
+ ? [
235
+ `${url.origin}${url.pathname.replace(/\/(responses|messages)$/, "")}`,
236
+ model.id,
237
+ baseEffort ?? model.defaultBaseEffort,
238
+ hash(credential),
239
+ session ?? "",
240
+ lineageKey ?? "",
241
+ ...wire.scopeParts(record),
242
+ ...(provider === "anthropic"
243
+ ? [
244
+ headers.get("anthropic-beta"),
245
+ headers.get("anthropic-version"),
246
+ ]
247
+ : []),
248
+ ]
249
+ : null,
250
+ });
251
+ if (prepared === null)
252
+ throw new PluginRequestError(499, "cancelled", "request cancelled");
253
+ const encoded = JSON.stringify(prepared.body);
254
+ headers.set("content-length", String(Buffer.byteLength(encoded)));
255
+ timer = setTimeout(() => {
256
+ timedOut = true;
257
+ controller.abort();
258
+ settle("failed");
259
+ }, headerTimeoutMs);
260
+ const exchange = {
261
+ url: request.url,
262
+ headers,
263
+ body: encoded,
264
+ signal,
265
+ respond(upstream) {
266
+ clearTimeout(timer);
267
+ if (responded)
268
+ throw new Error("exchange already responded");
269
+ if (releaseDone) {
270
+ // Headers arrived after the deadline or disposal already settled this exchange.
271
+ void upstream.body?.cancel().catch(() => undefined);
272
+ throw timedOut
273
+ ? new PluginRequestError(504, "upstream_timeout", "upstream_timeout")
274
+ : disposed
275
+ ? new PluginRequestError(503, "unavailable", "reasoning-router plugin is disposed")
276
+ : new PluginRequestError(499, "cancelled", "request cancelled");
277
+ }
278
+ responded = true;
279
+ const current = prepared;
280
+ const observer = new UsageObserver((upstream.headers.get("content-type") ?? "").includes("text/event-stream"), provider);
281
+ if (upstream.body === null) {
282
+ current.finish("completed", upstream.status, false);
283
+ release();
284
+ return new Response(null, {
285
+ status: upstream.status,
286
+ headers: pickFetchResponseHeaders(upstream.headers),
287
+ });
288
+ }
289
+ const reader = upstream.body.getReader();
290
+ // A read pending at cancellation settles (done or aborted) after it; cancel() records the outcome.
291
+ let cancelled = false;
292
+ const stream = new ReadableStream({
293
+ async pull(output) {
294
+ const idle = setTimeout(() => controller.abort(), idleTimeoutMs);
295
+ try {
296
+ const next = await reader.read();
297
+ if (cancelled)
298
+ return;
299
+ if (next.done) {
300
+ observer.finish();
301
+ current.finish("completed", upstream.status, observer.completed, observer.usage);
302
+ output.close();
303
+ release();
304
+ }
305
+ else {
306
+ observer.push(next.value);
307
+ output.enqueue(next.value);
308
+ }
309
+ }
310
+ catch (cause) {
311
+ if (cancelled)
312
+ return;
313
+ current.finish("failed", 0, false);
314
+ output.error(cause);
315
+ release();
316
+ }
317
+ finally {
318
+ clearTimeout(idle);
319
+ }
320
+ },
321
+ // OpenCode V2 cancels the body once it parses `response.completed`,
322
+ // before EOF. A terminal event already forwarded still completes.
323
+ async cancel() {
324
+ cancelled = true;
325
+ controller.abort();
326
+ try {
327
+ await reader.cancel();
328
+ }
329
+ finally {
330
+ if (observer.completed)
331
+ current.finish("completed", upstream.status, true, observer.usage);
332
+ else
333
+ current.finish("cancelled", 0, false);
334
+ release();
335
+ }
336
+ },
337
+ });
338
+ return new Response(stream, {
339
+ status: upstream.status,
340
+ statusText: upstream.statusText,
341
+ headers: pickFetchResponseHeaders(upstream.headers),
342
+ });
343
+ },
344
+ fail() {
345
+ settle("failed");
346
+ if (timedOut)
347
+ return new PluginRequestError(504, "upstream_timeout", "upstream_timeout");
348
+ if (signal.aborted)
349
+ return new PluginRequestError(499, "cancelled", "request cancelled");
350
+ return new PluginRequestError(502, "upstream_unavailable", "upstream_unavailable");
351
+ },
352
+ cancel() {
353
+ settle("cancelled");
354
+ },
355
+ };
356
+ handedOff = true;
357
+ return exchange;
358
+ }
359
+ catch (cause) {
360
+ discard("failed");
361
+ if (cause instanceof PluginRequestError)
362
+ throw cause;
363
+ if (cause instanceof Error && cause.message === "classification_failed")
364
+ throw new PluginRequestError(502, "classification_failed", "classification_failed");
365
+ if (cause instanceof UnsupportedInputError)
366
+ throw new PluginRequestError(400, "invalid_request", cause.message);
367
+ if (signal.aborted)
368
+ throw new PluginRequestError(499, "cancelled", "request cancelled");
369
+ throw new PluginRequestError(502, "upstream_unavailable", "upstream_unavailable");
370
+ }
371
+ finally {
372
+ if (!handedOff) {
373
+ discard("failed");
374
+ release();
375
+ }
376
+ }
377
+ }
378
+ return {
379
+ start,
380
+ dispose() {
381
+ disposed = true;
382
+ for (const controller of controllers.keys())
383
+ controller.abort();
384
+ for (const abandon of [...abandons])
385
+ abandon();
386
+ router.reset();
387
+ },
388
+ };
389
+ }
@@ -0,0 +1,11 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ /** The part of the plugin context the router uses. */
3
+ export type Host = Pick<Plugin.Context, "options"> & {
4
+ readonly provider: Pick<Plugin.Context["provider"], "transform">;
5
+ readonly model: Pick<Plugin.Context["model"], "transform">;
6
+ readonly integration: Pick<Plugin.Context["integration"], "connection">;
7
+ readonly session: Pick<Plugin.Context["session"], "hook">;
8
+ };
9
+ export declare const PROVIDER_ID = "reasoning-router";
10
+ export declare const PROVIDER_PACKAGE = "@opencode/ai/providers/openai/responses";
11
+ export declare function setupV2(ctx: Host): Promise<() => void>;
@@ -0,0 +1,237 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { MODELS, modelsFor, parseConfig, } from "@reasoning-router/core";
3
+ import { z } from "zod";
4
+ import { createPluginRuntime, PluginRequestError, SESSION, valid, } from "./plugin-runtime.js";
5
+ /**
6
+ * OpenCode's model and provider IDs are `Schema.String` brands with no other
7
+ * checks, so this is the check `ID.make` performs, without importing the SDK
8
+ * at runtime.
9
+ */
10
+ const isID = (value) => typeof value === "string";
11
+ const toID = (value) => {
12
+ if (isID(value))
13
+ return value;
14
+ throw new TypeError(`reasoning-router: invalid OpenCode ID ${value}`);
15
+ };
16
+ export const PROVIDER_ID = "reasoning-router";
17
+ export const PROVIDER_PACKAGE = "@opencode/ai/providers/openai/responses";
18
+ const PACKAGES = {
19
+ openai: PROVIDER_PACKAGE,
20
+ anthropic: "@opencode/ai/providers/anthropic",
21
+ };
22
+ const rejection = (cause) => cause instanceof PluginRequestError
23
+ ? new Error(`reasoning-router ${cause.code} (${cause.status}): ${cause.message}`)
24
+ : new Error("reasoning-router upstream_unavailable (502): upstream_unavailable");
25
+ const removed = (key) => z
26
+ .never(`reasoning-router: ${key} was removed; configure wrap instead`)
27
+ .optional();
28
+ const wrapRefs = (group) => {
29
+ const array = `reasoning-router: wrap.${group} must be a nonempty array of provider/model refs`;
30
+ const ref = `reasoning-router: wrap.${group} requires provider/model refs from another provider`;
31
+ return z
32
+ .array(z
33
+ .string(ref)
34
+ .regex(/^[^/\s]+\/[^/\s]+$/, ref)
35
+ .refine((value) => !value.startsWith(`${PROVIDER_ID}/`), ref), array)
36
+ .min(1, array)
37
+ .optional();
38
+ };
39
+ const WRAP = "reasoning-router: wrap must be a nonempty object of openai/anthropic model refs";
40
+ const wrapOptionsSchema = z.object({
41
+ upstreamBaseURL: removed("upstreamBaseURL"),
42
+ upstreamApiKey: removed("upstreamApiKey"),
43
+ anthropicUpstreamBaseURL: removed("anthropicUpstreamBaseURL"),
44
+ anthropicUpstreamApiKey: removed("anthropicUpstreamApiKey"),
45
+ wrap: z
46
+ .strictObject({ openai: wrapRefs("openai"), anthropic: wrapRefs("anthropic") }, {
47
+ error: (issue) => issue.code === "unrecognized_keys"
48
+ ? `reasoning-router: unknown wrap group ${issue.keys.join(", ")}`
49
+ : WRAP,
50
+ })
51
+ .refine((wrap) => wrap.openai || wrap.anthropic, WRAP),
52
+ });
53
+ function parseWrap(options) {
54
+ const { wrap } = parseConfig(wrapOptionsSchema, options);
55
+ return ["openai", "anthropic"].flatMap((group) => (wrap[group] ?? []).map((ref) => {
56
+ const slash = ref.indexOf("/");
57
+ return {
58
+ group,
59
+ providerID: ref.slice(0, slash),
60
+ modelID: ref.slice(slash + 1),
61
+ ref,
62
+ };
63
+ }));
64
+ }
65
+ export async function setupV2(ctx) {
66
+ const refs = parseWrap(ctx.options);
67
+ const runtime = createPluginRuntime(ctx.options);
68
+ const exchanges = new WeakMap();
69
+ let aliases = new Map();
70
+ let validationError;
71
+ await ctx.provider.transform((editor) => {
72
+ const models = [...new Set(refs.map((ref) => ref.group))].flatMap((group) => modelsFor(group).map((profile) => ({
73
+ id: toID(profile.id),
74
+ modelID: toID(profile.id),
75
+ providerID: toID(PROVIDER_ID),
76
+ name: profile.name,
77
+ package: PACKAGES[group],
78
+ settings: { baseURL: "http://127.0.0.1:1/v1" },
79
+ capabilities: {
80
+ tools: true,
81
+ input: ["text", "image"],
82
+ output: ["text"],
83
+ },
84
+ variants: [],
85
+ time: { released: 0 },
86
+ cost: [],
87
+ status: "active",
88
+ enabled: true,
89
+ limit: { context: 200_000, output: 32_000 },
90
+ })));
91
+ editor.add({
92
+ info: {
93
+ id: toID(PROVIDER_ID),
94
+ name: "Reasoning Router",
95
+ activation: "enabled",
96
+ package: PROVIDER_PACKAGE,
97
+ settings: { transport: "http" },
98
+ },
99
+ models,
100
+ });
101
+ });
102
+ await ctx.model.transform((editor) => {
103
+ const next = new Map();
104
+ const errors = [];
105
+ for (const { group, providerID, modelID, ref } of refs) {
106
+ const source = editor.get(providerID, modelID);
107
+ const provider = editor.provider.get(providerID)?.provider;
108
+ const apiID = source?.modelID ?? source?.id ?? modelID;
109
+ const profile = MODELS.find((model) => model.id === apiID && model.provider === group);
110
+ const sourcePackage = source?.package ?? provider?.package;
111
+ const supported = group === "openai"
112
+ ? sourcePackage === PROVIDER_PACKAGE ||
113
+ sourcePackage === "@opencode/ai/providers/openai"
114
+ : sourcePackage === PACKAGES[group];
115
+ const error = !source || !provider
116
+ ? `reasoning-router: source model ${ref} not found; check wrap`
117
+ : !profile
118
+ ? `reasoning-router: ${ref} API model ${apiID} is not a registered ${group} profile`
119
+ : !supported
120
+ ? `reasoning-router: ${ref} requires package ${PACKAGES[group]}${group === "openai" ? " or @opencode/ai/providers/openai" : ""}`
121
+ : next.has(profile.id)
122
+ ? `reasoning-router: duplicate wrap profile ${profile.id}`
123
+ : undefined;
124
+ if (error)
125
+ errors.push(error);
126
+ if (!profile)
127
+ continue;
128
+ const id = profile.id;
129
+ if (next.has(id))
130
+ continue;
131
+ next.set(id, {
132
+ group,
133
+ providerID,
134
+ integrationID: provider?.integrationID ?? providerID,
135
+ authHeader: group === "openai" ? "authorization" : "x-api-key",
136
+ });
137
+ if (error)
138
+ continue;
139
+ const resolved = provider;
140
+ const settings = { ...resolved.settings, ...source.settings };
141
+ delete settings.transport;
142
+ editor.update(PROVIDER_ID, id, (alias) => Object.assign(alias, {
143
+ ...source,
144
+ id,
145
+ modelID: apiID,
146
+ providerID: PROVIDER_ID,
147
+ package: source.package ?? resolved.package,
148
+ name: profile.name,
149
+ transport: "http",
150
+ settings,
151
+ headers: { ...resolved.headers, ...source.headers },
152
+ body: { ...resolved.body, ...source.body },
153
+ variants: [],
154
+ }));
155
+ }
156
+ if (!errors.length)
157
+ for (const profile of MODELS)
158
+ if (!next.has(profile.id))
159
+ editor.remove(PROVIDER_ID, profile.id);
160
+ aliases = next;
161
+ validationError = errors.length ? errors.join("; ") : undefined;
162
+ });
163
+ const onRequest = async (event) => {
164
+ if (validationError)
165
+ throw new Error(validationError);
166
+ const alias = aliases.get(event.model.id);
167
+ if (!alias)
168
+ throw new Error(`reasoning-router: alias ${event.model.id} is not configured in wrap`);
169
+ const incoming = event.request;
170
+ const authHeader = alias.authHeader;
171
+ if (!incoming.headers.get(authHeader)) {
172
+ const connection = await ctx.integration.connection.active(alias.integrationID);
173
+ const credential = connection && (await ctx.integration.connection.resolve(connection));
174
+ if (credential?.type === "oauth")
175
+ throw new Error(`reasoning-router: ${alias.providerID} uses OAuth, which wrap does not support yet; use an API key`);
176
+ if (credential?.type === "key")
177
+ incoming.headers.set(authHeader, alias.group === "openai"
178
+ ? `Bearer ${credential.key}`
179
+ : credential.key);
180
+ }
181
+ if (!incoming.headers.get(authHeader))
182
+ throw new Error(`reasoning-router: ${alias.providerID} has no API key; configure a source provider API key`);
183
+ if (event.kind !== "primary")
184
+ return;
185
+ const pathname = new URL(incoming.url).pathname;
186
+ if (!pathname.endsWith(alias.group === "openai" ? "/responses" : "/messages"))
187
+ throw new Error(`reasoning-router invalid_request (400): alias requires ${alias.group === "openai" ? "/responses" : "/messages"}`);
188
+ let exchange;
189
+ try {
190
+ exchange = await runtime.start(incoming, { session: valid(event.sessionID, SESSION), turnId: randomUUID() }, alias.group);
191
+ }
192
+ catch (cause) {
193
+ throw rejection(cause);
194
+ }
195
+ const outgoing = new Headers(exchange.headers);
196
+ outgoing.delete("content-length");
197
+ const request = new Request(exchange.url, {
198
+ method: "POST",
199
+ headers: outgoing,
200
+ body: exchange.body,
201
+ signal: exchange.signal,
202
+ ...(alias.group === "anthropic" ? { redirect: "manual" } : {}),
203
+ });
204
+ incoming.signal.addEventListener("abort", () => exchange.cancel(), {
205
+ once: true,
206
+ });
207
+ exchanges.set(request, exchange);
208
+ event.request = request;
209
+ };
210
+ const onResponse = (event) => {
211
+ const exchange = exchanges.get(event.request);
212
+ if (exchange === undefined)
213
+ return;
214
+ exchanges.delete(event.request);
215
+ try {
216
+ event.response = exchange.respond(event.response);
217
+ }
218
+ catch (cause) {
219
+ throw rejection(cause);
220
+ }
221
+ };
222
+ await ctx.session.hook("http.request", onRequest, {
223
+ providerID: PROVIDER_ID,
224
+ });
225
+ await ctx.session.hook("http.response", onResponse, {
226
+ providerID: PROVIDER_ID,
227
+ });
228
+ try {
229
+ await ctx.session.hook("experimental.ws.handshake", () => {
230
+ throw new Error('reasoning-router requires transport: http; remove providers["reasoning-router"].settings.transport');
231
+ }, { providerID: PROVIDER_ID });
232
+ }
233
+ catch {
234
+ /* Older hosts may not expose the experimental hook. */
235
+ }
236
+ return () => runtime.dispose();
237
+ }
@@ -0,0 +1,7 @@
1
+ import { setupV2 } from "./plugin-v2.js";
2
+ export type { PluginOptions } from "./plugin-runtime.js";
3
+ declare const plugin: {
4
+ id: string;
5
+ setup: typeof setupV2;
6
+ };
7
+ export default plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,6 @@
1
+ import { setupV2 } from "./plugin-v2.js";
2
+ const plugin = {
3
+ id: "reasoning-router",
4
+ setup: setupV2,
5
+ };
6
+ export default plugin;
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@reasoning-router/opencode",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Adaptive per-step reasoning effort for OpenCode V2",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=24.0.0 <25"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/robertn702/reasoning-router.git",
13
+ "directory": "packages/opencode"
14
+ },
15
+ "keywords": [
16
+ "opencode-plugin",
17
+ "opencode",
18
+ "reasoning-effort"
19
+ ],
20
+ "exports": {
21
+ ".": {
22
+ "@reasoning-router/source": "./src/plugin.ts",
23
+ "types": "./dist/plugin.d.ts",
24
+ "default": "./dist/plugin.js"
25
+ },
26
+ "./plugin": {
27
+ "@reasoning-router/source": "./src/plugin.ts",
28
+ "types": "./dist/plugin.d.ts",
29
+ "default": "./dist/plugin.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist/"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
40
+ "prepack": "npm run build"
41
+ },
42
+ "dependencies": {
43
+ "@reasoning-router/classifiers": "^0.1.0",
44
+ "@reasoning-router/core": "^0.1.0",
45
+ "zod": "^4.6.5"
46
+ },
47
+ "devDependencies": {
48
+ "@opencode/plugin": "2.0.18",
49
+ "@opencode/schema": "2.0.18"
50
+ }
51
+ }