@subrouter/opencode 0.3.0 → 0.4.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/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +86 -26
- package/dist/opencode-e2e.test.js +133 -5
- package/dist/plugin.test.js +226 -8
- package/dist/provider.d.ts +8 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +7 -1
- package/package.json +2 -2
- package/src/index.ts +89 -29
- package/src/opencode-e2e.test.ts +159 -6
- package/src/plugin.test.ts +238 -9
- package/src/provider.ts +17 -1
package/dist/index.d.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* URL, so opencode never installs anything). Each subrouter preset becomes a
|
|
7
7
|
* model: pick `subrouter/default` (or any preset created with
|
|
8
8
|
* `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
|
|
9
|
-
* visible name is `subrouter.org`.
|
|
10
|
-
*
|
|
9
|
+
* visible name is `subrouter.org`. Context limits follow the first live
|
|
10
|
+
* candidate. Input modalities cover every usable candidate so the
|
|
11
|
+
* router can select a compatible subscription for each prompt.
|
|
11
12
|
*
|
|
12
13
|
* `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
|
|
13
14
|
* (and any harness driving opencode's auth hook, like kimaki's Discord
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAA;AA0C9D,eAAO,MAAM,eAAe,EAAE,MAoH7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MA0EjC,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* URL, so opencode never installs anything). Each subrouter preset becomes a
|
|
7
7
|
* model: pick `subrouter/default` (or any preset created with
|
|
8
8
|
* `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
|
|
9
|
-
* visible name is `subrouter.org`.
|
|
10
|
-
*
|
|
9
|
+
* visible name is `subrouter.org`. Context limits follow the first live
|
|
10
|
+
* candidate. Input modalities cover every usable candidate so the
|
|
11
|
+
* router can select a compatible subscription for each prompt.
|
|
11
12
|
*
|
|
12
13
|
* `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
|
|
13
14
|
* (and any harness driving opencode's auth hook, like kimaki's Discord
|
|
@@ -17,39 +18,57 @@
|
|
|
17
18
|
* NOTE: only plugin initializer functions may be exported from this module.
|
|
18
19
|
* OpenCode calls every export as a plugin.
|
|
19
20
|
*/
|
|
20
|
-
import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadModelsDevCatalog, loadPresets, modelsDevLimit, PROVIDER_DISPLAY_NAME, PROVIDER_ID, PROVIDER_IDS,
|
|
21
|
+
import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadModelsDevCatalog, loadPresets, modelsDevInputModalities, modelsDevLimit, modelsDevModel, PROVIDER_DISPLAY_NAME, PROVIDER_ID, PROVIDER_IDS, resolveCandidates, resolvePresetModels, } from '@subrouter/cli';
|
|
21
22
|
import { addSubrouterHeaders, revealRoutedModel } from "./provider.js";
|
|
22
23
|
function providerEntryUrl() {
|
|
23
24
|
const isDev = import.meta.url.endsWith('.ts');
|
|
24
25
|
return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href;
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
27
|
+
function opencodeLog(client) {
|
|
28
|
+
if (!client?.app?.log)
|
|
29
|
+
return undefined;
|
|
30
|
+
const write = client.app.log.bind(client.app);
|
|
31
|
+
return (entry) => {
|
|
32
|
+
void write({
|
|
33
|
+
body: {
|
|
34
|
+
service: 'subrouter',
|
|
35
|
+
level: entry.level,
|
|
36
|
+
message: entry.message,
|
|
37
|
+
extra: entry.extra,
|
|
38
|
+
},
|
|
39
|
+
}).catch(() => { });
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export const subrouterPlugin = async ({ client, directory }) => {
|
|
43
|
+
const log = opencodeLog(client);
|
|
44
|
+
const pendingNotices = new Map();
|
|
45
|
+
const onCooldownFallback = (notice) => {
|
|
46
|
+
if (!client || !notice.sessionID || !notice.agent || notice.agent === 'title')
|
|
47
|
+
return;
|
|
48
|
+
if (pendingNotices.has(notice.sessionID))
|
|
49
|
+
return;
|
|
50
|
+
const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`;
|
|
51
|
+
const active = `${notice.active.provider}/${notice.active.modelId}`;
|
|
52
|
+
pendingNotices.set(notice.sessionID, {
|
|
53
|
+
agent: notice.agent,
|
|
54
|
+
variant: notice.variant,
|
|
55
|
+
preset: notice.preset,
|
|
56
|
+
text: `Subrouter: ${preferred} was rate limited. This message started with ${active}.`,
|
|
42
57
|
});
|
|
43
|
-
}
|
|
58
|
+
};
|
|
44
59
|
return {
|
|
45
60
|
config: async (config) => {
|
|
46
61
|
const presets = await loadPresets().catch(() => {
|
|
47
62
|
return { version: 1, presets: {} };
|
|
48
63
|
});
|
|
49
64
|
const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)]);
|
|
50
|
-
const catalog = await loadModelsDevCatalog();
|
|
65
|
+
const catalog = await loadModelsDevCatalog({ log });
|
|
51
66
|
const models = Object.fromEntries(await Promise.all([...names].map(async (name) => {
|
|
52
|
-
const
|
|
67
|
+
const presetModels = await resolvePresetModels(name);
|
|
68
|
+
const candidates = presetModels instanceof Error
|
|
69
|
+
? []
|
|
70
|
+
: (await resolveCandidates({ presetModels })).candidates;
|
|
71
|
+
const candidate = candidates[0];
|
|
53
72
|
const limit = candidate
|
|
54
73
|
? modelsDevLimit({
|
|
55
74
|
provider: candidate.provider,
|
|
@@ -57,15 +76,34 @@ export const subrouterPlugin = async ({ client }) => {
|
|
|
57
76
|
catalog,
|
|
58
77
|
})
|
|
59
78
|
: null;
|
|
79
|
+
const input = new Set(['text']);
|
|
80
|
+
let attachment = false;
|
|
81
|
+
for (const current of candidates) {
|
|
82
|
+
const model = modelsDevModel({
|
|
83
|
+
provider: current.provider,
|
|
84
|
+
modelId: current.modelId,
|
|
85
|
+
catalog,
|
|
86
|
+
});
|
|
87
|
+
const modalities = modelsDevInputModalities({
|
|
88
|
+
provider: current.provider,
|
|
89
|
+
modelId: current.modelId,
|
|
90
|
+
catalog,
|
|
91
|
+
});
|
|
92
|
+
if (!model || !modalities)
|
|
93
|
+
continue;
|
|
94
|
+
attachment ||= model.attachment && modalities.some((modality) => modality !== 'text');
|
|
95
|
+
for (const modality of modalities)
|
|
96
|
+
input.add(modality);
|
|
97
|
+
}
|
|
60
98
|
return [
|
|
61
99
|
name,
|
|
62
100
|
{
|
|
63
|
-
name
|
|
101
|
+
name,
|
|
64
102
|
tool_call: true,
|
|
65
|
-
attachment
|
|
103
|
+
attachment,
|
|
66
104
|
reasoning: false,
|
|
67
105
|
modalities: {
|
|
68
|
-
input: [
|
|
106
|
+
input: [...input],
|
|
69
107
|
output: ['text'],
|
|
70
108
|
},
|
|
71
109
|
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
@@ -79,10 +117,32 @@ export const subrouterPlugin = async ({ client }) => {
|
|
|
79
117
|
name: PROVIDER_DISPLAY_NAME,
|
|
80
118
|
npm: providerEntryUrl(),
|
|
81
119
|
models,
|
|
82
|
-
options: {},
|
|
120
|
+
options: { log, onCooldownFallback },
|
|
83
121
|
},
|
|
84
122
|
};
|
|
85
123
|
},
|
|
124
|
+
event: async ({ event }) => {
|
|
125
|
+
if (event.type !== 'session.idle')
|
|
126
|
+
return;
|
|
127
|
+
const pending = pendingNotices.get(event.properties.sessionID);
|
|
128
|
+
if (!pending)
|
|
129
|
+
return;
|
|
130
|
+
pendingNotices.delete(event.properties.sessionID);
|
|
131
|
+
const body = {
|
|
132
|
+
noReply: true,
|
|
133
|
+
agent: pending.agent,
|
|
134
|
+
model: { providerID: PROVIDER_ID, modelID: pending.preset },
|
|
135
|
+
variant: pending.variant,
|
|
136
|
+
parts: [{ type: 'text', text: pending.text, ignored: true }],
|
|
137
|
+
};
|
|
138
|
+
await client.session
|
|
139
|
+
.prompt({
|
|
140
|
+
path: { id: event.properties.sessionID },
|
|
141
|
+
query: { directory },
|
|
142
|
+
body,
|
|
143
|
+
})
|
|
144
|
+
.catch(() => { });
|
|
145
|
+
},
|
|
86
146
|
'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
|
|
87
147
|
// OpenCode identity uses the preset id; rewrite it to the live routed model.
|
|
88
148
|
'experimental.chat.system.transform': async (input, output) => {
|
|
@@ -17,7 +17,7 @@ import { tmpdir } from 'node:os';
|
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { pathToFileURL } from 'node:url';
|
|
19
19
|
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
|
20
|
-
import { OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, addAccount, markCooldown, } from '@subrouter/cli';
|
|
20
|
+
import { OPENCODE_AGENT_HEADER, OPENCODE_VARIANT_HEADER, OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, addAccount, markCooldown, } from '@subrouter/cli';
|
|
21
21
|
import { addSubrouterHeaders } from "./provider.js";
|
|
22
22
|
function summarizeSessionEvents(events) {
|
|
23
23
|
const summary = [];
|
|
@@ -58,7 +58,7 @@ async function startMockServer(handler) {
|
|
|
58
58
|
body += String(chunk);
|
|
59
59
|
});
|
|
60
60
|
req.on('end', () => {
|
|
61
|
-
requests.push(req.url ?? '');
|
|
61
|
+
requests.push({ path: req.url ?? '', body });
|
|
62
62
|
handler({ path: req.url ?? '', body }, res);
|
|
63
63
|
});
|
|
64
64
|
});
|
|
@@ -88,6 +88,7 @@ function sseChunk(data) {
|
|
|
88
88
|
let home;
|
|
89
89
|
let projectDir;
|
|
90
90
|
let anthropicMock;
|
|
91
|
+
let modelsDevMock;
|
|
91
92
|
let zenMock;
|
|
92
93
|
let server;
|
|
93
94
|
const savedEnv = {};
|
|
@@ -138,12 +139,35 @@ beforeAll(async () => {
|
|
|
138
139
|
res.write('data: [DONE]\n\n');
|
|
139
140
|
res.end();
|
|
140
141
|
});
|
|
142
|
+
modelsDevMock = await startMockServer((_request, res) => {
|
|
143
|
+
const emptyProvider = { models: {} };
|
|
144
|
+
const pdfModel = (id) => ({
|
|
145
|
+
id,
|
|
146
|
+
attachment: true,
|
|
147
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
148
|
+
limit: { context: 200_000, output: 64_000 },
|
|
149
|
+
});
|
|
150
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
151
|
+
res.end(JSON.stringify({
|
|
152
|
+
anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
|
|
153
|
+
openai: emptyProvider,
|
|
154
|
+
xai: emptyProvider,
|
|
155
|
+
'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
|
|
156
|
+
'github-copilot': emptyProvider,
|
|
157
|
+
poe: emptyProvider,
|
|
158
|
+
'minimax-coding-plan': emptyProvider,
|
|
159
|
+
'kimi-for-coding': emptyProvider,
|
|
160
|
+
'zai-coding-plan': emptyProvider,
|
|
161
|
+
'alibaba-coding-plan': emptyProvider,
|
|
162
|
+
}));
|
|
163
|
+
});
|
|
141
164
|
// Subrouter state: one rate-limited anthropic account + one zen key
|
|
142
165
|
const subrouterHome = path.join(home, 'subrouter');
|
|
143
166
|
await mkdir(subrouterHome, { recursive: true });
|
|
144
167
|
for (const [key, value] of Object.entries({
|
|
145
168
|
SUBROUTER_HOME: subrouterHome,
|
|
146
169
|
SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
|
|
170
|
+
SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
|
|
147
171
|
SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
|
|
148
172
|
// Isolate opencode from the user's real global config and auth
|
|
149
173
|
XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
|
|
@@ -171,10 +195,12 @@ beforeAll(async () => {
|
|
|
171
195
|
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
172
196
|
});
|
|
173
197
|
const providerEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'provider.js')).href;
|
|
198
|
+
const pluginEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'index.js')).href;
|
|
174
199
|
server = await createOpencodeServer({
|
|
175
200
|
port: 0,
|
|
176
201
|
timeout: 60_000,
|
|
177
202
|
config: {
|
|
203
|
+
plugin: [pluginEntry],
|
|
178
204
|
provider: {
|
|
179
205
|
subrouter: {
|
|
180
206
|
name: 'Subrouter',
|
|
@@ -183,6 +209,8 @@ beforeAll(async () => {
|
|
|
183
209
|
default: {
|
|
184
210
|
name: 'subrouter default',
|
|
185
211
|
tool_call: true,
|
|
212
|
+
attachment: true,
|
|
213
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
186
214
|
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
187
215
|
limit: { context: 200_000, output: 64_000 },
|
|
188
216
|
},
|
|
@@ -195,6 +223,7 @@ beforeAll(async () => {
|
|
|
195
223
|
afterAll(async () => {
|
|
196
224
|
server?.close();
|
|
197
225
|
await anthropicMock?.close();
|
|
226
|
+
await modelsDevMock?.close();
|
|
198
227
|
await zenMock?.close();
|
|
199
228
|
for (const [key, value] of Object.entries(savedEnv)) {
|
|
200
229
|
if (value === undefined)
|
|
@@ -211,14 +240,27 @@ describe('opencode + subrouter provider', () => {
|
|
|
211
240
|
sessionID: 'session-1',
|
|
212
241
|
agent: 'build',
|
|
213
242
|
model: { providerID: 'subrouter' },
|
|
243
|
+
message: {
|
|
244
|
+
agent: 'build',
|
|
245
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
246
|
+
},
|
|
214
247
|
}, output);
|
|
215
|
-
expect(output.headers).toEqual({
|
|
248
|
+
expect(output.headers).toEqual({
|
|
249
|
+
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
|
|
250
|
+
[OPENCODE_AGENT_HEADER]: 'build',
|
|
251
|
+
[OPENCODE_VARIANT_HEADER]: 'high',
|
|
252
|
+
});
|
|
253
|
+
const titleOutput = { headers: {} };
|
|
216
254
|
addSubrouterHeaders({
|
|
217
255
|
sessionID: 'session-2',
|
|
218
256
|
agent: 'title',
|
|
219
257
|
model: { providerID: 'subrouter' },
|
|
220
|
-
|
|
221
|
-
|
|
258
|
+
message: {
|
|
259
|
+
agent: 'build',
|
|
260
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
261
|
+
},
|
|
262
|
+
}, titleOutput);
|
|
263
|
+
expect(titleOutput.headers).toEqual({
|
|
222
264
|
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
|
|
223
265
|
[OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
|
|
224
266
|
});
|
|
@@ -248,6 +290,92 @@ describe('opencode + subrouter provider', () => {
|
|
|
248
290
|
expect(anthropicMock.requests.length).toBeGreaterThan(0);
|
|
249
291
|
expect(zenMock.requests.length).toBeGreaterThan(0);
|
|
250
292
|
}, 120_000);
|
|
293
|
+
test('a pre-existing cooldown leaves a visible ignored route notice without another model turn', async () => {
|
|
294
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
295
|
+
const session = await client.session.create({
|
|
296
|
+
query: { directory: projectDir },
|
|
297
|
+
body: { title: 'subrouter route notice' },
|
|
298
|
+
});
|
|
299
|
+
expect(session.data).toBeTruthy();
|
|
300
|
+
const requestsBefore = zenMock.requests.length;
|
|
301
|
+
const result = await client.session.prompt({
|
|
302
|
+
path: { id: session.data.id },
|
|
303
|
+
query: { directory: projectDir },
|
|
304
|
+
body: {
|
|
305
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
306
|
+
parts: [{ type: 'text', text: 'say hi again' }],
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
expect((result.data?.parts ?? [])
|
|
310
|
+
.filter((part) => part.type === 'text')
|
|
311
|
+
.map((part) => part.text)
|
|
312
|
+
.join('\n')).toContain('hello from fallback');
|
|
313
|
+
await expect
|
|
314
|
+
.poll(async () => {
|
|
315
|
+
const messages = await client.session.messages({
|
|
316
|
+
path: { id: session.data.id },
|
|
317
|
+
query: { directory: projectDir },
|
|
318
|
+
});
|
|
319
|
+
return (messages.data ?? []).some(({ parts }) => parts.some((part) => part.type === 'text' &&
|
|
320
|
+
part.ignored === true &&
|
|
321
|
+
part.text.includes('was rate limited. This message started with opencode-go/')));
|
|
322
|
+
})
|
|
323
|
+
.toBe(true);
|
|
324
|
+
const messages = await client.session.messages({
|
|
325
|
+
path: { id: session.data.id },
|
|
326
|
+
query: { directory: projectDir },
|
|
327
|
+
});
|
|
328
|
+
const notice = (messages.data ?? []).find(({ parts }) => parts.some((part) => part.type === 'text' && part.ignored === true));
|
|
329
|
+
expect(notice?.info).toMatchObject({
|
|
330
|
+
role: 'user',
|
|
331
|
+
agent: 'build',
|
|
332
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
333
|
+
});
|
|
334
|
+
expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1);
|
|
335
|
+
expect(zenMock.requests).toHaveLength(requestsBefore + 1);
|
|
336
|
+
}, 120_000);
|
|
337
|
+
test('PDF file parts reach a compatible fallback through opencode', async () => {
|
|
338
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
339
|
+
const session = await client.session.create({
|
|
340
|
+
query: { directory: projectDir },
|
|
341
|
+
body: { title: 'subrouter PDF e2e' },
|
|
342
|
+
});
|
|
343
|
+
expect(session.data).toBeTruthy();
|
|
344
|
+
const result = await client.session.prompt({
|
|
345
|
+
path: { id: session.data.id },
|
|
346
|
+
query: { directory: projectDir },
|
|
347
|
+
body: {
|
|
348
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
349
|
+
parts: [
|
|
350
|
+
{
|
|
351
|
+
type: 'file',
|
|
352
|
+
filename: 'document.pdf',
|
|
353
|
+
mime: 'application/pdf',
|
|
354
|
+
url: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
|
|
355
|
+
},
|
|
356
|
+
{ type: 'text', text: 'read the PDF' },
|
|
357
|
+
],
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
const texts = (result.data?.parts ?? [])
|
|
361
|
+
.filter((part) => part.type === 'text')
|
|
362
|
+
.map((part) => part.text)
|
|
363
|
+
.join('\n');
|
|
364
|
+
expect(texts).toContain('hello from fallback');
|
|
365
|
+
const request = zenMock.requests.at(-1);
|
|
366
|
+
expect(request).toBeTruthy();
|
|
367
|
+
const body = JSON.parse(request.body);
|
|
368
|
+
expect(body.messages.at(-1)?.content).toEqual([
|
|
369
|
+
{
|
|
370
|
+
type: 'file',
|
|
371
|
+
file: {
|
|
372
|
+
filename: 'document.pdf',
|
|
373
|
+
file_data: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
{ type: 'text', text: 'read the PDF' },
|
|
377
|
+
]);
|
|
378
|
+
}, 120_000);
|
|
251
379
|
test('all cooling-down accounts retry through opencode instead of dying', async () => {
|
|
252
380
|
const untilMs = Date.now() + 2_000;
|
|
253
381
|
await markCooldown({
|