@subrouter/opencode 0.2.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.
package/dist/index.d.ts CHANGED
@@ -5,7 +5,10 @@
5
5
  * provider whose npm field points at this package's provider module (file://
6
6
  * URL, so opencode never installs anything). Each subrouter preset becomes a
7
7
  * model: pick `subrouter/default` (or any preset created with
8
- * `subrouter preset create`) in opencode.
8
+ * `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
9
+ * visible name is `subrouter.org`. Context limits follow the first live
10
+ * candidate. Input modalities cover every usable candidate so the
11
+ * router can select a compatible subscription for each prompt.
9
12
  *
10
13
  * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
11
14
  * (and any harness driving opencode's auth hook, like kimaki's Discord
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAiBjD,eAAO,MAAM,eAAe,EAAE,MAgC7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MA0EjC,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAA;AA0C9D,eAAO,MAAM,eAAe,EAAE,MAoH7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MA0EjC,CAAA"}
package/dist/index.js CHANGED
@@ -5,7 +5,10 @@
5
5
  * provider whose npm field points at this package's provider module (file://
6
6
  * URL, so opencode never installs anything). Each subrouter preset becomes a
7
7
  * model: pick `subrouter/default` (or any preset created with
8
- * `subrouter preset create`) in opencode.
8
+ * `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
9
+ * visible name is `subrouter.org`. Context limits follow the first live
10
+ * candidate. Input modalities cover every usable candidate so the
11
+ * router can select a compatible subscription for each prompt.
9
12
  *
10
13
  * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
11
14
  * (and any harness driving opencode's auth hook, like kimaki's Discord
@@ -15,41 +18,140 @@
15
18
  * NOTE: only plugin initializer functions may be exported from this module.
16
19
  * OpenCode calls every export as a plugin.
17
20
  */
18
- import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadPresets, PROVIDER_IDS, } from '@subrouter/cli';
19
- import { addSubrouterHeaders } from "./provider.js";
21
+ import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadModelsDevCatalog, loadPresets, modelsDevInputModalities, modelsDevLimit, modelsDevModel, PROVIDER_DISPLAY_NAME, PROVIDER_ID, PROVIDER_IDS, resolveCandidates, resolvePresetModels, } from '@subrouter/cli';
22
+ import { addSubrouterHeaders, revealRoutedModel } from "./provider.js";
20
23
  function providerEntryUrl() {
21
24
  const isDev = import.meta.url.endsWith('.ts');
22
25
  return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href;
23
26
  }
24
- export const subrouterPlugin = async () => {
27
+ function opencodeLog(client) {
28
+ if (!client?.app?.log)
29
+ return undefined;
30
+ const write = client.app.log.bind(client.app);
31
+ return (entry) => {
32
+ void write({
33
+ body: {
34
+ service: 'subrouter',
35
+ level: entry.level,
36
+ message: entry.message,
37
+ extra: entry.extra,
38
+ },
39
+ }).catch(() => { });
40
+ };
41
+ }
42
+ export const subrouterPlugin = async ({ client, directory }) => {
43
+ const log = opencodeLog(client);
44
+ const pendingNotices = new Map();
45
+ const onCooldownFallback = (notice) => {
46
+ if (!client || !notice.sessionID || !notice.agent || notice.agent === 'title')
47
+ return;
48
+ if (pendingNotices.has(notice.sessionID))
49
+ return;
50
+ const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`;
51
+ const active = `${notice.active.provider}/${notice.active.modelId}`;
52
+ pendingNotices.set(notice.sessionID, {
53
+ agent: notice.agent,
54
+ variant: notice.variant,
55
+ preset: notice.preset,
56
+ text: `Subrouter: ${preferred} was rate limited. This message started with ${active}.`,
57
+ });
58
+ };
25
59
  return {
26
60
  config: async (config) => {
27
61
  const presets = await loadPresets().catch(() => {
28
62
  return { version: 1, presets: {} };
29
63
  });
30
64
  const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)]);
31
- const models = Object.fromEntries([...names].map((name) => [
32
- name,
33
- {
34
- name: `subrouter ${name}`,
35
- tool_call: true,
36
- attachment: false,
37
- reasoning: false,
38
- cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
39
- limit: { context: 200_000, output: 64_000 },
40
- },
41
- ]));
65
+ const catalog = await loadModelsDevCatalog({ log });
66
+ const models = Object.fromEntries(await Promise.all([...names].map(async (name) => {
67
+ const presetModels = await resolvePresetModels(name);
68
+ const candidates = presetModels instanceof Error
69
+ ? []
70
+ : (await resolveCandidates({ presetModels })).candidates;
71
+ const candidate = candidates[0];
72
+ const limit = candidate
73
+ ? modelsDevLimit({
74
+ provider: candidate.provider,
75
+ modelId: candidate.modelId,
76
+ catalog,
77
+ })
78
+ : null;
79
+ const input = new Set(['text']);
80
+ let attachment = false;
81
+ for (const current of candidates) {
82
+ const model = modelsDevModel({
83
+ provider: current.provider,
84
+ modelId: current.modelId,
85
+ catalog,
86
+ });
87
+ const modalities = modelsDevInputModalities({
88
+ provider: current.provider,
89
+ modelId: current.modelId,
90
+ catalog,
91
+ });
92
+ if (!model || !modalities)
93
+ continue;
94
+ attachment ||= model.attachment && modalities.some((modality) => modality !== 'text');
95
+ for (const modality of modalities)
96
+ input.add(modality);
97
+ }
98
+ return [
99
+ name,
100
+ {
101
+ name,
102
+ tool_call: true,
103
+ attachment,
104
+ reasoning: false,
105
+ modalities: {
106
+ input: [...input],
107
+ output: ['text'],
108
+ },
109
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
110
+ limit: limit ?? { context: 200_000, output: 64_000 },
111
+ },
112
+ ];
113
+ })));
42
114
  config.provider = {
43
115
  ...config.provider,
44
- subrouter: {
45
- name: 'Subrouter',
116
+ [PROVIDER_ID]: {
117
+ name: PROVIDER_DISPLAY_NAME,
46
118
  npm: providerEntryUrl(),
47
119
  models,
48
- options: {},
120
+ options: { log, onCooldownFallback },
49
121
  },
50
122
  };
51
123
  },
124
+ event: async ({ event }) => {
125
+ if (event.type !== 'session.idle')
126
+ return;
127
+ const pending = pendingNotices.get(event.properties.sessionID);
128
+ if (!pending)
129
+ return;
130
+ pendingNotices.delete(event.properties.sessionID);
131
+ const body = {
132
+ noReply: true,
133
+ agent: pending.agent,
134
+ model: { providerID: PROVIDER_ID, modelID: pending.preset },
135
+ variant: pending.variant,
136
+ parts: [{ type: 'text', text: pending.text, ignored: true }],
137
+ };
138
+ await client.session
139
+ .prompt({
140
+ path: { id: event.properties.sessionID },
141
+ query: { directory },
142
+ body,
143
+ })
144
+ .catch(() => { });
145
+ },
52
146
  'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
147
+ // OpenCode identity uses the preset id; rewrite it to the live routed model.
148
+ 'experimental.chat.system.transform': async (input, output) => {
149
+ await revealRoutedModel({
150
+ providerID: input.model.providerID,
151
+ preset: input.model.id,
152
+ system: output.system,
153
+ });
154
+ },
53
155
  };
54
156
  };
55
157
  /**
@@ -72,7 +174,7 @@ function toOpencodeCredentials(account) {
72
174
  export const subrouterAuthPlugin = async () => {
73
175
  return {
74
176
  auth: {
75
- provider: 'subrouter',
177
+ provider: PROVIDER_ID,
76
178
  methods: [
77
179
  {
78
180
  type: 'oauth',
@@ -2,7 +2,7 @@
2
2
  * End-to-end test: a real opencode server drives the subrouter provider.
3
3
  *
4
4
  * No real API requests. Fake HTTP servers play the provider endpoints:
5
- * anthropic always answers 429 (rate limited), the opencode zen mock streams
5
+ * anthropic always answers 429 (rate limited), the opencode-go mock streams
6
6
  * a canned completion. The test prompts opencode with model subrouter/default
7
7
  * and asserts the reply came from the fallback provider, proving the cycling
8
8
  * works through the whole opencode -> provider -> router pipeline.
@@ -2,7 +2,7 @@
2
2
  * End-to-end test: a real opencode server drives the subrouter provider.
3
3
  *
4
4
  * No real API requests. Fake HTTP servers play the provider endpoints:
5
- * anthropic always answers 429 (rate limited), the opencode zen mock streams
5
+ * anthropic always answers 429 (rate limited), the opencode-go mock streams
6
6
  * a canned completion. The test prompts opencode with model subrouter/default
7
7
  * and asserts the reply came from the fallback provider, proving the cycling
8
8
  * works through the whole opencode -> provider -> router pipeline.
@@ -12,13 +12,44 @@
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, writeFile, mkdir } from 'node:fs/promises';
15
+ import { mkdtemp, rm, mkdir } 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
19
  import { afterAll, beforeAll, describe, expect, test } from 'vitest';
20
- import { OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, } from '@subrouter/cli';
20
+ import { OPENCODE_AGENT_HEADER, OPENCODE_VARIANT_HEADER, OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, addAccount, markCooldown, } from '@subrouter/cli';
21
21
  import { addSubrouterHeaders } from "./provider.js";
22
+ function summarizeSessionEvents(events) {
23
+ const summary = [];
24
+ for (const event of events) {
25
+ if (event.type === 'session.status') {
26
+ const status = event.properties.status;
27
+ summary.push({
28
+ type: event.type,
29
+ status: status.type,
30
+ message: status.type === 'retry' ? status.message : undefined,
31
+ });
32
+ continue;
33
+ }
34
+ if (event.type === 'session.error') {
35
+ const error = event.properties.error;
36
+ if (!error)
37
+ continue;
38
+ const message = typeof error.data.message === 'string' ? error.data.message : undefined;
39
+ summary.push({
40
+ type: event.type,
41
+ name: error.name,
42
+ message,
43
+ statusCode: error.name === 'APIError' ? error.data.statusCode : undefined,
44
+ isRetryable: error.name === 'APIError' ? error.data.isRetryable : undefined,
45
+ });
46
+ continue;
47
+ }
48
+ if (event.type === 'session.idle')
49
+ summary.push({ type: event.type });
50
+ }
51
+ return summary;
52
+ }
22
53
  async function startMockServer(handler) {
23
54
  const requests = [];
24
55
  const server = createServer((req, res) => {
@@ -27,7 +58,7 @@ async function startMockServer(handler) {
27
58
  body += String(chunk);
28
59
  });
29
60
  req.on('end', () => {
30
- requests.push(req.url ?? '');
61
+ requests.push({ path: req.url ?? '', body });
31
62
  handler({ path: req.url ?? '', body }, res);
32
63
  });
33
64
  });
@@ -57,6 +88,7 @@ function sseChunk(data) {
57
88
  let home;
58
89
  let projectDir;
59
90
  let anthropicMock;
91
+ let modelsDevMock;
60
92
  let zenMock;
61
93
  let server;
62
94
  const savedEnv = {};
@@ -69,7 +101,7 @@ beforeAll(async () => {
69
101
  res.writeHead(429, { 'content-type': 'application/json' });
70
102
  res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }));
71
103
  });
72
- // Fake opencode zen: streams a canned completion
104
+ // Fake opencode-go: streams a canned completion
73
105
  zenMock = await startMockServer(({ body }, res) => {
74
106
  const streaming = body.includes('"stream":true');
75
107
  if (!streaming) {
@@ -107,36 +139,36 @@ beforeAll(async () => {
107
139
  res.write('data: [DONE]\n\n');
108
140
  res.end();
109
141
  });
142
+ modelsDevMock = await startMockServer((_request, res) => {
143
+ const emptyProvider = { models: {} };
144
+ const pdfModel = (id) => ({
145
+ id,
146
+ attachment: true,
147
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
148
+ limit: { context: 200_000, output: 64_000 },
149
+ });
150
+ res.writeHead(200, { 'content-type': 'application/json' });
151
+ res.end(JSON.stringify({
152
+ anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
153
+ openai: emptyProvider,
154
+ xai: emptyProvider,
155
+ 'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
156
+ 'github-copilot': emptyProvider,
157
+ poe: emptyProvider,
158
+ 'minimax-coding-plan': emptyProvider,
159
+ 'kimi-for-coding': emptyProvider,
160
+ 'zai-coding-plan': emptyProvider,
161
+ 'alibaba-coding-plan': emptyProvider,
162
+ }));
163
+ });
110
164
  // Subrouter state: one rate-limited anthropic account + one zen key
111
165
  const subrouterHome = path.join(home, 'subrouter');
112
166
  await mkdir(subrouterHome, { recursive: true });
113
- await writeFile(path.join(subrouterHome, 'accounts.json'), JSON.stringify({
114
- version: 1,
115
- providers: {
116
- anthropic: {
117
- activeIndex: 0,
118
- accounts: [
119
- {
120
- type: 'oauth',
121
- refresh: 'fake-refresh',
122
- access: 'fake-access',
123
- expires: Date.now() + 1_000_000_000,
124
- email: 'a@x.com',
125
- addedAt: 1,
126
- lastUsed: 1,
127
- },
128
- ],
129
- },
130
- opencode: {
131
- activeIndex: 0,
132
- accounts: [{ type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 }],
133
- },
134
- },
135
- }));
136
167
  for (const [key, value] of Object.entries({
137
168
  SUBROUTER_HOME: subrouterHome,
138
169
  SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
139
- SUBROUTER_OPENCODE_BASE_URL: `${zenMock.url}/v1`,
170
+ SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
171
+ SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
140
172
  // Isolate opencode from the user's real global config and auth
141
173
  XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
142
174
  XDG_DATA_HOME: path.join(home, 'xdg-data'),
@@ -146,11 +178,29 @@ beforeAll(async () => {
146
178
  savedEnv[key] = process.env[key];
147
179
  process.env[key] = value;
148
180
  }
181
+ await addAccount({
182
+ provider: 'anthropic',
183
+ account: {
184
+ type: 'oauth',
185
+ refresh: 'fake-refresh',
186
+ access: 'fake-access',
187
+ expires: Date.now() + 1_000_000_000,
188
+ email: 'a@x.com',
189
+ addedAt: 1,
190
+ lastUsed: 1,
191
+ },
192
+ });
193
+ await addAccount({
194
+ provider: 'opencode-go',
195
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
196
+ });
149
197
  const providerEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'provider.js')).href;
198
+ const pluginEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'index.js')).href;
150
199
  server = await createOpencodeServer({
151
200
  port: 0,
152
201
  timeout: 60_000,
153
202
  config: {
203
+ plugin: [pluginEntry],
154
204
  provider: {
155
205
  subrouter: {
156
206
  name: 'Subrouter',
@@ -159,6 +209,8 @@ beforeAll(async () => {
159
209
  default: {
160
210
  name: 'subrouter default',
161
211
  tool_call: true,
212
+ attachment: true,
213
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
162
214
  cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
163
215
  limit: { context: 200_000, output: 64_000 },
164
216
  },
@@ -171,6 +223,7 @@ beforeAll(async () => {
171
223
  afterAll(async () => {
172
224
  server?.close();
173
225
  await anthropicMock?.close();
226
+ await modelsDevMock?.close();
174
227
  await zenMock?.close();
175
228
  for (const [key, value] of Object.entries(savedEnv)) {
176
229
  if (value === undefined)
@@ -187,14 +240,27 @@ describe('opencode + subrouter provider', () => {
187
240
  sessionID: 'session-1',
188
241
  agent: 'build',
189
242
  model: { providerID: 'subrouter' },
243
+ message: {
244
+ agent: 'build',
245
+ model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
246
+ },
190
247
  }, output);
191
- expect(output.headers).toEqual({ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1' });
248
+ expect(output.headers).toEqual({
249
+ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
250
+ [OPENCODE_AGENT_HEADER]: 'build',
251
+ [OPENCODE_VARIANT_HEADER]: 'high',
252
+ });
253
+ const titleOutput = { headers: {} };
192
254
  addSubrouterHeaders({
193
255
  sessionID: 'session-2',
194
256
  agent: 'title',
195
257
  model: { providerID: 'subrouter' },
196
- }, output);
197
- expect(output.headers).toEqual({
258
+ message: {
259
+ agent: 'build',
260
+ model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
261
+ },
262
+ }, titleOutput);
263
+ expect(titleOutput.headers).toEqual({
198
264
  [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
199
265
  [OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
200
266
  });
@@ -224,4 +290,151 @@ describe('opencode + subrouter provider', () => {
224
290
  expect(anthropicMock.requests.length).toBeGreaterThan(0);
225
291
  expect(zenMock.requests.length).toBeGreaterThan(0);
226
292
  }, 120_000);
293
+ test('a pre-existing cooldown leaves a visible ignored route notice without another model turn', async () => {
294
+ const client = createOpencodeClient({ baseUrl: server.url });
295
+ const session = await client.session.create({
296
+ query: { directory: projectDir },
297
+ body: { title: 'subrouter route notice' },
298
+ });
299
+ expect(session.data).toBeTruthy();
300
+ const requestsBefore = zenMock.requests.length;
301
+ const result = await client.session.prompt({
302
+ path: { id: session.data.id },
303
+ query: { directory: projectDir },
304
+ body: {
305
+ model: { providerID: 'subrouter', modelID: 'default' },
306
+ parts: [{ type: 'text', text: 'say hi again' }],
307
+ },
308
+ });
309
+ expect((result.data?.parts ?? [])
310
+ .filter((part) => part.type === 'text')
311
+ .map((part) => part.text)
312
+ .join('\n')).toContain('hello from fallback');
313
+ await expect
314
+ .poll(async () => {
315
+ const messages = await client.session.messages({
316
+ path: { id: session.data.id },
317
+ query: { directory: projectDir },
318
+ });
319
+ return (messages.data ?? []).some(({ parts }) => parts.some((part) => part.type === 'text' &&
320
+ part.ignored === true &&
321
+ part.text.includes('was rate limited. This message started with opencode-go/')));
322
+ })
323
+ .toBe(true);
324
+ const messages = await client.session.messages({
325
+ path: { id: session.data.id },
326
+ query: { directory: projectDir },
327
+ });
328
+ const notice = (messages.data ?? []).find(({ parts }) => parts.some((part) => part.type === 'text' && part.ignored === true));
329
+ expect(notice?.info).toMatchObject({
330
+ role: 'user',
331
+ agent: 'build',
332
+ model: { providerID: 'subrouter', modelID: 'default' },
333
+ });
334
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1);
335
+ expect(zenMock.requests).toHaveLength(requestsBefore + 1);
336
+ }, 120_000);
337
+ test('PDF file parts reach a compatible fallback through opencode', async () => {
338
+ const client = createOpencodeClient({ baseUrl: server.url });
339
+ const session = await client.session.create({
340
+ query: { directory: projectDir },
341
+ body: { title: 'subrouter PDF e2e' },
342
+ });
343
+ expect(session.data).toBeTruthy();
344
+ const result = await client.session.prompt({
345
+ path: { id: session.data.id },
346
+ query: { directory: projectDir },
347
+ body: {
348
+ model: { providerID: 'subrouter', modelID: 'default' },
349
+ parts: [
350
+ {
351
+ type: 'file',
352
+ filename: 'document.pdf',
353
+ mime: 'application/pdf',
354
+ url: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
355
+ },
356
+ { type: 'text', text: 'read the PDF' },
357
+ ],
358
+ },
359
+ });
360
+ const texts = (result.data?.parts ?? [])
361
+ .filter((part) => part.type === 'text')
362
+ .map((part) => part.text)
363
+ .join('\n');
364
+ expect(texts).toContain('hello from fallback');
365
+ const request = zenMock.requests.at(-1);
366
+ expect(request).toBeTruthy();
367
+ const body = JSON.parse(request.body);
368
+ expect(body.messages.at(-1)?.content).toEqual([
369
+ {
370
+ type: 'file',
371
+ file: {
372
+ filename: 'document.pdf',
373
+ file_data: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
374
+ },
375
+ },
376
+ { type: 'text', text: 'read the PDF' },
377
+ ]);
378
+ }, 120_000);
379
+ test('all cooling-down accounts retry through opencode instead of dying', async () => {
380
+ const untilMs = Date.now() + 2_000;
381
+ await markCooldown({
382
+ provider: 'anthropic',
383
+ account: { type: 'oauth', refresh: 'fake-refresh', access: 'fake-access', email: 'a@x.com', addedAt: 1, lastUsed: 1 },
384
+ untilMs,
385
+ });
386
+ await markCooldown({
387
+ provider: 'opencode-go',
388
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
389
+ untilMs,
390
+ });
391
+ const client = createOpencodeClient({ baseUrl: server.url });
392
+ const events = [];
393
+ const subscription = await client.event.subscribe({
394
+ query: { directory: projectDir },
395
+ });
396
+ void (async () => {
397
+ for await (const event of subscription.stream) {
398
+ events.push(event);
399
+ }
400
+ })();
401
+ const session = await client.session.create({
402
+ query: { directory: projectDir },
403
+ body: { title: 'subrouter cooldown retry' },
404
+ });
405
+ expect(session.data).toBeTruthy();
406
+ const result = await client.session.prompt({
407
+ path: { id: session.data.id },
408
+ query: { directory: projectDir },
409
+ body: {
410
+ model: { providerID: 'subrouter', modelID: 'default' },
411
+ parts: [{ type: 'text', text: 'say hi' }],
412
+ },
413
+ });
414
+ const parts = result.data?.parts ?? [];
415
+ const texts = parts
416
+ .filter((part) => part.type === 'text')
417
+ .map((part) => part.text)
418
+ .join('\n');
419
+ expect(texts).toContain('hello from fallback');
420
+ const summary = summarizeSessionEvents(events);
421
+ expect(summary.some((event) => event.status === 'retry')).toBe(true);
422
+ expect(summary.some((event) => event.name === 'UnknownError')).toBe(false);
423
+ expect(summary.map((event) => {
424
+ if (event.status === 'retry') {
425
+ return { status: 'retry', coolingDown: event.message?.includes('cooling down') };
426
+ }
427
+ return event.status ?? event.type;
428
+ })).toMatchInlineSnapshot(`
429
+ [
430
+ "busy",
431
+ "busy",
432
+ {
433
+ "coolingDown": true,
434
+ "status": "retry",
435
+ },
436
+ "busy",
437
+ ]
438
+ `);
439
+ }, 120_000);
227
440
  });