@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/plugin.test.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import childProcess from 'node:child_process';
|
|
1
2
|
import { createServer } from 'node:http';
|
|
2
3
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
6
|
+
import util from 'node:util';
|
|
5
7
|
import { afterEach, beforeEach, expect, test } from 'vitest';
|
|
6
|
-
import {
|
|
8
|
+
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
9
|
+
import { addAccount, adapters, loadAccounts, PROVIDER_DISPLAY_NAME, PROVIDER_IDS, savePreset, } from '@subrouter/cli';
|
|
7
10
|
import { subrouterAuthPlugin, subrouterPlugin } from "./index.js";
|
|
8
11
|
import { revealRoutedModel, rewritePoweredByModelLine } from "./provider.js";
|
|
12
|
+
const execFile = util.promisify(childProcess.execFile);
|
|
9
13
|
let home;
|
|
10
14
|
const openServers = [];
|
|
11
15
|
const pluginInput = {};
|
|
@@ -15,7 +19,6 @@ beforeEach(async () => {
|
|
|
15
19
|
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev();
|
|
16
20
|
});
|
|
17
21
|
afterEach(async () => {
|
|
18
|
-
setSubrouterLog(undefined);
|
|
19
22
|
delete process.env.SUBROUTER_HOME;
|
|
20
23
|
delete process.env.SUBROUTER_OPENAI_ISSUER_URL;
|
|
21
24
|
delete process.env.SUBROUTER_MODELS_DEV_URL;
|
|
@@ -97,6 +100,120 @@ function authMethod() {
|
|
|
97
100
|
return { provider: hooks.auth.provider, method };
|
|
98
101
|
});
|
|
99
102
|
}
|
|
103
|
+
test('plugin load and config do not write stdout or stderr', async () => {
|
|
104
|
+
const script = "import('./src/index.ts').then(async ({ subrouterPlugin }) => { const hooks = await subrouterPlugin({}); await hooks.config?.({}) })";
|
|
105
|
+
const result = await execFile(process.execPath, ['--no-warnings', '--import', 'tsx', '--eval', script], {
|
|
106
|
+
cwd: process.cwd(),
|
|
107
|
+
env: process.env,
|
|
108
|
+
});
|
|
109
|
+
expect(result).toEqual({ stdout: '', stderr: '' });
|
|
110
|
+
});
|
|
111
|
+
test('provider log callback forwards only to client.app.log', async () => {
|
|
112
|
+
const received = Promise.withResolvers();
|
|
113
|
+
const server = createServer((req, res) => {
|
|
114
|
+
const chunks = [];
|
|
115
|
+
req.on('data', (chunk) => {
|
|
116
|
+
chunks.push(chunk);
|
|
117
|
+
});
|
|
118
|
+
req.on('end', () => {
|
|
119
|
+
received.resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
120
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
121
|
+
res.end('{}');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
await new Promise((resolve) => {
|
|
125
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
126
|
+
});
|
|
127
|
+
openServers.push(server);
|
|
128
|
+
const address = server.address();
|
|
129
|
+
if (typeof address === 'string' || !address)
|
|
130
|
+
throw new Error('failed to bind log server');
|
|
131
|
+
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` });
|
|
132
|
+
const hooks = await subrouterPlugin({ ...pluginInput, client });
|
|
133
|
+
const config = {};
|
|
134
|
+
await hooks.config?.(config);
|
|
135
|
+
const log = config.provider?.subrouter?.options?.log;
|
|
136
|
+
expect(log).toBeTypeOf('function');
|
|
137
|
+
if (typeof log !== 'function')
|
|
138
|
+
throw new Error('expected provider log callback');
|
|
139
|
+
await log({
|
|
140
|
+
level: 'warn',
|
|
141
|
+
message: 'failover openai/gpt-5.5',
|
|
142
|
+
extra: { provider: 'openai', modelId: 'gpt-5.5' },
|
|
143
|
+
});
|
|
144
|
+
expect(await received.promise).toEqual({
|
|
145
|
+
service: 'subrouter',
|
|
146
|
+
level: 'warn',
|
|
147
|
+
message: 'failover openai/gpt-5.5',
|
|
148
|
+
extra: { provider: 'openai', modelId: 'gpt-5.5' },
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
test('cooldown fallback creates only a persisted ignored notice after idle', async () => {
|
|
152
|
+
const requests = [];
|
|
153
|
+
const server = createServer((req, res) => {
|
|
154
|
+
const chunks = [];
|
|
155
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
156
|
+
req.on('end', () => {
|
|
157
|
+
requests.push({
|
|
158
|
+
path: req.url ?? '',
|
|
159
|
+
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
|
160
|
+
});
|
|
161
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
162
|
+
res.end('{}');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
166
|
+
openServers.push(server);
|
|
167
|
+
const address = server.address();
|
|
168
|
+
if (typeof address === 'string' || !address)
|
|
169
|
+
throw new Error('failed to bind notification server');
|
|
170
|
+
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` });
|
|
171
|
+
const hooks = await subrouterPlugin({ ...pluginInput, client, directory: '/tmp/project' });
|
|
172
|
+
const config = {};
|
|
173
|
+
await hooks.config?.(config);
|
|
174
|
+
const onCooldownFallback = config.provider?.subrouter?.options?.onCooldownFallback;
|
|
175
|
+
expect(onCooldownFallback).toBeTypeOf('function');
|
|
176
|
+
if (typeof onCooldownFallback !== 'function')
|
|
177
|
+
throw new Error('expected cooldown callback');
|
|
178
|
+
await onCooldownFallback({
|
|
179
|
+
sessionID: 'session-1',
|
|
180
|
+
agent: 'build',
|
|
181
|
+
variant: 'high',
|
|
182
|
+
preset: 'work',
|
|
183
|
+
preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 252_000 },
|
|
184
|
+
active: { provider: 'openai', modelId: 'gpt-5.6-sol' },
|
|
185
|
+
});
|
|
186
|
+
await onCooldownFallback({
|
|
187
|
+
sessionID: 'session-1',
|
|
188
|
+
agent: 'compaction',
|
|
189
|
+
preset: 'work',
|
|
190
|
+
preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 251_000 },
|
|
191
|
+
active: { provider: 'anthropic', modelId: 'claude-sonnet-5' },
|
|
192
|
+
});
|
|
193
|
+
const event = {
|
|
194
|
+
type: 'session.idle',
|
|
195
|
+
properties: { sessionID: 'session-1' },
|
|
196
|
+
};
|
|
197
|
+
await hooks.event?.({ event });
|
|
198
|
+
expect(requests).toEqual([
|
|
199
|
+
{
|
|
200
|
+
path: '/session/session-1/message?directory=%2Ftmp%2Fproject',
|
|
201
|
+
body: {
|
|
202
|
+
noReply: true,
|
|
203
|
+
agent: 'build',
|
|
204
|
+
model: { providerID: 'subrouter', modelID: 'work' },
|
|
205
|
+
variant: 'high',
|
|
206
|
+
parts: [
|
|
207
|
+
{
|
|
208
|
+
type: 'text',
|
|
209
|
+
text: 'Subrouter: xai/grok-4.6 was rate limited. This message started with openai/gpt-5.6-sol.',
|
|
210
|
+
ignored: true,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
100
217
|
test('config hook registers the subrouter provider with preset models', async () => {
|
|
101
218
|
await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] });
|
|
102
219
|
const hooks = await subrouterPlugin(pluginInput);
|
|
@@ -114,7 +231,7 @@ test('config hook registers the subrouter provider with preset models', async ()
|
|
|
114
231
|
expect(provider.models.default.limit).toEqual({ context: 200_000, output: 64_000 });
|
|
115
232
|
expect(provider.models.work.limit).toEqual({ context: 200_000, output: 64_000 });
|
|
116
233
|
});
|
|
117
|
-
test('preset model names
|
|
234
|
+
test('preset model names stay stable when the routed candidate changes', async () => {
|
|
118
235
|
await addAccount({
|
|
119
236
|
provider: 'anthropic',
|
|
120
237
|
account: {
|
|
@@ -132,7 +249,7 @@ test('preset model names show the first live candidate', async () => {
|
|
|
132
249
|
const config = {};
|
|
133
250
|
await hooks.config?.(config);
|
|
134
251
|
expect(config.provider?.subrouter?.name).toBe(PROVIDER_DISPLAY_NAME);
|
|
135
|
-
expect(config.provider?.subrouter?.models?.work?.name).toBe('work
|
|
252
|
+
expect(config.provider?.subrouter?.models?.work?.name).toBe('work');
|
|
136
253
|
});
|
|
137
254
|
test('preset model limits follow the first live candidate', async () => {
|
|
138
255
|
const modelId = adapters.anthropic.defaultModels[0];
|
|
@@ -143,8 +260,8 @@ test('preset model limits follow the first live candidate', async () => {
|
|
|
143
260
|
models: {
|
|
144
261
|
[modelId]: {
|
|
145
262
|
id: modelId,
|
|
146
|
-
modalities: { output: ['text'] },
|
|
147
|
-
limit: { context: 1_000_000, output: 128_000 },
|
|
263
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
264
|
+
limit: { context: 1_000_000, input: 900_000, output: 128_000 },
|
|
148
265
|
},
|
|
149
266
|
},
|
|
150
267
|
},
|
|
@@ -167,18 +284,56 @@ test('preset model limits follow the first live candidate', async () => {
|
|
|
167
284
|
await hooks.config?.(config);
|
|
168
285
|
expect(config.provider?.subrouter?.models?.work?.limit).toEqual({
|
|
169
286
|
context: 1_000_000,
|
|
287
|
+
input: 900_000,
|
|
170
288
|
output: 128_000,
|
|
171
289
|
});
|
|
172
290
|
expect(config.provider?.subrouter?.models?.default?.limit).toEqual({
|
|
173
291
|
context: 1_000_000,
|
|
292
|
+
input: 900_000,
|
|
174
293
|
output: 128_000,
|
|
175
294
|
});
|
|
176
295
|
});
|
|
177
|
-
test('preset
|
|
296
|
+
test('preset model input modalities are the union of usable candidates', async () => {
|
|
297
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
298
|
+
openai: {
|
|
299
|
+
models: {
|
|
300
|
+
'gpt-pdf': {
|
|
301
|
+
id: 'gpt-pdf',
|
|
302
|
+
attachment: true,
|
|
303
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
'kimi-for-coding': {
|
|
308
|
+
models: {
|
|
309
|
+
'kimi-image': {
|
|
310
|
+
id: 'kimi-image',
|
|
311
|
+
attachment: true,
|
|
312
|
+
modalities: { input: ['text', 'image'], output: ['text'] },
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
await addAccount({
|
|
318
|
+
provider: 'openai',
|
|
319
|
+
account: {
|
|
320
|
+
type: 'oauth',
|
|
321
|
+
access: 'access-1',
|
|
322
|
+
refresh: 'refresh-1',
|
|
323
|
+
expires: Date.now() + 60_000,
|
|
324
|
+
addedAt: 1,
|
|
325
|
+
lastUsed: 1,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
await addAccount({
|
|
329
|
+
provider: 'kimi',
|
|
330
|
+
account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
|
|
331
|
+
});
|
|
332
|
+
await savePreset({ name: 'work', models: ['openai/gpt-pdf', 'kimi/kimi-image'] });
|
|
178
333
|
const hooks = await subrouterPlugin(pluginInput);
|
|
179
334
|
const config = {};
|
|
180
335
|
await hooks.config?.(config);
|
|
181
|
-
expect(config.provider?.subrouter?.models?.
|
|
336
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
182
337
|
attachment: true,
|
|
183
338
|
modalities: {
|
|
184
339
|
input: ['text', 'image', 'pdf'],
|
|
@@ -186,6 +341,69 @@ test('preset models permit image and PDF attachments', async () => {
|
|
|
186
341
|
},
|
|
187
342
|
});
|
|
188
343
|
});
|
|
344
|
+
test('xAI presets do not advertise inline PDF support that its SDK cannot encode', async () => {
|
|
345
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
346
|
+
xai: {
|
|
347
|
+
models: {
|
|
348
|
+
'grok-pdf': {
|
|
349
|
+
id: 'grok-pdf',
|
|
350
|
+
attachment: true,
|
|
351
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
await addAccount({
|
|
357
|
+
provider: 'xai',
|
|
358
|
+
account: {
|
|
359
|
+
type: 'oauth',
|
|
360
|
+
access: 'access-1',
|
|
361
|
+
refresh: 'refresh-1',
|
|
362
|
+
expires: Date.now() + 60_000,
|
|
363
|
+
addedAt: 1,
|
|
364
|
+
lastUsed: 1,
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
await savePreset({ name: 'work', models: ['xai/grok-pdf'] });
|
|
368
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
369
|
+
const config = {};
|
|
370
|
+
await hooks.config?.(config);
|
|
371
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
372
|
+
attachment: true,
|
|
373
|
+
modalities: {
|
|
374
|
+
input: ['text', 'image'],
|
|
375
|
+
output: ['text'],
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
test('Anthropic-compatible coding plans do not advertise video input', async () => {
|
|
380
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
381
|
+
'kimi-for-coding': {
|
|
382
|
+
models: {
|
|
383
|
+
'kimi-video': {
|
|
384
|
+
id: 'kimi-video',
|
|
385
|
+
attachment: true,
|
|
386
|
+
modalities: { input: ['text', 'image', 'video'], output: ['text'] },
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
await addAccount({
|
|
392
|
+
provider: 'kimi',
|
|
393
|
+
account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
|
|
394
|
+
});
|
|
395
|
+
await savePreset({ name: 'work', models: ['kimi/kimi-video'] });
|
|
396
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
397
|
+
const config = {};
|
|
398
|
+
await hooks.config?.(config);
|
|
399
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
400
|
+
attachment: true,
|
|
401
|
+
modalities: {
|
|
402
|
+
input: ['text', 'image'],
|
|
403
|
+
output: ['text'],
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
});
|
|
189
407
|
test('rewrites the OpenCode powered-by line to the routed candidate', () => {
|
|
190
408
|
const system = [
|
|
191
409
|
'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
|
package/dist/provider.d.ts
CHANGED
|
@@ -23,6 +23,14 @@ export declare function addSubrouterHeaders(input: {
|
|
|
23
23
|
model: {
|
|
24
24
|
providerID: string;
|
|
25
25
|
};
|
|
26
|
+
message: {
|
|
27
|
+
agent: string;
|
|
28
|
+
model: {
|
|
29
|
+
providerID: string;
|
|
30
|
+
modelID: string;
|
|
31
|
+
variant?: string;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
26
34
|
}, output: {
|
|
27
35
|
headers: Record<string, string>;
|
|
28
36
|
}): void;
|
package/dist/provider.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAWH,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAIhD,wBAAgB,yBAAyB,CAAC,EACxC,MAAM,EACN,SAAS,GACV,EAAE;IACD,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,SAAS,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CACjD,QAKA;AAED,wBAAsB,iBAAiB,CAAC,EACtC,UAAU,EACV,MAAM,EACN,MAAM,GACP,EAAE;IACD,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB,iBAKA;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE;IACL,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAA;IAC7B,OAAO,EAAE;QACP,KAAK,EAAE,MAAM,CAAA;QACb,KAAK,EAAE;YAAE,UAAU,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAC;YAAC,OAAO,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KACjE,CAAA;CACF,EACD,MAAM,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,QAW5C"}
|
package/dist/provider.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* then `sdk.languageModel(modelId)` where modelId is a subrouter preset name.
|
|
5
5
|
* Also rewrites OpenCode's powered-by identity to the live routed model.
|
|
6
6
|
*/
|
|
7
|
-
import { OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, PROVIDER_ID, resolveActiveCandidate, } from '@subrouter/cli';
|
|
7
|
+
import { OPENCODE_AGENT_HEADER, OPENCODE_VARIANT_HEADER, OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, PROVIDER_ID, resolveActiveCandidate, } from '@subrouter/cli';
|
|
8
8
|
export { createSubrouter } from '@subrouter/cli';
|
|
9
9
|
const POWERED_BY_MODEL = /You are powered by the model named [^\n]+/;
|
|
10
10
|
export function rewritePoweredByModelLine({ system, candidate, }) {
|
|
@@ -25,6 +25,12 @@ export function addSubrouterHeaders(input, output) {
|
|
|
25
25
|
if (input.model.providerID !== PROVIDER_ID)
|
|
26
26
|
return;
|
|
27
27
|
output.headers[OPENAI_WEBSOCKET_SESSION_HEADER] = input.sessionID;
|
|
28
|
+
if (input.agent === input.message.agent) {
|
|
29
|
+
output.headers[OPENCODE_AGENT_HEADER] = input.message.agent;
|
|
30
|
+
if (input.message.model.variant) {
|
|
31
|
+
output.headers[OPENCODE_VARIANT_HEADER] = input.message.model.variant;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
28
34
|
if (input.agent === 'title')
|
|
29
35
|
output.headers[OPENAI_WEBSOCKET_TITLE_HEADER] = 'true';
|
|
30
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@subrouter/opencode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenCode plugin that registers the subrouter provider: cycle through your personal AI subscriptions when one hits rate limits.",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"errore": "^0.14.1",
|
|
47
|
-
"@subrouter/cli": "^0.
|
|
47
|
+
"@subrouter/cli": "^0.5.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@opencode-ai/plugin": "^1.18.23",
|
package/src/index.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
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
* OpenCode calls every export as a plugin.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
|
-
import type { Plugin } from '@opencode-ai/plugin'
|
|
22
|
+
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
22
23
|
import {
|
|
23
24
|
adapters,
|
|
24
25
|
addAccount,
|
|
@@ -26,13 +27,17 @@ import {
|
|
|
26
27
|
isProviderId,
|
|
27
28
|
loadModelsDevCatalog,
|
|
28
29
|
loadPresets,
|
|
30
|
+
modelsDevInputModalities,
|
|
29
31
|
modelsDevLimit,
|
|
32
|
+
modelsDevModel,
|
|
30
33
|
PROVIDER_DISPLAY_NAME,
|
|
31
34
|
PROVIDER_ID,
|
|
32
35
|
PROVIDER_IDS,
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
resolveCandidates,
|
|
37
|
+
resolvePresetModels,
|
|
38
|
+
type CooldownFallbackNotice,
|
|
35
39
|
type StoredAccount,
|
|
40
|
+
type SubrouterLog,
|
|
36
41
|
} from '@subrouter/cli'
|
|
37
42
|
import { addSubrouterHeaders, revealRoutedModel } from './provider.ts'
|
|
38
43
|
|
|
@@ -41,22 +46,37 @@ function providerEntryUrl() {
|
|
|
41
46
|
return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
function opencodeLog(client: PluginInput['client'] | undefined): SubrouterLog | undefined {
|
|
50
|
+
if (!client?.app?.log) return undefined
|
|
51
|
+
const write = client.app.log.bind(client.app)
|
|
52
|
+
return (entry) => {
|
|
53
|
+
void write({
|
|
54
|
+
body: {
|
|
55
|
+
service: 'subrouter',
|
|
56
|
+
level: entry.level,
|
|
57
|
+
message: entry.message,
|
|
58
|
+
extra: entry.extra,
|
|
59
|
+
},
|
|
60
|
+
}).catch(() => {})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const subrouterPlugin: Plugin = async ({ client, directory }) => {
|
|
65
|
+
const log = opencodeLog(client)
|
|
66
|
+
const pendingNotices = new Map<
|
|
67
|
+
string,
|
|
68
|
+
{ agent: string; variant?: string; preset: string; text: string }
|
|
69
|
+
>()
|
|
70
|
+
const onCooldownFallback = (notice: CooldownFallbackNotice) => {
|
|
71
|
+
if (!client || !notice.sessionID || !notice.agent || notice.agent === 'title') return
|
|
72
|
+
if (pendingNotices.has(notice.sessionID)) return
|
|
73
|
+
const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`
|
|
74
|
+
const active = `${notice.active.provider}/${notice.active.modelId}`
|
|
75
|
+
pendingNotices.set(notice.sessionID, {
|
|
76
|
+
agent: notice.agent,
|
|
77
|
+
variant: notice.variant,
|
|
78
|
+
preset: notice.preset,
|
|
79
|
+
text: `Subrouter: ${preferred} was rate limited. This message started with ${active}.`,
|
|
60
80
|
})
|
|
61
81
|
}
|
|
62
82
|
return {
|
|
@@ -65,11 +85,16 @@ export const subrouterPlugin: Plugin = async ({ client }) => {
|
|
|
65
85
|
return { version: 1 as const, presets: {} }
|
|
66
86
|
})
|
|
67
87
|
const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)])
|
|
68
|
-
const catalog = await loadModelsDevCatalog()
|
|
88
|
+
const catalog = await loadModelsDevCatalog({ log })
|
|
69
89
|
const models = Object.fromEntries(
|
|
70
90
|
await Promise.all(
|
|
71
91
|
[...names].map(async (name) => {
|
|
72
|
-
const
|
|
92
|
+
const presetModels = await resolvePresetModels(name)
|
|
93
|
+
const candidates =
|
|
94
|
+
presetModels instanceof Error
|
|
95
|
+
? []
|
|
96
|
+
: (await resolveCandidates({ presetModels })).candidates
|
|
97
|
+
const candidate = candidates[0]
|
|
73
98
|
const limit = candidate
|
|
74
99
|
? modelsDevLimit({
|
|
75
100
|
provider: candidate.provider,
|
|
@@ -77,17 +102,32 @@ export const subrouterPlugin: Plugin = async ({ client }) => {
|
|
|
77
102
|
catalog,
|
|
78
103
|
})
|
|
79
104
|
: null
|
|
105
|
+
const input = new Set<'text' | 'audio' | 'image' | 'video' | 'pdf'>(['text'])
|
|
106
|
+
let attachment = false
|
|
107
|
+
for (const current of candidates) {
|
|
108
|
+
const model = modelsDevModel({
|
|
109
|
+
provider: current.provider,
|
|
110
|
+
modelId: current.modelId,
|
|
111
|
+
catalog,
|
|
112
|
+
})
|
|
113
|
+
const modalities = modelsDevInputModalities({
|
|
114
|
+
provider: current.provider,
|
|
115
|
+
modelId: current.modelId,
|
|
116
|
+
catalog,
|
|
117
|
+
})
|
|
118
|
+
if (!model || !modalities) continue
|
|
119
|
+
attachment ||= model.attachment && modalities.some((modality) => modality !== 'text')
|
|
120
|
+
for (const modality of modalities) input.add(modality)
|
|
121
|
+
}
|
|
80
122
|
return [
|
|
81
123
|
name,
|
|
82
124
|
{
|
|
83
|
-
name
|
|
125
|
+
name,
|
|
84
126
|
tool_call: true,
|
|
85
|
-
attachment
|
|
127
|
+
attachment,
|
|
86
128
|
reasoning: false,
|
|
87
129
|
modalities: {
|
|
88
|
-
input: [
|
|
89
|
-
'text' | 'image' | 'pdf'
|
|
90
|
-
>,
|
|
130
|
+
input: [...input],
|
|
91
131
|
output: ['text'] satisfies Array<'text'>,
|
|
92
132
|
},
|
|
93
133
|
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
@@ -103,10 +143,30 @@ export const subrouterPlugin: Plugin = async ({ client }) => {
|
|
|
103
143
|
name: PROVIDER_DISPLAY_NAME,
|
|
104
144
|
npm: providerEntryUrl(),
|
|
105
145
|
models,
|
|
106
|
-
options: {},
|
|
146
|
+
options: { log, onCooldownFallback },
|
|
107
147
|
},
|
|
108
148
|
}
|
|
109
149
|
},
|
|
150
|
+
event: async ({ event }) => {
|
|
151
|
+
if (event.type !== 'session.idle') return
|
|
152
|
+
const pending = pendingNotices.get(event.properties.sessionID)
|
|
153
|
+
if (!pending) return
|
|
154
|
+
pendingNotices.delete(event.properties.sessionID)
|
|
155
|
+
const body = {
|
|
156
|
+
noReply: true,
|
|
157
|
+
agent: pending.agent,
|
|
158
|
+
model: { providerID: PROVIDER_ID, modelID: pending.preset },
|
|
159
|
+
variant: pending.variant,
|
|
160
|
+
parts: [{ type: 'text' as const, text: pending.text, ignored: true }],
|
|
161
|
+
}
|
|
162
|
+
await client.session
|
|
163
|
+
.prompt({
|
|
164
|
+
path: { id: event.properties.sessionID },
|
|
165
|
+
query: { directory },
|
|
166
|
+
body,
|
|
167
|
+
})
|
|
168
|
+
.catch(() => {})
|
|
169
|
+
},
|
|
110
170
|
'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
|
|
111
171
|
// OpenCode identity uses the preset id; rewrite it to the live routed model.
|
|
112
172
|
'experimental.chat.system.transform': async (input, output) => {
|