@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.
@@ -12,12 +12,12 @@
12
12
  import { createOpencodeClient } from '@opencode-ai/sdk';
13
13
  import { createOpencodeServer } from '@opencode-ai/sdk/server';
14
14
  import { createServer } from 'node:http';
15
- import { mkdtemp, rm, mkdir } from 'node:fs/promises';
15
+ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
16
16
  import { tmpdir } from 'node:os';
17
17
  import path from 'node:path';
18
18
  import { pathToFileURL } from 'node:url';
19
- import { afterAll, beforeAll, describe, expect, test } from 'vitest';
20
- import { OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, addAccount, markCooldown, } from '@subrouter/cli';
19
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
20
+ import { OPENCODE_AGENT_HEADER, OPENCODE_VARIANT_HEADER, OPENAI_WEBSOCKET_SESSION_HEADER, ROUTE_AFFINITY_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, addAccount, clearCooldowns, markCooldown, savePreset, } from '@subrouter/cli';
21
21
  import { addSubrouterHeaders } from "./provider.js";
22
22
  function summarizeSessionEvents(events) {
23
23
  const summary = [];
@@ -58,7 +58,7 @@ async function startMockServer(handler) {
58
58
  body += String(chunk);
59
59
  });
60
60
  req.on('end', () => {
61
- requests.push(req.url ?? '');
61
+ requests.push({ path: req.url ?? '', body });
62
62
  handler({ path: req.url ?? '', body }, res);
63
63
  });
64
64
  });
@@ -88,7 +88,11 @@ function sseChunk(data) {
88
88
  let home;
89
89
  let projectDir;
90
90
  let anthropicMock;
91
+ let modelsDevMock;
91
92
  let zenMock;
93
+ let openaiMock;
94
+ let zenRespond;
95
+ let defaultZenRespond;
92
96
  let server;
93
97
  const savedEnv = {};
94
98
  beforeAll(async () => {
@@ -101,7 +105,7 @@ beforeAll(async () => {
101
105
  res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }));
102
106
  });
103
107
  // Fake opencode-go: streams a canned completion
104
- zenMock = await startMockServer(({ body }, res) => {
108
+ defaultZenRespond = ({ body }, res) => {
105
109
  const streaming = body.includes('"stream":true');
106
110
  if (!streaming) {
107
111
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -137,6 +141,75 @@ beforeAll(async () => {
137
141
  }));
138
142
  res.write('data: [DONE]\n\n');
139
143
  res.end();
144
+ };
145
+ zenRespond = defaultZenRespond;
146
+ zenMock = await startMockServer((request, response) => zenRespond(request, response));
147
+ openaiMock = await startMockServer((_request, res) => {
148
+ const text = 'hello from openai';
149
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
150
+ for (const event of [
151
+ {
152
+ type: 'response.created',
153
+ response: { id: 'resp-1', created_at: 1, model: 'gpt-5.5', service_tier: null },
154
+ },
155
+ {
156
+ type: 'response.output_item.added',
157
+ output_index: 0,
158
+ item: { type: 'message', id: 'msg-1', role: 'assistant', status: 'in_progress', content: [] },
159
+ },
160
+ { type: 'response.content_part.added', part: { type: 'output_text', text: '' } },
161
+ { type: 'response.output_text.delta', item_id: 'msg-1', delta: text },
162
+ {
163
+ type: 'response.output_item.done',
164
+ output_index: 0,
165
+ item: {
166
+ type: 'message',
167
+ id: 'msg-1',
168
+ role: 'assistant',
169
+ status: 'completed',
170
+ content: [{ type: 'output_text', text }],
171
+ },
172
+ },
173
+ {
174
+ type: 'response.completed',
175
+ response: {
176
+ id: 'resp-1',
177
+ status: 'completed',
178
+ usage: {
179
+ input_tokens: 5,
180
+ output_tokens: 3,
181
+ total_tokens: 8,
182
+ input_tokens_details: { cached_tokens: 0 },
183
+ },
184
+ },
185
+ },
186
+ ]) {
187
+ res.write(sseChunk(event));
188
+ }
189
+ res.write('data: [DONE]\n\n');
190
+ res.end();
191
+ });
192
+ modelsDevMock = await startMockServer((_request, res) => {
193
+ const emptyProvider = { models: {} };
194
+ const pdfModel = (id) => ({
195
+ id,
196
+ attachment: true,
197
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
198
+ limit: { context: 200_000, output: 64_000 },
199
+ });
200
+ res.writeHead(200, { 'content-type': 'application/json' });
201
+ res.end(JSON.stringify({
202
+ anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
203
+ openai: { models: { 'gpt-5.5': pdfModel('gpt-5.5') } },
204
+ xai: emptyProvider,
205
+ 'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
206
+ 'github-copilot': emptyProvider,
207
+ poe: emptyProvider,
208
+ 'minimax-coding-plan': emptyProvider,
209
+ 'kimi-for-coding': emptyProvider,
210
+ 'zai-coding-plan': emptyProvider,
211
+ 'alibaba-coding-plan': emptyProvider,
212
+ }));
140
213
  });
141
214
  // Subrouter state: one rate-limited anthropic account + one zen key
142
215
  const subrouterHome = path.join(home, 'subrouter');
@@ -144,7 +217,9 @@ beforeAll(async () => {
144
217
  for (const [key, value] of Object.entries({
145
218
  SUBROUTER_HOME: subrouterHome,
146
219
  SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
220
+ SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
147
221
  SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
222
+ SUBROUTER_OPENAI_BASE_URL: openaiMock.url,
148
223
  // Isolate opencode from the user's real global config and auth
149
224
  XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
150
225
  XDG_DATA_HOME: path.join(home, 'xdg-data'),
@@ -170,11 +245,30 @@ beforeAll(async () => {
170
245
  provider: 'opencode-go',
171
246
  account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
172
247
  });
248
+ await addAccount({
249
+ provider: 'openai',
250
+ account: {
251
+ type: 'oauth',
252
+ refresh: 'openai-refresh',
253
+ access: 'openai-access',
254
+ expires: Date.now() + 1_000_000_000,
255
+ email: 'o@x.com',
256
+ addedAt: 1,
257
+ lastUsed: 1,
258
+ },
259
+ });
260
+ await savePreset({
261
+ name: 'default',
262
+ models: ['anthropic/claude-opus-4-6', 'opencode-go/grok-4.6'],
263
+ });
264
+ await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] });
173
265
  const providerEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'provider.js')).href;
266
+ const pluginEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'index.js')).href;
174
267
  server = await createOpencodeServer({
175
268
  port: 0,
176
269
  timeout: 60_000,
177
270
  config: {
271
+ plugin: [pluginEntry],
178
272
  provider: {
179
273
  subrouter: {
180
274
  name: 'Subrouter',
@@ -183,6 +277,8 @@ beforeAll(async () => {
183
277
  default: {
184
278
  name: 'subrouter default',
185
279
  tool_call: true,
280
+ attachment: true,
281
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
186
282
  cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
187
283
  limit: { context: 200_000, output: 64_000 },
188
284
  },
@@ -192,10 +288,15 @@ beforeAll(async () => {
192
288
  },
193
289
  });
194
290
  }, 120_000);
291
+ afterEach(() => {
292
+ zenRespond = defaultZenRespond;
293
+ });
195
294
  afterAll(async () => {
196
295
  server?.close();
197
296
  await anthropicMock?.close();
297
+ await modelsDevMock?.close();
198
298
  await zenMock?.close();
299
+ await openaiMock?.close();
199
300
  for (const [key, value] of Object.entries(savedEnv)) {
200
301
  if (value === undefined)
201
302
  delete process.env[key];
@@ -208,17 +309,39 @@ describe('opencode + subrouter provider', () => {
208
309
  test('adds session affinity headers for subrouter models', async () => {
209
310
  const output = { headers: {} };
210
311
  addSubrouterHeaders({
211
- sessionID: 'session-1',
212
- agent: 'build',
213
- model: { providerID: 'subrouter' },
214
- }, output);
215
- expect(output.headers).toEqual({ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1' });
216
- addSubrouterHeaders({
217
- sessionID: 'session-2',
218
- agent: 'title',
219
- model: { providerID: 'subrouter' },
220
- }, output);
312
+ input: {
313
+ sessionID: 'session-1',
314
+ agent: 'build',
315
+ model: { providerID: 'subrouter' },
316
+ message: {
317
+ id: 'message-1',
318
+ agent: 'build',
319
+ model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
320
+ },
321
+ },
322
+ output,
323
+ });
221
324
  expect(output.headers).toEqual({
325
+ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
326
+ [ROUTE_AFFINITY_HEADER]: 'message-1',
327
+ [OPENCODE_AGENT_HEADER]: 'build',
328
+ [OPENCODE_VARIANT_HEADER]: 'high',
329
+ });
330
+ const titleOutput = { headers: {} };
331
+ addSubrouterHeaders({
332
+ input: {
333
+ sessionID: 'session-2',
334
+ agent: 'title',
335
+ model: { providerID: 'subrouter' },
336
+ message: {
337
+ id: 'message-1',
338
+ agent: 'build',
339
+ model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
340
+ },
341
+ },
342
+ output: titleOutput,
343
+ });
344
+ expect(titleOutput.headers).toEqual({
222
345
  [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
223
346
  [OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
224
347
  });
@@ -248,6 +371,85 @@ describe('opencode + subrouter provider', () => {
248
371
  expect(anthropicMock.requests.length).toBeGreaterThan(0);
249
372
  expect(zenMock.requests.length).toBeGreaterThan(0);
250
373
  }, 120_000);
374
+ test('a pre-existing cooldown appends one ignored notice without another model turn', async () => {
375
+ const client = createOpencodeClient({ baseUrl: server.url });
376
+ const session = await client.session.create({
377
+ query: { directory: projectDir },
378
+ body: { title: 'subrouter route notice' },
379
+ });
380
+ expect(session.data).toBeTruthy();
381
+ const requestsBefore = zenMock.requests.length;
382
+ const result = await client.session.prompt({
383
+ path: { id: session.data.id },
384
+ query: { directory: projectDir },
385
+ body: {
386
+ model: { providerID: 'subrouter', modelID: 'default' },
387
+ parts: [{ type: 'text', text: 'say hi again' }],
388
+ },
389
+ });
390
+ expect((result.data?.parts ?? [])
391
+ .filter((part) => part.type === 'text')
392
+ .map((part) => part.text)
393
+ .join('\n')).toContain('hello from fallback');
394
+ await expect
395
+ .poll(async () => {
396
+ const messages = await client.session.messages({
397
+ path: { id: session.data.id },
398
+ query: { directory: projectDir },
399
+ });
400
+ return (messages.data ?? []).filter(({ parts }) => parts.some((part) => part.type === 'text' && part.ignored === true)).length;
401
+ })
402
+ .toBe(1);
403
+ const messages = await client.session.messages({
404
+ path: { id: session.data.id },
405
+ query: { directory: projectDir },
406
+ });
407
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(2);
408
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1);
409
+ expect(zenMock.requests).toHaveLength(requestsBefore + 1);
410
+ }, 120_000);
411
+ test('PDF file parts reach a compatible fallback through opencode', async () => {
412
+ const client = createOpencodeClient({ baseUrl: server.url });
413
+ const session = await client.session.create({
414
+ query: { directory: projectDir },
415
+ body: { title: 'subrouter PDF e2e' },
416
+ });
417
+ expect(session.data).toBeTruthy();
418
+ const result = await client.session.prompt({
419
+ path: { id: session.data.id },
420
+ query: { directory: projectDir },
421
+ body: {
422
+ model: { providerID: 'subrouter', modelID: 'default' },
423
+ parts: [
424
+ {
425
+ type: 'file',
426
+ filename: 'document.pdf',
427
+ mime: 'application/pdf',
428
+ url: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
429
+ },
430
+ { type: 'text', text: 'read the PDF' },
431
+ ],
432
+ },
433
+ });
434
+ const texts = (result.data?.parts ?? [])
435
+ .filter((part) => part.type === 'text')
436
+ .map((part) => part.text)
437
+ .join('\n');
438
+ expect(texts).toContain('hello from fallback');
439
+ const request = zenMock.requests.at(-1);
440
+ expect(request).toBeTruthy();
441
+ const body = JSON.parse(request.body);
442
+ expect(body.messages.at(-1)?.content).toEqual([
443
+ {
444
+ type: 'file',
445
+ file: {
446
+ filename: 'document.pdf',
447
+ file_data: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
448
+ },
449
+ },
450
+ { type: 'text', text: 'read the PDF' },
451
+ ]);
452
+ }, 120_000);
251
453
  test('all cooling-down accounts retry through opencode instead of dying', async () => {
252
454
  const untilMs = Date.now() + 2_000;
253
455
  await markCooldown({
@@ -309,4 +511,141 @@ describe('opencode + subrouter provider', () => {
309
511
  ]
310
512
  `);
311
513
  }, 120_000);
514
+ test('keeps the fallback candidate through tool follow-ups until the session is idle', async () => {
515
+ await clearCooldowns();
516
+ await markCooldown({
517
+ provider: 'anthropic',
518
+ account: {
519
+ type: 'oauth',
520
+ refresh: 'fake-refresh',
521
+ access: 'fake-access',
522
+ email: 'a@x.com',
523
+ addedAt: 1,
524
+ lastUsed: 1,
525
+ },
526
+ untilMs: Date.now() + 60_000,
527
+ });
528
+ const readable = path.join(projectDir, 'message.txt');
529
+ await writeFile(readable, 'tool result');
530
+ const fallbackBodies = [];
531
+ let fallbackCalls = 0;
532
+ let cooldownCleared = Promise.resolve();
533
+ zenRespond = ({ body }, res) => {
534
+ fallbackBodies.push(body);
535
+ fallbackCalls++;
536
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
537
+ if (fallbackCalls === 1) {
538
+ cooldownCleared = clearCooldowns();
539
+ res.write(sseChunk({
540
+ id: 'tool-1',
541
+ object: 'chat.completion.chunk',
542
+ created: 1,
543
+ model: 'fake-model',
544
+ choices: [
545
+ {
546
+ index: 0,
547
+ delta: {
548
+ role: 'assistant',
549
+ tool_calls: [
550
+ {
551
+ index: 0,
552
+ id: 'call-read',
553
+ type: 'function',
554
+ function: { name: 'read', arguments: JSON.stringify({ filePath: readable }) },
555
+ },
556
+ ],
557
+ },
558
+ finish_reason: null,
559
+ },
560
+ ],
561
+ }));
562
+ res.write(sseChunk({
563
+ id: 'tool-1',
564
+ object: 'chat.completion.chunk',
565
+ created: 1,
566
+ model: 'fake-model',
567
+ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
568
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
569
+ }));
570
+ res.end('data: [DONE]\n\n');
571
+ return;
572
+ }
573
+ res.write(sseChunk({
574
+ id: 'text-1',
575
+ object: 'chat.completion.chunk',
576
+ created: 1,
577
+ model: 'fake-model',
578
+ choices: [{ index: 0, delta: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }],
579
+ }));
580
+ res.write(sseChunk({
581
+ id: 'text-1',
582
+ object: 'chat.completion.chunk',
583
+ created: 1,
584
+ model: 'fake-model',
585
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
586
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
587
+ }));
588
+ res.end('data: [DONE]\n\n');
589
+ };
590
+ const client = createOpencodeClient({ baseUrl: server.url });
591
+ const session = await client.session.create({
592
+ query: { directory: projectDir },
593
+ body: { title: 'subrouter tool affinity' },
594
+ });
595
+ const anthropicBefore = anthropicMock.requests.length;
596
+ await client.session.prompt({
597
+ path: { id: session.data.id },
598
+ query: { directory: projectDir },
599
+ body: {
600
+ model: { providerID: 'subrouter', modelID: 'default' },
601
+ parts: [{ type: 'text', text: 'read the file' }],
602
+ },
603
+ });
604
+ expect(anthropicMock.requests).toHaveLength(anthropicBefore);
605
+ expect(fallbackBodies
606
+ .slice(0, 2)
607
+ .map((body) => body.includes('You are powered by the model named grok-4.6'))).toEqual([true, true]);
608
+ await cooldownCleared;
609
+ await clearCooldowns();
610
+ await client.session.prompt({
611
+ path: { id: session.data.id },
612
+ query: { directory: projectDir },
613
+ body: {
614
+ model: { providerID: 'subrouter', modelID: 'default' },
615
+ parts: [{ type: 'text', text: 'say done' }],
616
+ },
617
+ });
618
+ expect(anthropicMock.requests).toHaveLength(anthropicBefore + 1);
619
+ expect(fallbackCalls).toBeGreaterThanOrEqual(3);
620
+ }, 120_000);
621
+ test('openai live model advertises apply_patch and not edit or write', async () => {
622
+ const client = createOpencodeClient({ baseUrl: server.url });
623
+ const session = await client.session.create({
624
+ query: { directory: projectDir },
625
+ body: { title: 'subrouter apply_patch' },
626
+ });
627
+ expect(session.data).toBeTruthy();
628
+ const result = await client.session.prompt({
629
+ path: { id: session.data.id },
630
+ query: { directory: projectDir },
631
+ body: {
632
+ model: { providerID: 'subrouter', modelID: 'openai-only' },
633
+ parts: [{ type: 'text', text: 'say hi' }],
634
+ },
635
+ });
636
+ const texts = (result.data?.parts ?? [])
637
+ .filter((part) => part.type === 'text')
638
+ .map((part) => part.text)
639
+ .join('\n');
640
+ expect(texts).toContain('hello from openai');
641
+ expect(openaiMock.requests.length).toBeGreaterThan(0);
642
+ const raw = openaiMock.requests.at(-1).body;
643
+ const body = JSON.parse(raw);
644
+ const names = (body.tools ?? []).map((tool) => tool.name ?? tool.function?.name);
645
+ expect(names).toContain('apply_patch');
646
+ expect(names).not.toContain('edit');
647
+ expect(names).not.toContain('write');
648
+ expect(raw).toContain('apply_patch');
649
+ expect(raw.includes('"name":"edit"') || raw.includes('"name": "edit"')).toBe(false);
650
+ }, 120_000);
312
651
  });