@subrouter/opencode 0.1.0 → 0.3.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
@@ -1,14 +1,23 @@
1
1
  /**
2
- * OpenCode plugin that registers the `subrouter` provider.
2
+ * OpenCode plugins that expose subrouter inside opencode.
3
3
  *
4
- * The config hook injects a custom provider whose npm field points at this
5
- * package's provider module (file:// URL, so opencode never installs anything).
6
- * Each subrouter preset becomes a model: pick `subrouter/default` (or any
7
- * preset created with `subrouter preset create`) in opencode.
4
+ * `subrouterPlugin` registers the provider: the config hook injects a custom
5
+ * provider whose npm field points at this package's provider module (file://
6
+ * URL, so opencode never installs anything). Each subrouter preset becomes a
7
+ * model: pick `subrouter/default` (or any preset created with
8
+ * `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
9
+ * visible name is `subrouter.org`. Model names, context limits, and
10
+ * `experimental.chat.system.transform` follow the first live routed candidate.
11
+ *
12
+ * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
13
+ * (and any harness driving opencode's auth hook, like kimaki's Discord
14
+ * `/login`) can add subscriptions to the pool without leaving the harness.
15
+ * It asks which subscription to add first, then defers to that adapter.
8
16
  *
9
17
  * NOTE: only plugin initializer functions may be exported from this module.
10
18
  * OpenCode calls every export as a plugin.
11
19
  */
12
20
  import type { Plugin } from '@opencode-ai/plugin';
13
21
  export declare const subrouterPlugin: Plugin;
22
+ export declare const subrouterAuthPlugin: Plugin;
14
23
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAQjD,eAAO,MAAM,eAAe,EAAE,MA+B7B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAuBjD,eAAO,MAAM,eAAe,EAAE,MA4E7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MA0EjC,CAAA"}
package/dist/index.js CHANGED
@@ -1,46 +1,185 @@
1
1
  /**
2
- * OpenCode plugin that registers the `subrouter` provider.
2
+ * OpenCode plugins that expose subrouter inside opencode.
3
3
  *
4
- * The config hook injects a custom provider whose npm field points at this
5
- * package's provider module (file:// URL, so opencode never installs anything).
6
- * Each subrouter preset becomes a model: pick `subrouter/default` (or any
7
- * preset created with `subrouter preset create`) in opencode.
4
+ * `subrouterPlugin` registers the provider: the config hook injects a custom
5
+ * provider whose npm field points at this package's provider module (file://
6
+ * URL, so opencode never installs anything). Each subrouter preset becomes a
7
+ * model: pick `subrouter/default` (or any preset created with
8
+ * `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
9
+ * visible name is `subrouter.org`. Model names, context limits, and
10
+ * `experimental.chat.system.transform` follow the first live routed candidate.
11
+ *
12
+ * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
13
+ * (and any harness driving opencode's auth hook, like kimaki's Discord
14
+ * `/login`) can add subscriptions to the pool without leaving the harness.
15
+ * It asks which subscription to add first, then defers to that adapter.
8
16
  *
9
17
  * NOTE: only plugin initializer functions may be exported from this module.
10
18
  * OpenCode calls every export as a plugin.
11
19
  */
12
- import { DEFAULT_PRESET_NAME, loadPresets } from '@subrouter/cli';
20
+ import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadModelsDevCatalog, loadPresets, modelsDevLimit, PROVIDER_DISPLAY_NAME, PROVIDER_ID, PROVIDER_IDS, resolveActiveCandidate, setSubrouterLog, } from '@subrouter/cli';
21
+ import { addSubrouterHeaders, revealRoutedModel } from "./provider.js";
13
22
  function providerEntryUrl() {
14
23
  const isDev = import.meta.url.endsWith('.ts');
15
24
  return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href;
16
25
  }
17
- export const subrouterPlugin = async () => {
26
+ export const subrouterPlugin = async ({ client }) => {
27
+ // OpenCode loads this plugin and the provider module separately. Both import
28
+ // @subrouter/cli; this callback is the only log sink the router may use.
29
+ // Never console.log here. OpenCode prints plugin logs via client.app.log.
30
+ if (client?.app?.log) {
31
+ setSubrouterLog((entry) => {
32
+ void client.app
33
+ .log({
34
+ body: {
35
+ service: 'subrouter',
36
+ level: entry.level,
37
+ message: entry.message,
38
+ extra: entry.extra,
39
+ },
40
+ })
41
+ .catch(() => { });
42
+ });
43
+ }
18
44
  return {
19
45
  config: async (config) => {
20
46
  const presets = await loadPresets().catch(() => {
21
47
  return { version: 1, presets: {} };
22
48
  });
23
49
  const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)]);
24
- const models = Object.fromEntries([...names].map((name) => [
25
- name,
26
- {
27
- name: `subrouter ${name}`,
28
- tool_call: true,
29
- attachment: false,
30
- reasoning: false,
31
- cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
32
- limit: { context: 200_000, output: 64_000 },
33
- },
34
- ]));
50
+ const catalog = await loadModelsDevCatalog();
51
+ const models = Object.fromEntries(await Promise.all([...names].map(async (name) => {
52
+ const candidate = await resolveActiveCandidate(name);
53
+ const limit = candidate
54
+ ? modelsDevLimit({
55
+ provider: candidate.provider,
56
+ modelId: candidate.modelId,
57
+ catalog,
58
+ })
59
+ : null;
60
+ return [
61
+ name,
62
+ {
63
+ name: candidate ? `${name} (${candidate.modelId})` : name,
64
+ tool_call: true,
65
+ attachment: true,
66
+ reasoning: false,
67
+ modalities: {
68
+ input: ['text', 'image', 'pdf'],
69
+ output: ['text'],
70
+ },
71
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
72
+ limit: limit ?? { context: 200_000, output: 64_000 },
73
+ },
74
+ ];
75
+ })));
35
76
  config.provider = {
36
77
  ...config.provider,
37
- subrouter: {
38
- name: 'Subrouter',
78
+ [PROVIDER_ID]: {
79
+ name: PROVIDER_DISPLAY_NAME,
39
80
  npm: providerEntryUrl(),
40
81
  models,
41
82
  options: {},
42
83
  },
43
84
  };
44
85
  },
86
+ 'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
87
+ // OpenCode identity uses the preset id; rewrite it to the live routed model.
88
+ 'experimental.chat.system.transform': async (input, output) => {
89
+ await revealRoutedModel({
90
+ providerID: input.model.providerID,
91
+ preset: input.model.id,
92
+ system: output.system,
93
+ });
94
+ },
95
+ };
96
+ };
97
+ /**
98
+ * Map a subrouter account onto the credential shape opencode stores. The
99
+ * stored copy is redundant (RouterModel only ever reads ~/.subrouter), but
100
+ * opencode needs a success payload to close the login flow.
101
+ */
102
+ function toOpencodeCredentials(account) {
103
+ if (account.type === 'api') {
104
+ return { type: 'success', key: account.key ?? '' };
105
+ }
106
+ return {
107
+ type: 'success',
108
+ refresh: account.refresh ?? '',
109
+ access: account.access ?? '',
110
+ expires: account.expires ?? 0,
111
+ accountId: account.accountId,
112
+ };
113
+ }
114
+ export const subrouterAuthPlugin = async () => {
115
+ return {
116
+ auth: {
117
+ provider: PROVIDER_ID,
118
+ methods: [
119
+ {
120
+ type: 'oauth',
121
+ label: 'Add a subscription',
122
+ prompts: [
123
+ {
124
+ type: 'select',
125
+ key: 'provider',
126
+ message: 'Which subscription do you want to add?',
127
+ options: PROVIDER_IDS.map((id) => {
128
+ return { label: adapters[id].name, value: id };
129
+ }),
130
+ },
131
+ {
132
+ type: 'select',
133
+ key: 'method',
134
+ message: 'How do you want to log in to ChatGPT?',
135
+ options: [
136
+ { label: 'Browser (recommended)', value: 'browser' },
137
+ { label: 'Device code', value: 'device', hint: 'May be disabled for your account' },
138
+ ],
139
+ when: { key: 'provider', op: 'eq', value: 'openai' },
140
+ },
141
+ ],
142
+ authorize: async (inputs) => {
143
+ const providerId = inputs?.provider;
144
+ if (!providerId || !isProviderId(providerId)) {
145
+ // No failure variant exists in AuthOAuthResult, so surface this
146
+ // as a request error instead. Harnesses render the message.
147
+ throw new Error(`Pick a subscription to add. Expected one of: ${PROVIDER_IDS.join(', ')}`);
148
+ }
149
+ const adapter = adapters[providerId];
150
+ // Remote harnesses (a chat bot, a web UI) authorize in a browser
151
+ // that is not on this machine, so a localhost callback never fires
152
+ // and the user has to paste the redirect URL back instead.
153
+ const session = await adapter.beginLogin({
154
+ manualInput: Boolean(process.env.SUBROUTER_MANUAL_OAUTH),
155
+ method: inputs?.method,
156
+ });
157
+ if (session instanceof Error)
158
+ throw session;
159
+ const finish = async (input) => {
160
+ const account = await session.complete(input);
161
+ if (account instanceof Error)
162
+ return { type: 'failed' };
163
+ await addAccount({ provider: providerId, account });
164
+ return toOpencodeCredentials(account);
165
+ };
166
+ if (session.method === 'code') {
167
+ return {
168
+ url: session.url,
169
+ instructions: session.instructions,
170
+ method: 'code',
171
+ callback: (code) => finish(code),
172
+ };
173
+ }
174
+ return {
175
+ url: session.url,
176
+ instructions: session.instructions,
177
+ method: 'auto',
178
+ callback: () => finish(),
179
+ };
180
+ },
181
+ },
182
+ ],
183
+ },
45
184
  };
46
185
  };
@@ -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,11 +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, addAccount, markCooldown, } from '@subrouter/cli';
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
+ }
20
53
  async function startMockServer(handler) {
21
54
  const requests = [];
22
55
  const server = createServer((req, res) => {
@@ -67,7 +100,7 @@ beforeAll(async () => {
67
100
  res.writeHead(429, { 'content-type': 'application/json' });
68
101
  res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }));
69
102
  });
70
- // Fake opencode zen: streams a canned completion
103
+ // Fake opencode-go: streams a canned completion
71
104
  zenMock = await startMockServer(({ body }, res) => {
72
105
  const streaming = body.includes('"stream":true');
73
106
  if (!streaming) {
@@ -108,33 +141,10 @@ beforeAll(async () => {
108
141
  // Subrouter state: one rate-limited anthropic account + one zen key
109
142
  const subrouterHome = path.join(home, 'subrouter');
110
143
  await mkdir(subrouterHome, { recursive: true });
111
- await writeFile(path.join(subrouterHome, 'accounts.json'), JSON.stringify({
112
- version: 1,
113
- providers: {
114
- anthropic: {
115
- activeIndex: 0,
116
- accounts: [
117
- {
118
- type: 'oauth',
119
- refresh: 'fake-refresh',
120
- access: 'fake-access',
121
- expires: Date.now() + 1_000_000_000,
122
- email: 'a@x.com',
123
- addedAt: 1,
124
- lastUsed: 1,
125
- },
126
- ],
127
- },
128
- opencode: {
129
- activeIndex: 0,
130
- accounts: [{ type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 }],
131
- },
132
- },
133
- }));
134
144
  for (const [key, value] of Object.entries({
135
145
  SUBROUTER_HOME: subrouterHome,
136
146
  SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
137
- SUBROUTER_OPENCODE_BASE_URL: `${zenMock.url}/v1`,
147
+ SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
138
148
  // Isolate opencode from the user's real global config and auth
139
149
  XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
140
150
  XDG_DATA_HOME: path.join(home, 'xdg-data'),
@@ -144,6 +154,22 @@ beforeAll(async () => {
144
154
  savedEnv[key] = process.env[key];
145
155
  process.env[key] = value;
146
156
  }
157
+ await addAccount({
158
+ provider: 'anthropic',
159
+ account: {
160
+ type: 'oauth',
161
+ refresh: 'fake-refresh',
162
+ access: 'fake-access',
163
+ expires: Date.now() + 1_000_000_000,
164
+ email: 'a@x.com',
165
+ addedAt: 1,
166
+ lastUsed: 1,
167
+ },
168
+ });
169
+ await addAccount({
170
+ provider: 'opencode-go',
171
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
172
+ });
147
173
  const providerEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'provider.js')).href;
148
174
  server = await createOpencodeServer({
149
175
  port: 0,
@@ -179,6 +205,24 @@ afterAll(async () => {
179
205
  await rm(home, { recursive: true, force: true });
180
206
  });
181
207
  describe('opencode + subrouter provider', () => {
208
+ test('adds session affinity headers for subrouter models', async () => {
209
+ const output = { headers: {} };
210
+ 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);
221
+ expect(output.headers).toEqual({
222
+ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
223
+ [OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
224
+ });
225
+ });
182
226
  test('rate-limited provider is cycled to the fallback through opencode', async () => {
183
227
  const client = createOpencodeClient({ baseUrl: server.url });
184
228
  const session = await client.session.create({
@@ -204,4 +248,65 @@ describe('opencode + subrouter provider', () => {
204
248
  expect(anthropicMock.requests.length).toBeGreaterThan(0);
205
249
  expect(zenMock.requests.length).toBeGreaterThan(0);
206
250
  }, 120_000);
251
+ test('all cooling-down accounts retry through opencode instead of dying', async () => {
252
+ const untilMs = Date.now() + 2_000;
253
+ await markCooldown({
254
+ provider: 'anthropic',
255
+ account: { type: 'oauth', refresh: 'fake-refresh', access: 'fake-access', email: 'a@x.com', addedAt: 1, lastUsed: 1 },
256
+ untilMs,
257
+ });
258
+ await markCooldown({
259
+ provider: 'opencode-go',
260
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
261
+ untilMs,
262
+ });
263
+ const client = createOpencodeClient({ baseUrl: server.url });
264
+ const events = [];
265
+ const subscription = await client.event.subscribe({
266
+ query: { directory: projectDir },
267
+ });
268
+ void (async () => {
269
+ for await (const event of subscription.stream) {
270
+ events.push(event);
271
+ }
272
+ })();
273
+ const session = await client.session.create({
274
+ query: { directory: projectDir },
275
+ body: { title: 'subrouter cooldown retry' },
276
+ });
277
+ expect(session.data).toBeTruthy();
278
+ const result = await client.session.prompt({
279
+ path: { id: session.data.id },
280
+ query: { directory: projectDir },
281
+ body: {
282
+ model: { providerID: 'subrouter', modelID: 'default' },
283
+ parts: [{ type: 'text', text: 'say hi' }],
284
+ },
285
+ });
286
+ const parts = result.data?.parts ?? [];
287
+ const texts = parts
288
+ .filter((part) => part.type === 'text')
289
+ .map((part) => part.text)
290
+ .join('\n');
291
+ expect(texts).toContain('hello from fallback');
292
+ const summary = summarizeSessionEvents(events);
293
+ expect(summary.some((event) => event.status === 'retry')).toBe(true);
294
+ expect(summary.some((event) => event.name === 'UnknownError')).toBe(false);
295
+ expect(summary.map((event) => {
296
+ if (event.status === 'retry') {
297
+ return { status: 'retry', coolingDown: event.message?.includes('cooling down') };
298
+ }
299
+ return event.status ?? event.type;
300
+ })).toMatchInlineSnapshot(`
301
+ [
302
+ "busy",
303
+ "busy",
304
+ {
305
+ "coolingDown": true,
306
+ "status": "retry",
307
+ },
308
+ "busy",
309
+ ]
310
+ `);
311
+ }, 120_000);
207
312
  });