@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 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`. Model names, context limits, and
10
- * `experimental.chat.system.transform` follow the first live routed candidate.
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAuBjD,eAAO,MAAM,eAAe,EAAE,MA4E7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MA0EjC,CAAA"}
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`. Model names, context limits, and
10
- * `experimental.chat.system.transform` follow the first live routed candidate.
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, resolveActiveCandidate, setSubrouterLog, } from '@subrouter/cli';
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
- export const subrouterPlugin = async ({ client }) => {
27
- // OpenCode loads this plugin and the provider module separately. Both import
28
- // @subrouter/cli; this callback is the only log sink the router may use.
29
- // Never console.log here. OpenCode prints plugin logs via client.app.log.
30
- if (client?.app?.log) {
31
- setSubrouterLog((entry) => {
32
- void client.app
33
- .log({
34
- body: {
35
- service: 'subrouter',
36
- level: entry.level,
37
- message: entry.message,
38
- extra: entry.extra,
39
- },
40
- })
41
- .catch(() => { });
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 candidate = await resolveActiveCandidate(name);
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: candidate ? `${name} (${candidate.modelId})` : name,
101
+ name,
64
102
  tool_call: true,
65
- attachment: true,
103
+ attachment,
66
104
  reasoning: false,
67
105
  modalities: {
68
- input: ['text', 'image', 'pdf'],
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({ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1' });
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
- }, output);
221
- expect(output.headers).toEqual({
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({