@subrouter/opencode 0.4.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,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,7 +433,7 @@ 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 appends one ignored notice without another model turn', async () => {
352
437
  const client = createOpencodeClient({ baseUrl: server.url })
353
438
  const session = await client.session.create({
354
439
  query: { directory: projectDir },
@@ -378,29 +463,17 @@ describe('opencode + subrouter provider', () => {
378
463
  path: { id: session.data!.id },
379
464
  query: { directory: projectDir },
380
465
  })
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
- )
466
+ return (messages.data ?? []).filter(({ parts }) =>
467
+ parts.some((part) => part.type === 'text' && part.ignored === true),
468
+ ).length
389
469
  })
390
- .toBe(true)
470
+ .toBe(1)
391
471
 
392
472
  const messages = await client.session.messages({
393
473
  path: { id: session.data!.id },
394
474
  query: { directory: projectDir },
395
475
  })
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
- })
476
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(2)
404
477
  expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1)
405
478
  expect(zenMock.requests).toHaveLength(requestsBefore + 1)
406
479
  }, 120_000)
@@ -520,4 +593,159 @@ describe('opencode + subrouter provider', () => {
520
593
  ]
521
594
  `)
522
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)
523
751
  })