@subrouter/opencode 0.3.0 → 0.5.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.
@@ -13,16 +13,21 @@
13
13
  import { createOpencodeClient, type Event } from '@opencode-ai/sdk'
14
14
  import { createOpencodeServer } from '@opencode-ai/sdk/server'
15
15
  import { createServer, type Server } from 'node:http'
16
- import { mkdtemp, rm, mkdir } from 'node:fs/promises'
16
+ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
17
17
  import { tmpdir } from 'node:os'
18
18
  import path from 'node:path'
19
19
  import { pathToFileURL } from 'node:url'
20
- import { afterAll, beforeAll, describe, expect, test } from 'vitest'
20
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'
21
21
  import {
22
+ OPENCODE_AGENT_HEADER,
23
+ OPENCODE_VARIANT_HEADER,
22
24
  OPENAI_WEBSOCKET_SESSION_HEADER,
25
+ ROUTE_AFFINITY_HEADER,
23
26
  OPENAI_WEBSOCKET_TITLE_HEADER,
24
27
  addAccount,
28
+ clearCooldowns,
25
29
  markCooldown,
30
+ savePreset,
26
31
  } from '@subrouter/cli'
27
32
  import { addSubrouterHeaders } from './provider.ts'
28
33
 
@@ -65,21 +70,26 @@ function summarizeSessionEvents(events: Event[]) {
65
70
 
66
71
  type MockServer = {
67
72
  url: string
68
- requests: string[]
73
+ requests: Array<{ path: string; body: string }>
69
74
  close: () => Promise<void>
70
75
  }
71
76
 
77
+ type MockHandler = (
78
+ args: { path: string; body: string },
79
+ res: import('node:http').ServerResponse,
80
+ ) => void
81
+
72
82
  async function startMockServer(
73
- handler: (args: { path: string; body: string }, res: import('node:http').ServerResponse) => void,
83
+ handler: MockHandler,
74
84
  ): Promise<MockServer> {
75
- const requests: string[] = []
85
+ const requests: MockServer['requests'] = []
76
86
  const server: Server = createServer((req, res) => {
77
87
  let body = ''
78
88
  req.on('data', (chunk) => {
79
89
  body += String(chunk)
80
90
  })
81
91
  req.on('end', () => {
82
- requests.push(req.url ?? '')
92
+ requests.push({ path: req.url ?? '', body })
83
93
  handler({ path: req.url ?? '', body }, res)
84
94
  })
85
95
  })
@@ -110,7 +120,11 @@ function sseChunk(data: object) {
110
120
  let home: string
111
121
  let projectDir: string
112
122
  let anthropicMock: MockServer
123
+ let modelsDevMock: MockServer
113
124
  let zenMock: MockServer
125
+ let openaiMock: MockServer
126
+ let zenRespond: MockHandler
127
+ let defaultZenRespond: MockHandler
114
128
  let server: { url: string; close: () => void }
115
129
  const savedEnv: Record<string, string | undefined> = {}
116
130
 
@@ -126,7 +140,7 @@ beforeAll(async () => {
126
140
  })
127
141
 
128
142
  // Fake opencode-go: streams a canned completion
129
- zenMock = await startMockServer(({ body }, res) => {
143
+ defaultZenRespond = ({ body }, res) => {
130
144
  const streaming = body.includes('"stream":true')
131
145
  if (!streaming) {
132
146
  res.writeHead(200, { 'content-type': 'application/json' })
@@ -168,6 +182,79 @@ beforeAll(async () => {
168
182
  )
169
183
  res.write('data: [DONE]\n\n')
170
184
  res.end()
185
+ }
186
+ zenRespond = defaultZenRespond
187
+ zenMock = await startMockServer((request, response) => zenRespond(request, response))
188
+
189
+ openaiMock = await startMockServer((_request, res) => {
190
+ const text = 'hello from openai'
191
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
192
+ for (const event of [
193
+ {
194
+ type: 'response.created',
195
+ response: { id: 'resp-1', created_at: 1, model: 'gpt-5.5', service_tier: null },
196
+ },
197
+ {
198
+ type: 'response.output_item.added',
199
+ output_index: 0,
200
+ item: { type: 'message', id: 'msg-1', role: 'assistant', status: 'in_progress', content: [] },
201
+ },
202
+ { type: 'response.content_part.added', part: { type: 'output_text', text: '' } },
203
+ { type: 'response.output_text.delta', item_id: 'msg-1', delta: text },
204
+ {
205
+ type: 'response.output_item.done',
206
+ output_index: 0,
207
+ item: {
208
+ type: 'message',
209
+ id: 'msg-1',
210
+ role: 'assistant',
211
+ status: 'completed',
212
+ content: [{ type: 'output_text', text }],
213
+ },
214
+ },
215
+ {
216
+ type: 'response.completed',
217
+ response: {
218
+ id: 'resp-1',
219
+ status: 'completed',
220
+ usage: {
221
+ input_tokens: 5,
222
+ output_tokens: 3,
223
+ total_tokens: 8,
224
+ input_tokens_details: { cached_tokens: 0 },
225
+ },
226
+ },
227
+ },
228
+ ]) {
229
+ res.write(sseChunk(event))
230
+ }
231
+ res.write('data: [DONE]\n\n')
232
+ res.end()
233
+ })
234
+
235
+ modelsDevMock = await startMockServer((_request, res) => {
236
+ const emptyProvider = { models: {} }
237
+ const pdfModel = (id: string) => ({
238
+ id,
239
+ attachment: true,
240
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
241
+ limit: { context: 200_000, output: 64_000 },
242
+ })
243
+ res.writeHead(200, { 'content-type': 'application/json' })
244
+ res.end(
245
+ JSON.stringify({
246
+ anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
247
+ openai: { models: { 'gpt-5.5': pdfModel('gpt-5.5') } },
248
+ xai: emptyProvider,
249
+ 'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
250
+ 'github-copilot': emptyProvider,
251
+ poe: emptyProvider,
252
+ 'minimax-coding-plan': emptyProvider,
253
+ 'kimi-for-coding': emptyProvider,
254
+ 'zai-coding-plan': emptyProvider,
255
+ 'alibaba-coding-plan': emptyProvider,
256
+ }),
257
+ )
171
258
  })
172
259
 
173
260
  // Subrouter state: one rate-limited anthropic account + one zen key
@@ -177,7 +264,9 @@ beforeAll(async () => {
177
264
  for (const [key, value] of Object.entries({
178
265
  SUBROUTER_HOME: subrouterHome,
179
266
  SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
267
+ SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
180
268
  SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
269
+ SUBROUTER_OPENAI_BASE_URL: openaiMock.url,
181
270
  // Isolate opencode from the user's real global config and auth
182
271
  XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
183
272
  XDG_DATA_HOME: path.join(home, 'xdg-data'),
@@ -204,15 +293,36 @@ beforeAll(async () => {
204
293
  provider: 'opencode-go',
205
294
  account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
206
295
  })
296
+ await addAccount({
297
+ provider: 'openai',
298
+ account: {
299
+ type: 'oauth',
300
+ refresh: 'openai-refresh',
301
+ access: 'openai-access',
302
+ expires: Date.now() + 1_000_000_000,
303
+ email: 'o@x.com',
304
+ addedAt: 1,
305
+ lastUsed: 1,
306
+ },
307
+ })
308
+ await savePreset({
309
+ name: 'default',
310
+ models: ['anthropic/claude-opus-4-6', 'opencode-go/grok-4.6'],
311
+ })
312
+ await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] })
207
313
 
208
314
  const providerEntry = pathToFileURL(
209
315
  path.join(import.meta.dirname, '..', 'dist', 'provider.js'),
210
316
  ).href
317
+ const pluginEntry = pathToFileURL(
318
+ path.join(import.meta.dirname, '..', 'dist', 'index.js'),
319
+ ).href
211
320
 
212
321
  server = await createOpencodeServer({
213
322
  port: 0,
214
323
  timeout: 60_000,
215
324
  config: {
325
+ plugin: [pluginEntry],
216
326
  provider: {
217
327
  subrouter: {
218
328
  name: 'Subrouter',
@@ -221,6 +331,8 @@ beforeAll(async () => {
221
331
  default: {
222
332
  name: 'subrouter default',
223
333
  tool_call: true,
334
+ attachment: true,
335
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
224
336
  cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
225
337
  limit: { context: 200_000, output: 64_000 },
226
338
  },
@@ -231,10 +343,16 @@ beforeAll(async () => {
231
343
  })
232
344
  }, 120_000)
233
345
 
346
+ afterEach(() => {
347
+ zenRespond = defaultZenRespond
348
+ })
349
+
234
350
  afterAll(async () => {
235
351
  server?.close()
236
352
  await anthropicMock?.close()
353
+ await modelsDevMock?.close()
237
354
  await zenMock?.close()
355
+ await openaiMock?.close()
238
356
  for (const [key, value] of Object.entries(savedEnv)) {
239
357
  if (value === undefined) delete process.env[key]
240
358
  else process.env[key] = value
@@ -245,25 +363,41 @@ afterAll(async () => {
245
363
  describe('opencode + subrouter provider', () => {
246
364
  test('adds session affinity headers for subrouter models', async () => {
247
365
  const output = { headers: {} }
248
- addSubrouterHeaders(
249
- {
366
+ addSubrouterHeaders({
367
+ input: {
250
368
  sessionID: 'session-1',
251
369
  agent: 'build',
252
370
  model: { providerID: 'subrouter' },
371
+ message: {
372
+ id: 'message-1',
373
+ agent: 'build',
374
+ model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
375
+ },
253
376
  },
254
377
  output,
255
- )
256
- expect(output.headers).toEqual({ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1' })
378
+ })
379
+ expect(output.headers).toEqual({
380
+ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
381
+ [ROUTE_AFFINITY_HEADER]: 'message-1',
382
+ [OPENCODE_AGENT_HEADER]: 'build',
383
+ [OPENCODE_VARIANT_HEADER]: 'high',
384
+ })
257
385
 
258
- addSubrouterHeaders(
259
- {
386
+ const titleOutput = { headers: {} }
387
+ addSubrouterHeaders({
388
+ input: {
260
389
  sessionID: 'session-2',
261
390
  agent: 'title',
262
391
  model: { providerID: 'subrouter' },
392
+ message: {
393
+ id: 'message-1',
394
+ agent: 'build',
395
+ model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
396
+ },
263
397
  },
264
- output,
265
- )
266
- expect(output.headers).toEqual({
398
+ output: titleOutput,
399
+ })
400
+ expect(titleOutput.headers).toEqual({
267
401
  [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
268
402
  [OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
269
403
  })
@@ -299,6 +433,98 @@ describe('opencode + subrouter provider', () => {
299
433
  expect(zenMock.requests.length).toBeGreaterThan(0)
300
434
  }, 120_000)
301
435
 
436
+ test('a pre-existing cooldown appends one ignored notice without another model turn', async () => {
437
+ const client = createOpencodeClient({ baseUrl: server.url })
438
+ const session = await client.session.create({
439
+ query: { directory: projectDir },
440
+ body: { title: 'subrouter route notice' },
441
+ })
442
+ expect(session.data).toBeTruthy()
443
+ const requestsBefore = zenMock.requests.length
444
+
445
+ const result = await client.session.prompt({
446
+ path: { id: session.data!.id },
447
+ query: { directory: projectDir },
448
+ body: {
449
+ model: { providerID: 'subrouter', modelID: 'default' },
450
+ parts: [{ type: 'text', text: 'say hi again' }],
451
+ },
452
+ })
453
+ expect(
454
+ (result.data?.parts ?? [])
455
+ .filter((part) => part.type === 'text')
456
+ .map((part) => part.text)
457
+ .join('\n'),
458
+ ).toContain('hello from fallback')
459
+
460
+ await expect
461
+ .poll(async () => {
462
+ const messages = await client.session.messages({
463
+ path: { id: session.data!.id },
464
+ query: { directory: projectDir },
465
+ })
466
+ return (messages.data ?? []).filter(({ parts }) =>
467
+ parts.some((part) => part.type === 'text' && part.ignored === true),
468
+ ).length
469
+ })
470
+ .toBe(1)
471
+
472
+ const messages = await client.session.messages({
473
+ path: { id: session.data!.id },
474
+ query: { directory: projectDir },
475
+ })
476
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(2)
477
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1)
478
+ expect(zenMock.requests).toHaveLength(requestsBefore + 1)
479
+ }, 120_000)
480
+
481
+ test('PDF file parts reach a compatible fallback through opencode', async () => {
482
+ const client = createOpencodeClient({ baseUrl: server.url })
483
+ const session = await client.session.create({
484
+ query: { directory: projectDir },
485
+ body: { title: 'subrouter PDF e2e' },
486
+ })
487
+ expect(session.data).toBeTruthy()
488
+
489
+ const result = await client.session.prompt({
490
+ path: { id: session.data!.id },
491
+ query: { directory: projectDir },
492
+ body: {
493
+ model: { providerID: 'subrouter', modelID: 'default' },
494
+ parts: [
495
+ {
496
+ type: 'file',
497
+ filename: 'document.pdf',
498
+ mime: 'application/pdf',
499
+ url: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
500
+ },
501
+ { type: 'text', text: 'read the PDF' },
502
+ ],
503
+ },
504
+ })
505
+
506
+ const texts = (result.data?.parts ?? [])
507
+ .filter((part) => part.type === 'text')
508
+ .map((part) => part.text)
509
+ .join('\n')
510
+ expect(texts).toContain('hello from fallback')
511
+ const request = zenMock.requests.at(-1)
512
+ expect(request).toBeTruthy()
513
+ const body = JSON.parse(request!.body) as {
514
+ messages: Array<{ content: Array<{ type: string; file?: { filename?: string } }> }>
515
+ }
516
+ expect(body.messages.at(-1)?.content).toEqual([
517
+ {
518
+ type: 'file',
519
+ file: {
520
+ filename: 'document.pdf',
521
+ file_data: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
522
+ },
523
+ },
524
+ { type: 'text', text: 'read the PDF' },
525
+ ])
526
+ }, 120_000)
527
+
302
528
  test('all cooling-down accounts retry through opencode instead of dying', async () => {
303
529
  const untilMs = Date.now() + 2_000
304
530
  await markCooldown({
@@ -367,4 +593,159 @@ describe('opencode + subrouter provider', () => {
367
593
  ]
368
594
  `)
369
595
  }, 120_000)
596
+
597
+ test('keeps the fallback candidate through tool follow-ups until the session is idle', async () => {
598
+ await clearCooldowns()
599
+ await markCooldown({
600
+ provider: 'anthropic',
601
+ account: {
602
+ type: 'oauth',
603
+ refresh: 'fake-refresh',
604
+ access: 'fake-access',
605
+ email: 'a@x.com',
606
+ addedAt: 1,
607
+ lastUsed: 1,
608
+ },
609
+ untilMs: Date.now() + 60_000,
610
+ })
611
+ const readable = path.join(projectDir, 'message.txt')
612
+ await writeFile(readable, 'tool result')
613
+ const fallbackBodies: string[] = []
614
+ let fallbackCalls = 0
615
+ let cooldownCleared = Promise.resolve()
616
+ zenRespond = ({ body }, res) => {
617
+ fallbackBodies.push(body)
618
+ fallbackCalls++
619
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
620
+ if (fallbackCalls === 1) {
621
+ cooldownCleared = clearCooldowns()
622
+ res.write(
623
+ sseChunk({
624
+ id: 'tool-1',
625
+ object: 'chat.completion.chunk',
626
+ created: 1,
627
+ model: 'fake-model',
628
+ choices: [
629
+ {
630
+ index: 0,
631
+ delta: {
632
+ role: 'assistant',
633
+ tool_calls: [
634
+ {
635
+ index: 0,
636
+ id: 'call-read',
637
+ type: 'function',
638
+ function: { name: 'read', arguments: JSON.stringify({ filePath: readable }) },
639
+ },
640
+ ],
641
+ },
642
+ finish_reason: null,
643
+ },
644
+ ],
645
+ }),
646
+ )
647
+ res.write(
648
+ sseChunk({
649
+ id: 'tool-1',
650
+ object: 'chat.completion.chunk',
651
+ created: 1,
652
+ model: 'fake-model',
653
+ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
654
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
655
+ }),
656
+ )
657
+ res.end('data: [DONE]\n\n')
658
+ return
659
+ }
660
+ res.write(
661
+ sseChunk({
662
+ id: 'text-1',
663
+ object: 'chat.completion.chunk',
664
+ created: 1,
665
+ model: 'fake-model',
666
+ choices: [{ index: 0, delta: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }],
667
+ }),
668
+ )
669
+ res.write(
670
+ sseChunk({
671
+ id: 'text-1',
672
+ object: 'chat.completion.chunk',
673
+ created: 1,
674
+ model: 'fake-model',
675
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
676
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
677
+ }),
678
+ )
679
+ res.end('data: [DONE]\n\n')
680
+ }
681
+
682
+ const client = createOpencodeClient({ baseUrl: server.url })
683
+ const session = await client.session.create({
684
+ query: { directory: projectDir },
685
+ body: { title: 'subrouter tool affinity' },
686
+ })
687
+ const anthropicBefore = anthropicMock.requests.length
688
+ await client.session.prompt({
689
+ path: { id: session.data!.id },
690
+ query: { directory: projectDir },
691
+ body: {
692
+ model: { providerID: 'subrouter', modelID: 'default' },
693
+ parts: [{ type: 'text', text: 'read the file' }],
694
+ },
695
+ })
696
+ expect(anthropicMock.requests).toHaveLength(anthropicBefore)
697
+ expect(
698
+ fallbackBodies
699
+ .slice(0, 2)
700
+ .map((body) => body.includes('You are powered by the model named grok-4.6')),
701
+ ).toEqual([true, true])
702
+
703
+ await cooldownCleared
704
+ await clearCooldowns()
705
+ await client.session.prompt({
706
+ path: { id: session.data!.id },
707
+ query: { directory: projectDir },
708
+ body: {
709
+ model: { providerID: 'subrouter', modelID: 'default' },
710
+ parts: [{ type: 'text', text: 'say done' }],
711
+ },
712
+ })
713
+ expect(anthropicMock.requests).toHaveLength(anthropicBefore + 1)
714
+ expect(fallbackCalls).toBeGreaterThanOrEqual(3)
715
+ }, 120_000)
716
+
717
+ test('openai live model advertises apply_patch and not edit or write', async () => {
718
+ const client = createOpencodeClient({ baseUrl: server.url })
719
+ const session = await client.session.create({
720
+ query: { directory: projectDir },
721
+ body: { title: 'subrouter apply_patch' },
722
+ })
723
+ expect(session.data).toBeTruthy()
724
+
725
+ const result = await client.session.prompt({
726
+ path: { id: session.data!.id },
727
+ query: { directory: projectDir },
728
+ body: {
729
+ model: { providerID: 'subrouter', modelID: 'openai-only' },
730
+ parts: [{ type: 'text', text: 'say hi' }],
731
+ },
732
+ })
733
+ const texts = (result.data?.parts ?? [])
734
+ .filter((part) => part.type === 'text')
735
+ .map((part) => part.text)
736
+ .join('\n')
737
+ expect(texts).toContain('hello from openai')
738
+ expect(openaiMock.requests.length).toBeGreaterThan(0)
739
+
740
+ const raw = openaiMock.requests.at(-1)!.body
741
+ const body = JSON.parse(raw) as {
742
+ tools?: Array<{ name?: string; type?: string; function?: { name?: string } }>
743
+ }
744
+ const names = (body.tools ?? []).map((tool) => tool.name ?? tool.function?.name)
745
+ expect(names).toContain('apply_patch')
746
+ expect(names).not.toContain('edit')
747
+ expect(names).not.toContain('write')
748
+ expect(raw).toContain('apply_patch')
749
+ expect(raw.includes('"name":"edit"') || raw.includes('"name": "edit"')).toBe(false)
750
+ }, 120_000)
370
751
  })