@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/src/index.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
@@ -16,54 +19,163 @@
16
19
  * OpenCode calls every export as a plugin.
17
20
  */
18
21
 
19
- import type { Plugin } from '@opencode-ai/plugin'
22
+ import type { Plugin, PluginInput } from '@opencode-ai/plugin'
20
23
  import {
21
24
  adapters,
22
25
  addAccount,
23
26
  DEFAULT_PRESET_NAME,
24
27
  isProviderId,
28
+ loadModelsDevCatalog,
25
29
  loadPresets,
30
+ modelsDevInputModalities,
31
+ modelsDevLimit,
32
+ modelsDevModel,
33
+ PROVIDER_DISPLAY_NAME,
34
+ PROVIDER_ID,
26
35
  PROVIDER_IDS,
36
+ resolveCandidates,
37
+ resolvePresetModels,
38
+ type CooldownFallbackNotice,
27
39
  type StoredAccount,
40
+ type SubrouterLog,
28
41
  } from '@subrouter/cli'
29
- import { addSubrouterHeaders } from './provider.ts'
42
+ import { addSubrouterHeaders, revealRoutedModel } from './provider.ts'
30
43
 
31
44
  function providerEntryUrl() {
32
45
  const isDev = import.meta.url.endsWith('.ts')
33
46
  return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href
34
47
  }
35
48
 
36
- export const subrouterPlugin: Plugin = async () => {
49
+ function opencodeLog(client: PluginInput['client'] | undefined): SubrouterLog | undefined {
50
+ if (!client?.app?.log) return undefined
51
+ const write = client.app.log.bind(client.app)
52
+ return (entry) => {
53
+ void write({
54
+ body: {
55
+ service: 'subrouter',
56
+ level: entry.level,
57
+ message: entry.message,
58
+ extra: entry.extra,
59
+ },
60
+ }).catch(() => {})
61
+ }
62
+ }
63
+
64
+ export const subrouterPlugin: Plugin = async ({ client, directory }) => {
65
+ const log = opencodeLog(client)
66
+ const pendingNotices = new Map<
67
+ string,
68
+ { agent: string; variant?: string; preset: string; text: string }
69
+ >()
70
+ const onCooldownFallback = (notice: CooldownFallbackNotice) => {
71
+ if (!client || !notice.sessionID || !notice.agent || notice.agent === 'title') return
72
+ if (pendingNotices.has(notice.sessionID)) return
73
+ const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`
74
+ const active = `${notice.active.provider}/${notice.active.modelId}`
75
+ pendingNotices.set(notice.sessionID, {
76
+ agent: notice.agent,
77
+ variant: notice.variant,
78
+ preset: notice.preset,
79
+ text: `Subrouter: ${preferred} was rate limited. This message started with ${active}.`,
80
+ })
81
+ }
37
82
  return {
38
83
  config: async (config) => {
39
84
  const presets = await loadPresets().catch(() => {
40
85
  return { version: 1 as const, presets: {} }
41
86
  })
42
87
  const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)])
88
+ const catalog = await loadModelsDevCatalog({ log })
43
89
  const models = Object.fromEntries(
44
- [...names].map((name) => [
45
- name,
46
- {
47
- name: `subrouter ${name}`,
48
- tool_call: true,
49
- attachment: false,
50
- reasoning: false,
51
- cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
52
- limit: { context: 200_000, output: 64_000 },
53
- },
54
- ]),
90
+ await Promise.all(
91
+ [...names].map(async (name) => {
92
+ const presetModels = await resolvePresetModels(name)
93
+ const candidates =
94
+ presetModels instanceof Error
95
+ ? []
96
+ : (await resolveCandidates({ presetModels })).candidates
97
+ const candidate = candidates[0]
98
+ const limit = candidate
99
+ ? modelsDevLimit({
100
+ provider: candidate.provider,
101
+ modelId: candidate.modelId,
102
+ catalog,
103
+ })
104
+ : null
105
+ const input = new Set<'text' | 'audio' | 'image' | 'video' | 'pdf'>(['text'])
106
+ let attachment = false
107
+ for (const current of candidates) {
108
+ const model = modelsDevModel({
109
+ provider: current.provider,
110
+ modelId: current.modelId,
111
+ catalog,
112
+ })
113
+ const modalities = modelsDevInputModalities({
114
+ provider: current.provider,
115
+ modelId: current.modelId,
116
+ catalog,
117
+ })
118
+ if (!model || !modalities) continue
119
+ attachment ||= model.attachment && modalities.some((modality) => modality !== 'text')
120
+ for (const modality of modalities) input.add(modality)
121
+ }
122
+ return [
123
+ name,
124
+ {
125
+ name,
126
+ tool_call: true,
127
+ attachment,
128
+ reasoning: false,
129
+ modalities: {
130
+ input: [...input],
131
+ output: ['text'] satisfies Array<'text'>,
132
+ },
133
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
134
+ limit: limit ?? { context: 200_000, output: 64_000 },
135
+ },
136
+ ]
137
+ }),
138
+ ),
55
139
  )
56
140
  config.provider = {
57
141
  ...config.provider,
58
- subrouter: {
59
- name: 'Subrouter',
142
+ [PROVIDER_ID]: {
143
+ name: PROVIDER_DISPLAY_NAME,
60
144
  npm: providerEntryUrl(),
61
145
  models,
62
- options: {},
146
+ options: { log, onCooldownFallback },
63
147
  },
64
148
  }
65
149
  },
150
+ event: async ({ event }) => {
151
+ if (event.type !== 'session.idle') return
152
+ const pending = pendingNotices.get(event.properties.sessionID)
153
+ if (!pending) return
154
+ pendingNotices.delete(event.properties.sessionID)
155
+ const body = {
156
+ noReply: true,
157
+ agent: pending.agent,
158
+ model: { providerID: PROVIDER_ID, modelID: pending.preset },
159
+ variant: pending.variant,
160
+ parts: [{ type: 'text' as const, text: pending.text, ignored: true }],
161
+ }
162
+ await client.session
163
+ .prompt({
164
+ path: { id: event.properties.sessionID },
165
+ query: { directory },
166
+ body,
167
+ })
168
+ .catch(() => {})
169
+ },
66
170
  'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
171
+ // OpenCode identity uses the preset id; rewrite it to the live routed model.
172
+ 'experimental.chat.system.transform': async (input, output) => {
173
+ await revealRoutedModel({
174
+ providerID: input.model.providerID,
175
+ preset: input.model.id,
176
+ system: output.system,
177
+ })
178
+ },
67
179
  }
68
180
  }
69
181
 
@@ -88,7 +200,7 @@ function toOpencodeCredentials(account: StoredAccount) {
88
200
  export const subrouterAuthPlugin: Plugin = async () => {
89
201
  return {
90
202
  auth: {
91
- provider: 'subrouter',
203
+ provider: PROVIDER_ID,
92
204
  methods: [
93
205
  {
94
206
  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.
@@ -10,37 +10,78 @@
10
10
  * Requires built dist (pnpm build) because opencode loads dist/provider.js.
11
11
  */
12
12
 
13
- import { createOpencodeClient } from '@opencode-ai/sdk'
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, writeFile, mkdir } from 'node:fs/promises'
16
+ import { mkdtemp, rm, mkdir } 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
20
  import { afterAll, beforeAll, describe, expect, test } from 'vitest'
21
21
  import {
22
+ OPENCODE_AGENT_HEADER,
23
+ OPENCODE_VARIANT_HEADER,
22
24
  OPENAI_WEBSOCKET_SESSION_HEADER,
23
25
  OPENAI_WEBSOCKET_TITLE_HEADER,
26
+ addAccount,
27
+ markCooldown,
24
28
  } from '@subrouter/cli'
25
29
  import { addSubrouterHeaders } from './provider.ts'
26
30
 
31
+ function summarizeSessionEvents(events: Event[]) {
32
+ const summary: Array<{
33
+ type: string
34
+ status?: string
35
+ message?: string
36
+ name?: string
37
+ statusCode?: number
38
+ isRetryable?: boolean
39
+ }> = []
40
+ for (const event of events) {
41
+ if (event.type === 'session.status') {
42
+ const status = event.properties.status
43
+ summary.push({
44
+ type: event.type,
45
+ status: status.type,
46
+ message: status.type === 'retry' ? status.message : undefined,
47
+ })
48
+ continue
49
+ }
50
+ if (event.type === 'session.error') {
51
+ const error = event.properties.error
52
+ if (!error) continue
53
+ const message = typeof error.data.message === 'string' ? error.data.message : undefined
54
+ summary.push({
55
+ type: event.type,
56
+ name: error.name,
57
+ message,
58
+ statusCode: error.name === 'APIError' ? error.data.statusCode : undefined,
59
+ isRetryable: error.name === 'APIError' ? error.data.isRetryable : undefined,
60
+ })
61
+ continue
62
+ }
63
+ if (event.type === 'session.idle') summary.push({ type: event.type })
64
+ }
65
+ return summary
66
+ }
67
+
27
68
  type MockServer = {
28
69
  url: string
29
- requests: string[]
70
+ requests: Array<{ path: string; body: string }>
30
71
  close: () => Promise<void>
31
72
  }
32
73
 
33
74
  async function startMockServer(
34
75
  handler: (args: { path: string; body: string }, res: import('node:http').ServerResponse) => void,
35
76
  ): Promise<MockServer> {
36
- const requests: string[] = []
77
+ const requests: MockServer['requests'] = []
37
78
  const server: Server = createServer((req, res) => {
38
79
  let body = ''
39
80
  req.on('data', (chunk) => {
40
81
  body += String(chunk)
41
82
  })
42
83
  req.on('end', () => {
43
- requests.push(req.url ?? '')
84
+ requests.push({ path: req.url ?? '', body })
44
85
  handler({ path: req.url ?? '', body }, res)
45
86
  })
46
87
  })
@@ -71,6 +112,7 @@ function sseChunk(data: object) {
71
112
  let home: string
72
113
  let projectDir: string
73
114
  let anthropicMock: MockServer
115
+ let modelsDevMock: MockServer
74
116
  let zenMock: MockServer
75
117
  let server: { url: string; close: () => void }
76
118
  const savedEnv: Record<string, string | undefined> = {}
@@ -86,7 +128,7 @@ beforeAll(async () => {
86
128
  res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }))
87
129
  })
88
130
 
89
- // Fake opencode zen: streams a canned completion
131
+ // Fake opencode-go: streams a canned completion
90
132
  zenMock = await startMockServer(({ body }, res) => {
91
133
  const streaming = body.includes('"stream":true')
92
134
  if (!streaming) {
@@ -131,40 +173,40 @@ beforeAll(async () => {
131
173
  res.end()
132
174
  })
133
175
 
176
+ modelsDevMock = await startMockServer((_request, res) => {
177
+ const emptyProvider = { models: {} }
178
+ const pdfModel = (id: string) => ({
179
+ id,
180
+ attachment: true,
181
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
182
+ limit: { context: 200_000, output: 64_000 },
183
+ })
184
+ res.writeHead(200, { 'content-type': 'application/json' })
185
+ res.end(
186
+ JSON.stringify({
187
+ anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
188
+ openai: emptyProvider,
189
+ xai: emptyProvider,
190
+ 'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
191
+ 'github-copilot': emptyProvider,
192
+ poe: emptyProvider,
193
+ 'minimax-coding-plan': emptyProvider,
194
+ 'kimi-for-coding': emptyProvider,
195
+ 'zai-coding-plan': emptyProvider,
196
+ 'alibaba-coding-plan': emptyProvider,
197
+ }),
198
+ )
199
+ })
200
+
134
201
  // Subrouter state: one rate-limited anthropic account + one zen key
135
202
  const subrouterHome = path.join(home, 'subrouter')
136
203
  await mkdir(subrouterHome, { recursive: true })
137
- await writeFile(
138
- path.join(subrouterHome, 'accounts.json'),
139
- JSON.stringify({
140
- version: 1,
141
- providers: {
142
- anthropic: {
143
- activeIndex: 0,
144
- accounts: [
145
- {
146
- type: 'oauth',
147
- refresh: 'fake-refresh',
148
- access: 'fake-access',
149
- expires: Date.now() + 1_000_000_000,
150
- email: 'a@x.com',
151
- addedAt: 1,
152
- lastUsed: 1,
153
- },
154
- ],
155
- },
156
- opencode: {
157
- activeIndex: 0,
158
- accounts: [{ type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 }],
159
- },
160
- },
161
- }),
162
- )
163
204
 
164
205
  for (const [key, value] of Object.entries({
165
206
  SUBROUTER_HOME: subrouterHome,
166
207
  SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
167
- SUBROUTER_OPENCODE_BASE_URL: `${zenMock.url}/v1`,
208
+ SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
209
+ SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
168
210
  // Isolate opencode from the user's real global config and auth
169
211
  XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
170
212
  XDG_DATA_HOME: path.join(home, 'xdg-data'),
@@ -175,14 +217,35 @@ beforeAll(async () => {
175
217
  process.env[key] = value
176
218
  }
177
219
 
220
+ await addAccount({
221
+ provider: 'anthropic',
222
+ account: {
223
+ type: 'oauth',
224
+ refresh: 'fake-refresh',
225
+ access: 'fake-access',
226
+ expires: Date.now() + 1_000_000_000,
227
+ email: 'a@x.com',
228
+ addedAt: 1,
229
+ lastUsed: 1,
230
+ },
231
+ })
232
+ await addAccount({
233
+ provider: 'opencode-go',
234
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
235
+ })
236
+
178
237
  const providerEntry = pathToFileURL(
179
238
  path.join(import.meta.dirname, '..', 'dist', 'provider.js'),
180
239
  ).href
240
+ const pluginEntry = pathToFileURL(
241
+ path.join(import.meta.dirname, '..', 'dist', 'index.js'),
242
+ ).href
181
243
 
182
244
  server = await createOpencodeServer({
183
245
  port: 0,
184
246
  timeout: 60_000,
185
247
  config: {
248
+ plugin: [pluginEntry],
186
249
  provider: {
187
250
  subrouter: {
188
251
  name: 'Subrouter',
@@ -191,6 +254,8 @@ beforeAll(async () => {
191
254
  default: {
192
255
  name: 'subrouter default',
193
256
  tool_call: true,
257
+ attachment: true,
258
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
194
259
  cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
195
260
  limit: { context: 200_000, output: 64_000 },
196
261
  },
@@ -204,6 +269,7 @@ beforeAll(async () => {
204
269
  afterAll(async () => {
205
270
  server?.close()
206
271
  await anthropicMock?.close()
272
+ await modelsDevMock?.close()
207
273
  await zenMock?.close()
208
274
  for (const [key, value] of Object.entries(savedEnv)) {
209
275
  if (value === undefined) delete process.env[key]
@@ -220,20 +286,33 @@ describe('opencode + subrouter provider', () => {
220
286
  sessionID: 'session-1',
221
287
  agent: 'build',
222
288
  model: { providerID: 'subrouter' },
289
+ message: {
290
+ agent: 'build',
291
+ model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
292
+ },
223
293
  },
224
294
  output,
225
295
  )
226
- expect(output.headers).toEqual({ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1' })
296
+ expect(output.headers).toEqual({
297
+ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
298
+ [OPENCODE_AGENT_HEADER]: 'build',
299
+ [OPENCODE_VARIANT_HEADER]: 'high',
300
+ })
227
301
 
302
+ const titleOutput = { headers: {} }
228
303
  addSubrouterHeaders(
229
304
  {
230
305
  sessionID: 'session-2',
231
306
  agent: 'title',
232
307
  model: { providerID: 'subrouter' },
308
+ message: {
309
+ agent: 'build',
310
+ model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
311
+ },
233
312
  },
234
- output,
313
+ titleOutput,
235
314
  )
236
- expect(output.headers).toEqual({
315
+ expect(titleOutput.headers).toEqual({
237
316
  [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
238
317
  [OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
239
318
  })
@@ -268,4 +347,177 @@ describe('opencode + subrouter provider', () => {
268
347
  expect(anthropicMock.requests.length).toBeGreaterThan(0)
269
348
  expect(zenMock.requests.length).toBeGreaterThan(0)
270
349
  }, 120_000)
350
+
351
+ test('a pre-existing cooldown leaves a visible ignored route notice without another model turn', async () => {
352
+ const client = createOpencodeClient({ baseUrl: server.url })
353
+ const session = await client.session.create({
354
+ query: { directory: projectDir },
355
+ body: { title: 'subrouter route notice' },
356
+ })
357
+ expect(session.data).toBeTruthy()
358
+ const requestsBefore = zenMock.requests.length
359
+
360
+ const result = await client.session.prompt({
361
+ path: { id: session.data!.id },
362
+ query: { directory: projectDir },
363
+ body: {
364
+ model: { providerID: 'subrouter', modelID: 'default' },
365
+ parts: [{ type: 'text', text: 'say hi again' }],
366
+ },
367
+ })
368
+ expect(
369
+ (result.data?.parts ?? [])
370
+ .filter((part) => part.type === 'text')
371
+ .map((part) => part.text)
372
+ .join('\n'),
373
+ ).toContain('hello from fallback')
374
+
375
+ 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)
391
+
392
+ const messages = await client.session.messages({
393
+ path: { id: session.data!.id },
394
+ query: { directory: projectDir },
395
+ })
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
+ })
404
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1)
405
+ expect(zenMock.requests).toHaveLength(requestsBefore + 1)
406
+ }, 120_000)
407
+
408
+ test('PDF file parts reach a compatible fallback through opencode', async () => {
409
+ const client = createOpencodeClient({ baseUrl: server.url })
410
+ const session = await client.session.create({
411
+ query: { directory: projectDir },
412
+ body: { title: 'subrouter PDF e2e' },
413
+ })
414
+ expect(session.data).toBeTruthy()
415
+
416
+ const result = await client.session.prompt({
417
+ path: { id: session.data!.id },
418
+ query: { directory: projectDir },
419
+ body: {
420
+ model: { providerID: 'subrouter', modelID: 'default' },
421
+ parts: [
422
+ {
423
+ type: 'file',
424
+ filename: 'document.pdf',
425
+ mime: 'application/pdf',
426
+ url: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
427
+ },
428
+ { type: 'text', text: 'read the PDF' },
429
+ ],
430
+ },
431
+ })
432
+
433
+ const texts = (result.data?.parts ?? [])
434
+ .filter((part) => part.type === 'text')
435
+ .map((part) => part.text)
436
+ .join('\n')
437
+ expect(texts).toContain('hello from fallback')
438
+ const request = zenMock.requests.at(-1)
439
+ expect(request).toBeTruthy()
440
+ const body = JSON.parse(request!.body) as {
441
+ messages: Array<{ content: Array<{ type: string; file?: { filename?: string } }> }>
442
+ }
443
+ expect(body.messages.at(-1)?.content).toEqual([
444
+ {
445
+ type: 'file',
446
+ file: {
447
+ filename: 'document.pdf',
448
+ file_data: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
449
+ },
450
+ },
451
+ { type: 'text', text: 'read the PDF' },
452
+ ])
453
+ }, 120_000)
454
+
455
+ test('all cooling-down accounts retry through opencode instead of dying', async () => {
456
+ const untilMs = Date.now() + 2_000
457
+ await markCooldown({
458
+ provider: 'anthropic',
459
+ account: { type: 'oauth', refresh: 'fake-refresh', access: 'fake-access', email: 'a@x.com', addedAt: 1, lastUsed: 1 },
460
+ untilMs,
461
+ })
462
+ await markCooldown({
463
+ provider: 'opencode-go',
464
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
465
+ untilMs,
466
+ })
467
+
468
+ const client = createOpencodeClient({ baseUrl: server.url })
469
+ const events: Event[] = []
470
+ const subscription = await client.event.subscribe({
471
+ query: { directory: projectDir },
472
+ })
473
+ void (async () => {
474
+ for await (const event of subscription.stream) {
475
+ events.push(event)
476
+ }
477
+ })()
478
+
479
+ const session = await client.session.create({
480
+ query: { directory: projectDir },
481
+ body: { title: 'subrouter cooldown retry' },
482
+ })
483
+ expect(session.data).toBeTruthy()
484
+
485
+ const result = await client.session.prompt({
486
+ path: { id: session.data!.id },
487
+ query: { directory: projectDir },
488
+ body: {
489
+ model: { providerID: 'subrouter', modelID: 'default' },
490
+ parts: [{ type: 'text', text: 'say hi' }],
491
+ },
492
+ })
493
+
494
+ const parts = result.data?.parts ?? []
495
+ const texts = parts
496
+ .filter((part) => part.type === 'text')
497
+ .map((part) => part.text)
498
+ .join('\n')
499
+ expect(texts).toContain('hello from fallback')
500
+
501
+ const summary = summarizeSessionEvents(events)
502
+ expect(summary.some((event) => event.status === 'retry')).toBe(true)
503
+ expect(summary.some((event) => event.name === 'UnknownError')).toBe(false)
504
+ expect(
505
+ summary.map((event) => {
506
+ if (event.status === 'retry') {
507
+ return { status: 'retry', coolingDown: event.message?.includes('cooling down') }
508
+ }
509
+ return event.status ?? event.type
510
+ }),
511
+ ).toMatchInlineSnapshot(`
512
+ [
513
+ "busy",
514
+ "busy",
515
+ {
516
+ "coolingDown": true,
517
+ "status": "retry",
518
+ },
519
+ "busy",
520
+ ]
521
+ `)
522
+ }, 120_000)
271
523
  })