@subrouter/opencode 0.4.0 → 0.5.1

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,18 +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
22
  OPENCODE_AGENT_HEADER,
23
23
  OPENCODE_VARIANT_HEADER,
24
24
  OPENAI_WEBSOCKET_SESSION_HEADER,
25
+ ROUTE_AFFINITY_HEADER,
25
26
  OPENAI_WEBSOCKET_TITLE_HEADER,
26
27
  addAccount,
28
+ clearCooldowns,
27
29
  markCooldown,
30
+ savePreset,
28
31
  } from '@subrouter/cli'
29
32
  import { addSubrouterHeaders } from './provider.ts'
30
33
 
@@ -71,8 +74,13 @@ type MockServer = {
71
74
  close: () => Promise<void>
72
75
  }
73
76
 
77
+ type MockHandler = (
78
+ args: { path: string; body: string },
79
+ res: import('node:http').ServerResponse,
80
+ ) => void
81
+
74
82
  async function startMockServer(
75
- handler: (args: { path: string; body: string }, res: import('node:http').ServerResponse) => void,
83
+ handler: MockHandler,
76
84
  ): Promise<MockServer> {
77
85
  const requests: MockServer['requests'] = []
78
86
  const server: Server = createServer((req, res) => {
@@ -114,6 +122,9 @@ let projectDir: string
114
122
  let anthropicMock: MockServer
115
123
  let modelsDevMock: MockServer
116
124
  let zenMock: MockServer
125
+ let openaiMock: MockServer
126
+ let zenRespond: MockHandler
127
+ let defaultZenRespond: MockHandler
117
128
  let server: { url: string; close: () => void }
118
129
  const savedEnv: Record<string, string | undefined> = {}
119
130
 
@@ -129,7 +140,7 @@ beforeAll(async () => {
129
140
  })
130
141
 
131
142
  // Fake opencode-go: streams a canned completion
132
- zenMock = await startMockServer(({ body }, res) => {
143
+ defaultZenRespond = ({ body }, res) => {
133
144
  const streaming = body.includes('"stream":true')
134
145
  if (!streaming) {
135
146
  res.writeHead(200, { 'content-type': 'application/json' })
@@ -171,6 +182,54 @@ beforeAll(async () => {
171
182
  )
172
183
  res.write('data: [DONE]\n\n')
173
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()
174
233
  })
175
234
 
176
235
  modelsDevMock = await startMockServer((_request, res) => {
@@ -185,7 +244,7 @@ beforeAll(async () => {
185
244
  res.end(
186
245
  JSON.stringify({
187
246
  anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
188
- openai: emptyProvider,
247
+ openai: { models: { 'gpt-5.5': pdfModel('gpt-5.5') } },
189
248
  xai: emptyProvider,
190
249
  'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
191
250
  'github-copilot': emptyProvider,
@@ -207,6 +266,7 @@ beforeAll(async () => {
207
266
  SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
208
267
  SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
209
268
  SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
269
+ SUBROUTER_OPENAI_BASE_URL: openaiMock.url,
210
270
  // Isolate opencode from the user's real global config and auth
211
271
  XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
212
272
  XDG_DATA_HOME: path.join(home, 'xdg-data'),
@@ -233,6 +293,23 @@ beforeAll(async () => {
233
293
  provider: 'opencode-go',
234
294
  account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
235
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'] })
236
313
 
237
314
  const providerEntry = pathToFileURL(
238
315
  path.join(import.meta.dirname, '..', 'dist', 'provider.js'),
@@ -266,11 +343,16 @@ beforeAll(async () => {
266
343
  })
267
344
  }, 120_000)
268
345
 
346
+ afterEach(() => {
347
+ zenRespond = defaultZenRespond
348
+ })
349
+
269
350
  afterAll(async () => {
270
351
  server?.close()
271
352
  await anthropicMock?.close()
272
353
  await modelsDevMock?.close()
273
354
  await zenMock?.close()
355
+ await openaiMock?.close()
274
356
  for (const [key, value] of Object.entries(savedEnv)) {
275
357
  if (value === undefined) delete process.env[key]
276
358
  else process.env[key] = value
@@ -281,37 +363,40 @@ afterAll(async () => {
281
363
  describe('opencode + subrouter provider', () => {
282
364
  test('adds session affinity headers for subrouter models', async () => {
283
365
  const output = { headers: {} }
284
- addSubrouterHeaders(
285
- {
366
+ addSubrouterHeaders({
367
+ input: {
286
368
  sessionID: 'session-1',
287
369
  agent: 'build',
288
370
  model: { providerID: 'subrouter' },
289
371
  message: {
372
+ id: 'message-1',
290
373
  agent: 'build',
291
374
  model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
292
375
  },
293
376
  },
294
377
  output,
295
- )
378
+ })
296
379
  expect(output.headers).toEqual({
297
380
  [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
381
+ [ROUTE_AFFINITY_HEADER]: 'message-1',
298
382
  [OPENCODE_AGENT_HEADER]: 'build',
299
383
  [OPENCODE_VARIANT_HEADER]: 'high',
300
384
  })
301
385
 
302
386
  const titleOutput = { headers: {} }
303
- addSubrouterHeaders(
304
- {
387
+ addSubrouterHeaders({
388
+ input: {
305
389
  sessionID: 'session-2',
306
390
  agent: 'title',
307
391
  model: { providerID: 'subrouter' },
308
392
  message: {
393
+ id: 'message-1',
309
394
  agent: 'build',
310
395
  model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
311
396
  },
312
397
  },
313
- titleOutput,
314
- )
398
+ output: titleOutput,
399
+ })
315
400
  expect(titleOutput.headers).toEqual({
316
401
  [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
317
402
  [OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
@@ -348,8 +433,18 @@ describe('opencode + subrouter provider', () => {
348
433
  expect(zenMock.requests.length).toBeGreaterThan(0)
349
434
  }, 120_000)
350
435
 
351
- test('a pre-existing cooldown leaves a visible ignored route notice without another model turn', async () => {
436
+ test('a pre-existing cooldown shows one session toast without another model turn', async () => {
352
437
  const client = createOpencodeClient({ baseUrl: server.url })
438
+ const toasts: Array<{ message: string }> = []
439
+ const subscription = await client.event.subscribe({
440
+ query: { directory: projectDir },
441
+ })
442
+ void (async () => {
443
+ for await (const event of subscription.stream) {
444
+ if (event.type === 'tui.toast.show') toasts.push({ message: event.properties.message })
445
+ }
446
+ })()
447
+
353
448
  const session = await client.session.create({
354
449
  query: { directory: projectDir },
355
450
  body: { title: 'subrouter route notice' },
@@ -372,35 +467,22 @@ describe('opencode + subrouter provider', () => {
372
467
  .join('\n'),
373
468
  ).toContain('hello from fallback')
374
469
 
470
+ // The toast is session-scoped through the trailing session-id marker and never
471
+ // enters the transcript, so no extra user/assistant message and no extra model turn.
375
472
  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)
473
+ .poll(() =>
474
+ toasts.filter(
475
+ ({ message }) =>
476
+ message.startsWith('Subrouter: Using ') && message.endsWith(session.data!.id),
477
+ ).length,
478
+ )
479
+ .toBe(1)
391
480
 
392
481
  const messages = await client.session.messages({
393
482
  path: { id: session.data!.id },
394
483
  query: { directory: projectDir },
395
484
  })
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
- })
485
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(1)
404
486
  expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1)
405
487
  expect(zenMock.requests).toHaveLength(requestsBefore + 1)
406
488
  }, 120_000)
@@ -520,4 +602,159 @@ describe('opencode + subrouter provider', () => {
520
602
  ]
521
603
  `)
522
604
  }, 120_000)
605
+
606
+ test('keeps the fallback candidate through tool follow-ups until the session is idle', async () => {
607
+ await clearCooldowns()
608
+ await markCooldown({
609
+ provider: 'anthropic',
610
+ account: {
611
+ type: 'oauth',
612
+ refresh: 'fake-refresh',
613
+ access: 'fake-access',
614
+ email: 'a@x.com',
615
+ addedAt: 1,
616
+ lastUsed: 1,
617
+ },
618
+ untilMs: Date.now() + 60_000,
619
+ })
620
+ const readable = path.join(projectDir, 'message.txt')
621
+ await writeFile(readable, 'tool result')
622
+ const fallbackBodies: string[] = []
623
+ let fallbackCalls = 0
624
+ let cooldownCleared = Promise.resolve()
625
+ zenRespond = ({ body }, res) => {
626
+ fallbackBodies.push(body)
627
+ fallbackCalls++
628
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
629
+ if (fallbackCalls === 1) {
630
+ cooldownCleared = clearCooldowns()
631
+ res.write(
632
+ sseChunk({
633
+ id: 'tool-1',
634
+ object: 'chat.completion.chunk',
635
+ created: 1,
636
+ model: 'fake-model',
637
+ choices: [
638
+ {
639
+ index: 0,
640
+ delta: {
641
+ role: 'assistant',
642
+ tool_calls: [
643
+ {
644
+ index: 0,
645
+ id: 'call-read',
646
+ type: 'function',
647
+ function: { name: 'read', arguments: JSON.stringify({ filePath: readable }) },
648
+ },
649
+ ],
650
+ },
651
+ finish_reason: null,
652
+ },
653
+ ],
654
+ }),
655
+ )
656
+ res.write(
657
+ sseChunk({
658
+ id: 'tool-1',
659
+ object: 'chat.completion.chunk',
660
+ created: 1,
661
+ model: 'fake-model',
662
+ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
663
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
664
+ }),
665
+ )
666
+ res.end('data: [DONE]\n\n')
667
+ return
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: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }],
676
+ }),
677
+ )
678
+ res.write(
679
+ sseChunk({
680
+ id: 'text-1',
681
+ object: 'chat.completion.chunk',
682
+ created: 1,
683
+ model: 'fake-model',
684
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
685
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
686
+ }),
687
+ )
688
+ res.end('data: [DONE]\n\n')
689
+ }
690
+
691
+ const client = createOpencodeClient({ baseUrl: server.url })
692
+ const session = await client.session.create({
693
+ query: { directory: projectDir },
694
+ body: { title: 'subrouter tool affinity' },
695
+ })
696
+ const anthropicBefore = anthropicMock.requests.length
697
+ await client.session.prompt({
698
+ path: { id: session.data!.id },
699
+ query: { directory: projectDir },
700
+ body: {
701
+ model: { providerID: 'subrouter', modelID: 'default' },
702
+ parts: [{ type: 'text', text: 'read the file' }],
703
+ },
704
+ })
705
+ expect(anthropicMock.requests).toHaveLength(anthropicBefore)
706
+ expect(
707
+ fallbackBodies
708
+ .slice(0, 2)
709
+ .map((body) => body.includes('You are powered by the model named grok-4.6')),
710
+ ).toEqual([true, true])
711
+
712
+ await cooldownCleared
713
+ await clearCooldowns()
714
+ await client.session.prompt({
715
+ path: { id: session.data!.id },
716
+ query: { directory: projectDir },
717
+ body: {
718
+ model: { providerID: 'subrouter', modelID: 'default' },
719
+ parts: [{ type: 'text', text: 'say done' }],
720
+ },
721
+ })
722
+ expect(anthropicMock.requests).toHaveLength(anthropicBefore + 1)
723
+ expect(fallbackCalls).toBeGreaterThanOrEqual(3)
724
+ }, 120_000)
725
+
726
+ test('openai live model advertises apply_patch and not edit or write', async () => {
727
+ const client = createOpencodeClient({ baseUrl: server.url })
728
+ const session = await client.session.create({
729
+ query: { directory: projectDir },
730
+ body: { title: 'subrouter apply_patch' },
731
+ })
732
+ expect(session.data).toBeTruthy()
733
+
734
+ const result = await client.session.prompt({
735
+ path: { id: session.data!.id },
736
+ query: { directory: projectDir },
737
+ body: {
738
+ model: { providerID: 'subrouter', modelID: 'openai-only' },
739
+ parts: [{ type: 'text', text: 'say hi' }],
740
+ },
741
+ })
742
+ const texts = (result.data?.parts ?? [])
743
+ .filter((part) => part.type === 'text')
744
+ .map((part) => part.text)
745
+ .join('\n')
746
+ expect(texts).toContain('hello from openai')
747
+ expect(openaiMock.requests.length).toBeGreaterThan(0)
748
+
749
+ const raw = openaiMock.requests.at(-1)!.body
750
+ const body = JSON.parse(raw) as {
751
+ tools?: Array<{ name?: string; type?: string; function?: { name?: string } }>
752
+ }
753
+ const names = (body.tools ?? []).map((tool) => tool.name ?? tool.function?.name)
754
+ expect(names).toContain('apply_patch')
755
+ expect(names).not.toContain('edit')
756
+ expect(names).not.toContain('write')
757
+ expect(raw).toContain('apply_patch')
758
+ expect(raw.includes('"name":"edit"') || raw.includes('"name": "edit"')).toBe(false)
759
+ }, 120_000)
523
760
  })