@subrouter/opencode 0.5.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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAA;AAkD9D,eAAO,MAAM,eAAe,EAAE,MA2L7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MA0EjC,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAA;AAkD9D,eAAO,MAAM,eAAe,EAAE,MA0L7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MAsFjC,CAAA"}
package/dist/index.js CHANGED
@@ -46,6 +46,13 @@ export const subrouterPlugin = async ({ client, directory }) => {
46
46
  const affinity = new RouteAffinity();
47
47
  const activeMessages = new Map();
48
48
  const deliveredNotices = new Map();
49
+ // Surface the fallback as a TUI toast, the same channel OpenCode plugins use for
50
+ // notifications (see kimaki's legacy anthropic/openai/xai auth plugins). A toast is
51
+ // not persisted into the transcript, so it never becomes a fake user/assistant
52
+ // message or enters model context. tui.toast.show is a global event with no session
53
+ // field, so append the OpenCode session id at the end: kimaki routes the toast to
54
+ // the matching Discord thread by that marker and strips it before display. Plain
55
+ // OpenCode TUI just shows the toast for the active session.
49
56
  const onCooldownFallback = async (notice) => {
50
57
  if (!notice.sessionID || !notice.agent || notice.agent === 'title')
51
58
  return;
@@ -57,21 +64,13 @@ export const subrouterPlugin = async ({ client, directory }) => {
57
64
  return;
58
65
  const current = { text, expiresAt: Date.now() + notice.preferred.retryAfterMs };
59
66
  deliveredNotices.set(notice.sessionID, current);
60
- const body = {
61
- noReply: true,
62
- agent: notice.agent,
63
- model: { providerID: PROVIDER_ID, modelID: notice.preset },
64
- variant: notice.variant,
65
- parts: [{ type: 'text', text, ignored: true }],
66
- };
67
- const result = await client.session
68
- .prompt({
69
- path: { id: notice.sessionID },
67
+ const result = await client.tui
68
+ .showToast({
70
69
  query: { directory },
71
- body,
70
+ body: { message: `${text} ${notice.sessionID}`, variant: 'info' },
72
71
  throwOnError: true,
73
72
  })
74
- .catch((cause) => new Error('failed to persist cooldown fallback notice', { cause }));
73
+ .catch((cause) => new Error('failed to show cooldown fallback toast', { cause }));
75
74
  if (!(result instanceof Error))
76
75
  return;
77
76
  if (deliveredNotices.get(notice.sessionID) === current) {
@@ -233,7 +232,8 @@ function toOpencodeCredentials(account) {
233
232
  accountId: account.accountId,
234
233
  };
235
234
  }
236
- export const subrouterAuthPlugin = async () => {
235
+ export const subrouterAuthPlugin = async ({ client }) => {
236
+ const log = opencodeLog(client);
237
237
  return {
238
238
  auth: {
239
239
  provider: PROVIDER_ID,
@@ -280,8 +280,18 @@ export const subrouterAuthPlugin = async () => {
280
280
  throw session;
281
281
  const finish = async (input) => {
282
282
  const account = await session.complete(input);
283
- if (account instanceof Error)
283
+ if (account instanceof Error) {
284
+ // opencode's AuthOAuthResult has no failure-with-message
285
+ // variant, so the real reason cannot travel back through
286
+ // oauth.callback and harnesses show a generic fallback. Log it
287
+ // here so the cause is diagnosable instead of lost.
288
+ void log?.({
289
+ level: 'error',
290
+ message: `login failed: ${account.message}`,
291
+ extra: { provider: providerId },
292
+ });
284
293
  return { type: 'failed' };
294
+ }
285
295
  await addAccount({ provider: providerId, account });
286
296
  return toOpencodeCredentials(account);
287
297
  };
@@ -371,8 +371,18 @@ describe('opencode + subrouter provider', () => {
371
371
  expect(anthropicMock.requests.length).toBeGreaterThan(0);
372
372
  expect(zenMock.requests.length).toBeGreaterThan(0);
373
373
  }, 120_000);
374
- test('a pre-existing cooldown appends one ignored notice without another model turn', async () => {
374
+ test('a pre-existing cooldown shows one session toast without another model turn', async () => {
375
375
  const client = createOpencodeClient({ baseUrl: server.url });
376
+ const toasts = [];
377
+ const subscription = await client.event.subscribe({
378
+ query: { directory: projectDir },
379
+ });
380
+ void (async () => {
381
+ for await (const event of subscription.stream) {
382
+ if (event.type === 'tui.toast.show')
383
+ toasts.push({ message: event.properties.message });
384
+ }
385
+ })();
376
386
  const session = await client.session.create({
377
387
  query: { directory: projectDir },
378
388
  body: { title: 'subrouter route notice' },
@@ -391,20 +401,16 @@ describe('opencode + subrouter provider', () => {
391
401
  .filter((part) => part.type === 'text')
392
402
  .map((part) => part.text)
393
403
  .join('\n')).toContain('hello from fallback');
404
+ // The toast is session-scoped through the trailing session-id marker and never
405
+ // enters the transcript, so no extra user/assistant message and no extra model turn.
394
406
  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
- })
407
+ .poll(() => toasts.filter(({ message }) => message.startsWith('Subrouter: Using ') && message.endsWith(session.data.id)).length)
402
408
  .toBe(1);
403
409
  const messages = await client.session.messages({
404
410
  path: { id: session.data.id },
405
411
  query: { directory: projectDir },
406
412
  });
407
- expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(2);
413
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(1);
408
414
  expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1);
409
415
  expect(zenMock.requests).toHaveLength(requestsBefore + 1);
410
416
  }, 120_000);
@@ -147,7 +147,7 @@ test('provider log callback forwards only to client.app.log', async () => {
147
147
  extra: { provider: 'openai', modelId: 'gpt-5.5' },
148
148
  });
149
149
  });
150
- test('cooldown fallback persists one ignored notice during the active run', async () => {
150
+ test('cooldown fallback shows one session-scoped toast during the active run', async () => {
151
151
  const requests = [];
152
152
  const server = createServer((req, res) => {
153
153
  const chunks = [];
@@ -175,7 +175,7 @@ test('cooldown fallback persists one ignored notice during the active run', asyn
175
175
  if (typeof onCooldownFallback !== 'function')
176
176
  throw new Error('expected cooldown callback');
177
177
  const notice = {
178
- sessionID: 'session-1',
178
+ sessionID: 'ses_session1',
179
179
  agent: 'build',
180
180
  variant: 'high',
181
181
  preset: 'work',
@@ -186,19 +186,10 @@ test('cooldown fallback persists one ignored notice during the active run', asyn
186
186
  await onCooldownFallback(notice);
187
187
  expect(requests).toEqual([
188
188
  {
189
- path: '/session/session-1/message?directory=%2Ftmp%2Fproject',
189
+ path: '/tui/show-toast?directory=%2Ftmp%2Fproject',
190
190
  body: {
191
- noReply: true,
192
- agent: 'build',
193
- model: { providerID: 'subrouter', modelID: 'work' },
194
- variant: 'high',
195
- parts: [
196
- {
197
- type: 'text',
198
- text: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.',
199
- ignored: true,
200
- },
201
- ],
191
+ message: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited. ses_session1',
192
+ variant: 'info',
202
193
  },
203
194
  },
204
195
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@subrouter/opencode",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin that registers the subrouter provider: cycle through your personal AI subscriptions when one hits rate limits.",
6
6
  "main": "./dist/index.js",
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "errore": "^0.14.1",
47
- "@subrouter/cli": "^0.6.0"
47
+ "@subrouter/cli": "^0.6.1"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@opencode-ai/plugin": "^1.18.23",
package/src/index.ts CHANGED
@@ -76,6 +76,13 @@ export const subrouterPlugin: Plugin = async ({ client, directory }) => {
76
76
  const affinity = new RouteAffinity()
77
77
  const activeMessages = new Map<string, string>()
78
78
  const deliveredNotices = new Map<string, { text: string; expiresAt: number }>()
79
+ // Surface the fallback as a TUI toast, the same channel OpenCode plugins use for
80
+ // notifications (see kimaki's legacy anthropic/openai/xai auth plugins). A toast is
81
+ // not persisted into the transcript, so it never becomes a fake user/assistant
82
+ // message or enters model context. tui.toast.show is a global event with no session
83
+ // field, so append the OpenCode session id at the end: kimaki routes the toast to
84
+ // the matching Discord thread by that marker and strips it before display. Plain
85
+ // OpenCode TUI just shows the toast for the active session.
79
86
  const onCooldownFallback = async (notice: CooldownFallbackNotice) => {
80
87
  if (!notice.sessionID || !notice.agent || notice.agent === 'title') return
81
88
  const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`
@@ -85,21 +92,13 @@ export const subrouterPlugin: Plugin = async ({ client, directory }) => {
85
92
  if (delivered?.text === text && delivered.expiresAt > Date.now()) return
86
93
  const current = { text, expiresAt: Date.now() + notice.preferred.retryAfterMs }
87
94
  deliveredNotices.set(notice.sessionID, current)
88
- const body = {
89
- noReply: true,
90
- agent: notice.agent,
91
- model: { providerID: PROVIDER_ID, modelID: notice.preset },
92
- variant: notice.variant,
93
- parts: [{ type: 'text' as const, text, ignored: true }],
94
- }
95
- const result = await client.session
96
- .prompt({
97
- path: { id: notice.sessionID },
95
+ const result = await client.tui
96
+ .showToast({
98
97
  query: { directory },
99
- body,
98
+ body: { message: `${text} ${notice.sessionID}`, variant: 'info' },
100
99
  throwOnError: true,
101
100
  })
102
- .catch((cause) => new Error('failed to persist cooldown fallback notice', { cause }))
101
+ .catch((cause) => new Error('failed to show cooldown fallback toast', { cause }))
103
102
  if (!(result instanceof Error)) return
104
103
  if (deliveredNotices.get(notice.sessionID) === current) {
105
104
  deliveredNotices.delete(notice.sessionID)
@@ -278,7 +277,8 @@ function toOpencodeCredentials(account: StoredAccount) {
278
277
  }
279
278
  }
280
279
 
281
- export const subrouterAuthPlugin: Plugin = async () => {
280
+ export const subrouterAuthPlugin: Plugin = async ({ client }) => {
281
+ const log = opencodeLog(client)
282
282
  return {
283
283
  auth: {
284
284
  provider: PROVIDER_ID,
@@ -328,7 +328,18 @@ export const subrouterAuthPlugin: Plugin = async () => {
328
328
 
329
329
  const finish = async (input?: string) => {
330
330
  const account = await session.complete(input)
331
- if (account instanceof Error) return { type: 'failed' as const }
331
+ if (account instanceof Error) {
332
+ // opencode's AuthOAuthResult has no failure-with-message
333
+ // variant, so the real reason cannot travel back through
334
+ // oauth.callback and harnesses show a generic fallback. Log it
335
+ // here so the cause is diagnosable instead of lost.
336
+ void log?.({
337
+ level: 'error',
338
+ message: `login failed: ${account.message}`,
339
+ extra: { provider: providerId },
340
+ })
341
+ return { type: 'failed' as const }
342
+ }
332
343
  await addAccount({ provider: providerId, account })
333
344
  return toOpencodeCredentials(account)
334
345
  }
@@ -433,8 +433,18 @@ describe('opencode + subrouter provider', () => {
433
433
  expect(zenMock.requests.length).toBeGreaterThan(0)
434
434
  }, 120_000)
435
435
 
436
- test('a pre-existing cooldown appends one ignored notice without another model turn', async () => {
436
+ test('a pre-existing cooldown shows one session toast without another model turn', async () => {
437
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
+
438
448
  const session = await client.session.create({
439
449
  query: { directory: projectDir },
440
450
  body: { title: 'subrouter route notice' },
@@ -457,23 +467,22 @@ describe('opencode + subrouter provider', () => {
457
467
  .join('\n'),
458
468
  ).toContain('hello from fallback')
459
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.
460
472
  await expect
461
- .poll(async () => {
462
- const messages = await client.session.messages({
463
- path: { id: session.data!.id },
464
- query: { directory: projectDir },
465
- })
466
- return (messages.data ?? []).filter(({ parts }) =>
467
- parts.some((part) => part.type === 'text' && part.ignored === true),
468
- ).length
469
- })
473
+ .poll(() =>
474
+ toasts.filter(
475
+ ({ message }) =>
476
+ message.startsWith('Subrouter: Using ') && message.endsWith(session.data!.id),
477
+ ).length,
478
+ )
470
479
  .toBe(1)
471
480
 
472
481
  const messages = await client.session.messages({
473
482
  path: { id: session.data!.id },
474
483
  query: { directory: projectDir },
475
484
  })
476
- expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(2)
485
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(1)
477
486
  expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1)
478
487
  expect(zenMock.requests).toHaveLength(requestsBefore + 1)
479
488
  }, 120_000)
@@ -188,7 +188,7 @@ test('provider log callback forwards only to client.app.log', async () => {
188
188
  })
189
189
  })
190
190
 
191
- test('cooldown fallback persists one ignored notice during the active run', async () => {
191
+ test('cooldown fallback shows one session-scoped toast during the active run', async () => {
192
192
  const requests: Array<{ path: string; body: Record<string, unknown> }> = []
193
193
  const server = createServer((req, res) => {
194
194
  const chunks: Buffer[] = []
@@ -214,7 +214,7 @@ test('cooldown fallback persists one ignored notice during the active run', asyn
214
214
  expect(onCooldownFallback).toBeTypeOf('function')
215
215
  if (typeof onCooldownFallback !== 'function') throw new Error('expected cooldown callback')
216
216
  const notice = {
217
- sessionID: 'session-1',
217
+ sessionID: 'ses_session1',
218
218
  agent: 'build',
219
219
  variant: 'high',
220
220
  preset: 'work',
@@ -227,19 +227,11 @@ test('cooldown fallback persists one ignored notice during the active run', asyn
227
227
 
228
228
  expect(requests).toEqual([
229
229
  {
230
- path: '/session/session-1/message?directory=%2Ftmp%2Fproject',
230
+ path: '/tui/show-toast?directory=%2Ftmp%2Fproject',
231
231
  body: {
232
- noReply: true,
233
- agent: 'build',
234
- model: { providerID: 'subrouter', modelID: 'work' },
235
- variant: 'high',
236
- parts: [
237
- {
238
- type: 'text',
239
- text: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.',
240
- ignored: true,
241
- },
242
- ],
232
+ message:
233
+ 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited. ses_session1',
234
+ variant: 'info',
243
235
  },
244
236
  },
245
237
  ])