@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.
@@ -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 { addAccount, adapters, loadAccounts, PROVIDER_DISPLAY_NAME, PROVIDER_IDS, savePreset, setSubrouterLog, } from '@subrouter/cli';
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 show the first live candidate', async () => {
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 (claude-opus-4-6)');
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 models permit image and PDF attachments', async () => {
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?.default).toMatchObject({
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>',
@@ -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;
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,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;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,EAC1E,MAAM,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,QAK5C"}
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.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.4.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`. 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
@@ -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
- resolveActiveCandidate,
34
- setSubrouterLog,
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
- export const subrouterPlugin: Plugin = async ({ client }) => {
45
- // OpenCode loads this plugin and the provider module separately. Both import
46
- // @subrouter/cli; this callback is the only log sink the router may use.
47
- // Never console.log here. OpenCode prints plugin logs via client.app.log.
48
- if (client?.app?.log) {
49
- setSubrouterLog((entry) => {
50
- void client.app
51
- .log({
52
- body: {
53
- service: 'subrouter',
54
- level: entry.level,
55
- message: entry.message,
56
- extra: entry.extra,
57
- },
58
- })
59
- .catch(() => {})
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 candidate = await resolveActiveCandidate(name)
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: candidate ? `${name} (${candidate.modelId})` : name,
125
+ name,
84
126
  tool_call: true,
85
- attachment: true,
127
+ attachment,
86
128
  reasoning: false,
87
129
  modalities: {
88
- input: ['text', 'image', 'pdf'] satisfies Array<
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) => {