@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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -14
- package/dist/opencode-e2e.test.js +15 -9
- package/dist/plugin.test.js +5 -14
- package/package.json +2 -2
- package/src/index.ts +25 -14
- package/src/opencode-e2e.test.ts +20 -11
- package/src/plugin.test.ts +6 -14
package/dist/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
|
61
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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);
|
package/dist/plugin.test.js
CHANGED
|
@@ -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
|
|
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: '
|
|
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: '/
|
|
189
|
+
path: '/tui/show-toast?directory=%2Ftmp%2Fproject',
|
|
190
190
|
body: {
|
|
191
|
-
|
|
192
|
-
|
|
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.
|
|
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.
|
|
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
|
|
89
|
-
|
|
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
|
|
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)
|
|
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
|
}
|
package/src/opencode-e2e.test.ts
CHANGED
|
@@ -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
|
|
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(
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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(
|
|
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)
|
package/src/plugin.test.ts
CHANGED
|
@@ -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
|
|
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: '
|
|
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: '/
|
|
230
|
+
path: '/tui/show-toast?directory=%2Ftmp%2Fproject',
|
|
231
231
|
body: {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
])
|