@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.
@@ -19,6 +19,8 @@ import path from 'node:path'
19
19
  import { pathToFileURL } from 'node:url'
20
20
  import { afterAll, beforeAll, describe, expect, test } from 'vitest'
21
21
  import {
22
+ OPENCODE_AGENT_HEADER,
23
+ OPENCODE_VARIANT_HEADER,
22
24
  OPENAI_WEBSOCKET_SESSION_HEADER,
23
25
  OPENAI_WEBSOCKET_TITLE_HEADER,
24
26
  addAccount,
@@ -65,21 +67,21 @@ function summarizeSessionEvents(events: Event[]) {
65
67
 
66
68
  type MockServer = {
67
69
  url: string
68
- requests: string[]
70
+ requests: Array<{ path: string; body: string }>
69
71
  close: () => Promise<void>
70
72
  }
71
73
 
72
74
  async function startMockServer(
73
75
  handler: (args: { path: string; body: string }, res: import('node:http').ServerResponse) => void,
74
76
  ): Promise<MockServer> {
75
- const requests: string[] = []
77
+ const requests: MockServer['requests'] = []
76
78
  const server: Server = createServer((req, res) => {
77
79
  let body = ''
78
80
  req.on('data', (chunk) => {
79
81
  body += String(chunk)
80
82
  })
81
83
  req.on('end', () => {
82
- requests.push(req.url ?? '')
84
+ requests.push({ path: req.url ?? '', body })
83
85
  handler({ path: req.url ?? '', body }, res)
84
86
  })
85
87
  })
@@ -110,6 +112,7 @@ function sseChunk(data: object) {
110
112
  let home: string
111
113
  let projectDir: string
112
114
  let anthropicMock: MockServer
115
+ let modelsDevMock: MockServer
113
116
  let zenMock: MockServer
114
117
  let server: { url: string; close: () => void }
115
118
  const savedEnv: Record<string, string | undefined> = {}
@@ -170,6 +173,31 @@ beforeAll(async () => {
170
173
  res.end()
171
174
  })
172
175
 
176
+ modelsDevMock = await startMockServer((_request, res) => {
177
+ const emptyProvider = { models: {} }
178
+ const pdfModel = (id: string) => ({
179
+ id,
180
+ attachment: true,
181
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
182
+ limit: { context: 200_000, output: 64_000 },
183
+ })
184
+ res.writeHead(200, { 'content-type': 'application/json' })
185
+ res.end(
186
+ JSON.stringify({
187
+ anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
188
+ openai: emptyProvider,
189
+ xai: emptyProvider,
190
+ 'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
191
+ 'github-copilot': emptyProvider,
192
+ poe: emptyProvider,
193
+ 'minimax-coding-plan': emptyProvider,
194
+ 'kimi-for-coding': emptyProvider,
195
+ 'zai-coding-plan': emptyProvider,
196
+ 'alibaba-coding-plan': emptyProvider,
197
+ }),
198
+ )
199
+ })
200
+
173
201
  // Subrouter state: one rate-limited anthropic account + one zen key
174
202
  const subrouterHome = path.join(home, 'subrouter')
175
203
  await mkdir(subrouterHome, { recursive: true })
@@ -177,6 +205,7 @@ beforeAll(async () => {
177
205
  for (const [key, value] of Object.entries({
178
206
  SUBROUTER_HOME: subrouterHome,
179
207
  SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
208
+ SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
180
209
  SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
181
210
  // Isolate opencode from the user's real global config and auth
182
211
  XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
@@ -208,11 +237,15 @@ beforeAll(async () => {
208
237
  const providerEntry = pathToFileURL(
209
238
  path.join(import.meta.dirname, '..', 'dist', 'provider.js'),
210
239
  ).href
240
+ const pluginEntry = pathToFileURL(
241
+ path.join(import.meta.dirname, '..', 'dist', 'index.js'),
242
+ ).href
211
243
 
212
244
  server = await createOpencodeServer({
213
245
  port: 0,
214
246
  timeout: 60_000,
215
247
  config: {
248
+ plugin: [pluginEntry],
216
249
  provider: {
217
250
  subrouter: {
218
251
  name: 'Subrouter',
@@ -221,6 +254,8 @@ beforeAll(async () => {
221
254
  default: {
222
255
  name: 'subrouter default',
223
256
  tool_call: true,
257
+ attachment: true,
258
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
224
259
  cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
225
260
  limit: { context: 200_000, output: 64_000 },
226
261
  },
@@ -234,6 +269,7 @@ beforeAll(async () => {
234
269
  afterAll(async () => {
235
270
  server?.close()
236
271
  await anthropicMock?.close()
272
+ await modelsDevMock?.close()
237
273
  await zenMock?.close()
238
274
  for (const [key, value] of Object.entries(savedEnv)) {
239
275
  if (value === undefined) delete process.env[key]
@@ -250,20 +286,33 @@ describe('opencode + subrouter provider', () => {
250
286
  sessionID: 'session-1',
251
287
  agent: 'build',
252
288
  model: { providerID: 'subrouter' },
289
+ message: {
290
+ agent: 'build',
291
+ model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
292
+ },
253
293
  },
254
294
  output,
255
295
  )
256
- expect(output.headers).toEqual({ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1' })
296
+ expect(output.headers).toEqual({
297
+ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
298
+ [OPENCODE_AGENT_HEADER]: 'build',
299
+ [OPENCODE_VARIANT_HEADER]: 'high',
300
+ })
257
301
 
302
+ const titleOutput = { headers: {} }
258
303
  addSubrouterHeaders(
259
304
  {
260
305
  sessionID: 'session-2',
261
306
  agent: 'title',
262
307
  model: { providerID: 'subrouter' },
308
+ message: {
309
+ agent: 'build',
310
+ model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
311
+ },
263
312
  },
264
- output,
313
+ titleOutput,
265
314
  )
266
- expect(output.headers).toEqual({
315
+ expect(titleOutput.headers).toEqual({
267
316
  [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
268
317
  [OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
269
318
  })
@@ -299,6 +348,110 @@ describe('opencode + subrouter provider', () => {
299
348
  expect(zenMock.requests.length).toBeGreaterThan(0)
300
349
  }, 120_000)
301
350
 
351
+ test('a pre-existing cooldown leaves a visible ignored route notice without another model turn', async () => {
352
+ const client = createOpencodeClient({ baseUrl: server.url })
353
+ const session = await client.session.create({
354
+ query: { directory: projectDir },
355
+ body: { title: 'subrouter route notice' },
356
+ })
357
+ expect(session.data).toBeTruthy()
358
+ const requestsBefore = zenMock.requests.length
359
+
360
+ const result = await client.session.prompt({
361
+ path: { id: session.data!.id },
362
+ query: { directory: projectDir },
363
+ body: {
364
+ model: { providerID: 'subrouter', modelID: 'default' },
365
+ parts: [{ type: 'text', text: 'say hi again' }],
366
+ },
367
+ })
368
+ expect(
369
+ (result.data?.parts ?? [])
370
+ .filter((part) => part.type === 'text')
371
+ .map((part) => part.text)
372
+ .join('\n'),
373
+ ).toContain('hello from fallback')
374
+
375
+ await expect
376
+ .poll(async () => {
377
+ const messages = await client.session.messages({
378
+ path: { id: session.data!.id },
379
+ query: { directory: projectDir },
380
+ })
381
+ return (messages.data ?? []).some(({ parts }) =>
382
+ parts.some(
383
+ (part) =>
384
+ part.type === 'text' &&
385
+ part.ignored === true &&
386
+ part.text.includes('was rate limited. This message started with opencode-go/'),
387
+ ),
388
+ )
389
+ })
390
+ .toBe(true)
391
+
392
+ const messages = await client.session.messages({
393
+ path: { id: session.data!.id },
394
+ query: { directory: projectDir },
395
+ })
396
+ const notice = (messages.data ?? []).find(({ parts }) =>
397
+ parts.some((part) => part.type === 'text' && part.ignored === true),
398
+ )
399
+ expect(notice?.info).toMatchObject({
400
+ role: 'user',
401
+ agent: 'build',
402
+ model: { providerID: 'subrouter', modelID: 'default' },
403
+ })
404
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1)
405
+ expect(zenMock.requests).toHaveLength(requestsBefore + 1)
406
+ }, 120_000)
407
+
408
+ test('PDF file parts reach a compatible fallback through opencode', async () => {
409
+ const client = createOpencodeClient({ baseUrl: server.url })
410
+ const session = await client.session.create({
411
+ query: { directory: projectDir },
412
+ body: { title: 'subrouter PDF e2e' },
413
+ })
414
+ expect(session.data).toBeTruthy()
415
+
416
+ const result = await client.session.prompt({
417
+ path: { id: session.data!.id },
418
+ query: { directory: projectDir },
419
+ body: {
420
+ model: { providerID: 'subrouter', modelID: 'default' },
421
+ parts: [
422
+ {
423
+ type: 'file',
424
+ filename: 'document.pdf',
425
+ mime: 'application/pdf',
426
+ url: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
427
+ },
428
+ { type: 'text', text: 'read the PDF' },
429
+ ],
430
+ },
431
+ })
432
+
433
+ const texts = (result.data?.parts ?? [])
434
+ .filter((part) => part.type === 'text')
435
+ .map((part) => part.text)
436
+ .join('\n')
437
+ expect(texts).toContain('hello from fallback')
438
+ const request = zenMock.requests.at(-1)
439
+ expect(request).toBeTruthy()
440
+ const body = JSON.parse(request!.body) as {
441
+ messages: Array<{ content: Array<{ type: string; file?: { filename?: string } }> }>
442
+ }
443
+ expect(body.messages.at(-1)?.content).toEqual([
444
+ {
445
+ type: 'file',
446
+ file: {
447
+ filename: 'document.pdf',
448
+ file_data: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
449
+ },
450
+ },
451
+ { type: 'text', text: 'read the PDF' },
452
+ ])
453
+ }, 120_000)
454
+
302
455
  test('all cooling-down accounts retry through opencode instead of dying', async () => {
303
456
  const untilMs = Date.now() + 2_000
304
457
  await markCooldown({
@@ -1,9 +1,12 @@
1
+ import childProcess from 'node:child_process'
1
2
  import { createServer, type Server } 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
8
  import type { Config, PluginInput } from '@opencode-ai/plugin'
9
+ import { createOpencodeClient, type Event } from '@opencode-ai/sdk'
7
10
  import {
8
11
  addAccount,
9
12
  adapters,
@@ -11,11 +14,11 @@ import {
11
14
  PROVIDER_DISPLAY_NAME,
12
15
  PROVIDER_IDS,
13
16
  savePreset,
14
- setSubrouterLog,
15
17
  } from '@subrouter/cli'
16
18
  import { subrouterAuthPlugin, subrouterPlugin } from './index.ts'
17
19
  import { revealRoutedModel, rewritePoweredByModelLine } from './provider.ts'
18
20
 
21
+ const execFile = util.promisify(childProcess.execFile)
19
22
  let home: string
20
23
  const openServers: Server[] = []
21
24
  const pluginInput = {} as PluginInput
@@ -27,7 +30,6 @@ beforeEach(async () => {
27
30
  })
28
31
 
29
32
  afterEach(async () => {
30
- setSubrouterLog(undefined)
31
33
  delete process.env.SUBROUTER_HOME
32
34
  delete process.env.SUBROUTER_OPENAI_ISSUER_URL
33
35
  delete process.env.SUBROUTER_MODELS_DEV_URL
@@ -49,7 +51,11 @@ async function startFakeModelsDev(
49
51
  string,
50
52
  {
51
53
  id: string
52
- modalities?: { output?: string[] }
54
+ attachment?: boolean
55
+ reasoning?: boolean
56
+ temperature?: boolean
57
+ tool_call?: boolean
58
+ modalities?: { input?: string[]; output?: string[] }
53
59
  limit?: { context?: number; output?: number; input?: number }
54
60
  }
55
61
  >
@@ -126,6 +132,121 @@ function authMethod() {
126
132
  })
127
133
  }
128
134
 
135
+ test('plugin load and config do not write stdout or stderr', async () => {
136
+ const script = "import('./src/index.ts').then(async ({ subrouterPlugin }) => { const hooks = await subrouterPlugin({}); await hooks.config?.({}) })"
137
+ const result = await execFile(process.execPath, ['--no-warnings', '--import', 'tsx', '--eval', script], {
138
+ cwd: process.cwd(),
139
+ env: process.env,
140
+ })
141
+ expect(result).toEqual({ stdout: '', stderr: '' })
142
+ })
143
+
144
+ test('provider log callback forwards only to client.app.log', async () => {
145
+ const received = Promise.withResolvers<object>()
146
+ const server = createServer((req, res) => {
147
+ const chunks: Buffer[] = []
148
+ req.on('data', (chunk: Buffer) => {
149
+ chunks.push(chunk)
150
+ })
151
+ req.on('end', () => {
152
+ received.resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as object)
153
+ res.writeHead(200, { 'Content-Type': 'application/json' })
154
+ res.end('{}')
155
+ })
156
+ })
157
+ await new Promise<void>((resolve) => {
158
+ server.listen(0, '127.0.0.1', resolve)
159
+ })
160
+ openServers.push(server)
161
+ const address = server.address()
162
+ if (typeof address === 'string' || !address) throw new Error('failed to bind log server')
163
+ const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` })
164
+ const hooks = await subrouterPlugin({ ...pluginInput, client })
165
+ const config: Config = {}
166
+ await hooks.config?.(config)
167
+ const log = config.provider?.subrouter?.options?.log
168
+ expect(log).toBeTypeOf('function')
169
+ if (typeof log !== 'function') throw new Error('expected provider log callback')
170
+ await log({
171
+ level: 'warn',
172
+ message: 'failover openai/gpt-5.5',
173
+ extra: { provider: 'openai', modelId: 'gpt-5.5' },
174
+ })
175
+ expect(await received.promise).toEqual({
176
+ service: 'subrouter',
177
+ level: 'warn',
178
+ message: 'failover openai/gpt-5.5',
179
+ extra: { provider: 'openai', modelId: 'gpt-5.5' },
180
+ })
181
+ })
182
+
183
+ test('cooldown fallback creates only a persisted ignored notice after idle', async () => {
184
+ const requests: Array<{ path: string; body: any }> = []
185
+ const server = createServer((req, res) => {
186
+ const chunks: Buffer[] = []
187
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
188
+ req.on('end', () => {
189
+ requests.push({
190
+ path: req.url ?? '',
191
+ body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
192
+ })
193
+ res.writeHead(200, { 'Content-Type': 'application/json' })
194
+ res.end('{}')
195
+ })
196
+ })
197
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
198
+ openServers.push(server)
199
+ const address = server.address()
200
+ if (typeof address === 'string' || !address) throw new Error('failed to bind notification server')
201
+ const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` })
202
+ const hooks = await subrouterPlugin({ ...pluginInput, client, directory: '/tmp/project' })
203
+ const config: Config = {}
204
+ await hooks.config?.(config)
205
+ const onCooldownFallback = config.provider?.subrouter?.options?.onCooldownFallback
206
+ expect(onCooldownFallback).toBeTypeOf('function')
207
+ if (typeof onCooldownFallback !== 'function') throw new Error('expected cooldown callback')
208
+
209
+ await onCooldownFallback({
210
+ sessionID: 'session-1',
211
+ agent: 'build',
212
+ variant: 'high',
213
+ preset: 'work',
214
+ preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 252_000 },
215
+ active: { provider: 'openai', modelId: 'gpt-5.6-sol' },
216
+ })
217
+ await onCooldownFallback({
218
+ sessionID: 'session-1',
219
+ agent: 'compaction',
220
+ preset: 'work',
221
+ preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 251_000 },
222
+ active: { provider: 'anthropic', modelId: 'claude-sonnet-5' },
223
+ })
224
+ const event: Event = {
225
+ type: 'session.idle',
226
+ properties: { sessionID: 'session-1' },
227
+ }
228
+ await hooks.event?.({ event })
229
+
230
+ expect(requests).toEqual([
231
+ {
232
+ path: '/session/session-1/message?directory=%2Ftmp%2Fproject',
233
+ body: {
234
+ noReply: true,
235
+ agent: 'build',
236
+ model: { providerID: 'subrouter', modelID: 'work' },
237
+ variant: 'high',
238
+ parts: [
239
+ {
240
+ type: 'text',
241
+ text: 'Subrouter: xai/grok-4.6 was rate limited. This message started with openai/gpt-5.6-sol.',
242
+ ignored: true,
243
+ },
244
+ ],
245
+ },
246
+ },
247
+ ])
248
+ })
249
+
129
250
  test('config hook registers the subrouter provider with preset models', async () => {
130
251
  await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] })
131
252
 
@@ -146,7 +267,7 @@ test('config hook registers the subrouter provider with preset models', async ()
146
267
  expect(provider.models.work.limit).toEqual({ context: 200_000, output: 64_000 })
147
268
  })
148
269
 
149
- test('preset model names show the first live candidate', async () => {
270
+ test('preset model names stay stable when the routed candidate changes', async () => {
150
271
  await addAccount({
151
272
  provider: 'anthropic',
152
273
  account: {
@@ -166,7 +287,7 @@ test('preset model names show the first live candidate', async () => {
166
287
  await hooks.config?.(config)
167
288
 
168
289
  expect(config.provider?.subrouter?.name).toBe(PROVIDER_DISPLAY_NAME)
169
- expect(config.provider?.subrouter?.models?.work?.name).toBe('work (claude-opus-4-6)')
290
+ expect(config.provider?.subrouter?.models?.work?.name).toBe('work')
170
291
  })
171
292
 
172
293
  test('preset model limits follow the first live candidate', async () => {
@@ -177,8 +298,8 @@ test('preset model limits follow the first live candidate', async () => {
177
298
  models: {
178
299
  [modelId]: {
179
300
  id: modelId,
180
- modalities: { output: ['text'] },
181
- limit: { context: 1_000_000, output: 128_000 },
301
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
302
+ limit: { context: 1_000_000, input: 900_000, output: 128_000 },
182
303
  },
183
304
  },
184
305
  },
@@ -203,20 +324,59 @@ test('preset model limits follow the first live candidate', async () => {
203
324
 
204
325
  expect(config.provider?.subrouter?.models?.work?.limit).toEqual({
205
326
  context: 1_000_000,
327
+ input: 900_000,
206
328
  output: 128_000,
207
329
  })
208
330
  expect(config.provider?.subrouter?.models?.default?.limit).toEqual({
209
331
  context: 1_000_000,
332
+ input: 900_000,
210
333
  output: 128_000,
211
334
  })
212
335
  })
213
336
 
214
- test('preset models permit image and PDF attachments', async () => {
337
+ test('preset model input modalities are the union of usable candidates', async () => {
338
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
339
+ openai: {
340
+ models: {
341
+ 'gpt-pdf': {
342
+ id: 'gpt-pdf',
343
+ attachment: true,
344
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
345
+ },
346
+ },
347
+ },
348
+ 'kimi-for-coding': {
349
+ models: {
350
+ 'kimi-image': {
351
+ id: 'kimi-image',
352
+ attachment: true,
353
+ modalities: { input: ['text', 'image'], output: ['text'] },
354
+ },
355
+ },
356
+ },
357
+ })
358
+ await addAccount({
359
+ provider: 'openai',
360
+ account: {
361
+ type: 'oauth',
362
+ access: 'access-1',
363
+ refresh: 'refresh-1',
364
+ expires: Date.now() + 60_000,
365
+ addedAt: 1,
366
+ lastUsed: 1,
367
+ },
368
+ })
369
+ await addAccount({
370
+ provider: 'kimi',
371
+ account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
372
+ })
373
+ await savePreset({ name: 'work', models: ['openai/gpt-pdf', 'kimi/kimi-image'] })
374
+
215
375
  const hooks = await subrouterPlugin(pluginInput)
216
376
  const config: Config = {}
217
377
  await hooks.config?.(config)
218
378
 
219
- expect(config.provider?.subrouter?.models?.default).toMatchObject({
379
+ expect(config.provider?.subrouter?.models?.work).toMatchObject({
220
380
  attachment: true,
221
381
  modalities: {
222
382
  input: ['text', 'image', 'pdf'],
@@ -225,6 +385,75 @@ test('preset models permit image and PDF attachments', async () => {
225
385
  })
226
386
  })
227
387
 
388
+ test('xAI presets do not advertise inline PDF support that its SDK cannot encode', async () => {
389
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
390
+ xai: {
391
+ models: {
392
+ 'grok-pdf': {
393
+ id: 'grok-pdf',
394
+ attachment: true,
395
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
396
+ },
397
+ },
398
+ },
399
+ })
400
+ await addAccount({
401
+ provider: 'xai',
402
+ account: {
403
+ type: 'oauth',
404
+ access: 'access-1',
405
+ refresh: 'refresh-1',
406
+ expires: Date.now() + 60_000,
407
+ addedAt: 1,
408
+ lastUsed: 1,
409
+ },
410
+ })
411
+ await savePreset({ name: 'work', models: ['xai/grok-pdf'] })
412
+
413
+ const hooks = await subrouterPlugin(pluginInput)
414
+ const config: Config = {}
415
+ await hooks.config?.(config)
416
+
417
+ expect(config.provider?.subrouter?.models?.work).toMatchObject({
418
+ attachment: true,
419
+ modalities: {
420
+ input: ['text', 'image'],
421
+ output: ['text'],
422
+ },
423
+ })
424
+ })
425
+
426
+ test('Anthropic-compatible coding plans do not advertise video input', async () => {
427
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
428
+ 'kimi-for-coding': {
429
+ models: {
430
+ 'kimi-video': {
431
+ id: 'kimi-video',
432
+ attachment: true,
433
+ modalities: { input: ['text', 'image', 'video'], output: ['text'] },
434
+ },
435
+ },
436
+ },
437
+ })
438
+ await addAccount({
439
+ provider: 'kimi',
440
+ account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
441
+ })
442
+ await savePreset({ name: 'work', models: ['kimi/kimi-video'] })
443
+
444
+ const hooks = await subrouterPlugin(pluginInput)
445
+ const config: Config = {}
446
+ await hooks.config?.(config)
447
+
448
+ expect(config.provider?.subrouter?.models?.work).toMatchObject({
449
+ attachment: true,
450
+ modalities: {
451
+ input: ['text', 'image'],
452
+ output: ['text'],
453
+ },
454
+ })
455
+ })
456
+
228
457
  test('rewrites the OpenCode powered-by line to the routed candidate', () => {
229
458
  const system = [
230
459
  'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
package/src/provider.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  import {
9
+ OPENCODE_AGENT_HEADER,
10
+ OPENCODE_VARIANT_HEADER,
9
11
  OPENAI_WEBSOCKET_SESSION_HEADER,
10
12
  OPENAI_WEBSOCKET_TITLE_HEADER,
11
13
  PROVIDER_ID,
@@ -45,10 +47,24 @@ export async function revealRoutedModel({
45
47
  }
46
48
 
47
49
  export function addSubrouterHeaders(
48
- input: { sessionID: string; agent: string; model: { providerID: string } },
50
+ input: {
51
+ sessionID: string
52
+ agent: string
53
+ model: { providerID: string }
54
+ message: {
55
+ agent: string
56
+ model: { providerID: string; modelID: string; variant?: string }
57
+ }
58
+ },
49
59
  output: { headers: Record<string, string> },
50
60
  ) {
51
61
  if (input.model.providerID !== PROVIDER_ID) return
52
62
  output.headers[OPENAI_WEBSOCKET_SESSION_HEADER] = input.sessionID
63
+ if (input.agent === input.message.agent) {
64
+ output.headers[OPENCODE_AGENT_HEADER] = input.message.agent
65
+ if (input.message.model.variant) {
66
+ output.headers[OPENCODE_VARIANT_HEADER] = input.message.model.variant
67
+ }
68
+ }
53
69
  if (input.agent === 'title') output.headers[OPENAI_WEBSOCKET_TITLE_HEADER] = 'true'
54
70
  }