@subrouter/opencode 0.2.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
@@ -5,7 +5,9 @@
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`. Model names, context limits, and
10
+ * `experimental.chat.system.transform` follow the first live routed candidate.
9
11
  *
10
12
  * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
11
13
  * (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;;;;;;;;;;;;;;;;;;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
@@ -5,7 +5,9 @@
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`. Model names, context limits, and
10
+ * `experimental.chat.system.transform` follow the first live routed candidate.
9
11
  *
10
12
  * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
11
13
  * (and any harness driving opencode's auth hook, like kimaki's Discord
@@ -15,34 +17,66 @@
15
17
  * NOTE: only plugin initializer functions may be exported from this module.
16
18
  * OpenCode calls every export as a plugin.
17
19
  */
18
- import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadPresets, PROVIDER_IDS, } from '@subrouter/cli';
19
- import { addSubrouterHeaders } from "./provider.js";
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";
20
22
  function providerEntryUrl() {
21
23
  const isDev = import.meta.url.endsWith('.ts');
22
24
  return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href;
23
25
  }
24
- 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
+ }
25
44
  return {
26
45
  config: async (config) => {
27
46
  const presets = await loadPresets().catch(() => {
28
47
  return { version: 1, presets: {} };
29
48
  });
30
49
  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
- ]));
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
+ })));
42
76
  config.provider = {
43
77
  ...config.provider,
44
- subrouter: {
45
- name: 'Subrouter',
78
+ [PROVIDER_ID]: {
79
+ name: PROVIDER_DISPLAY_NAME,
46
80
  npm: providerEntryUrl(),
47
81
  models,
48
82
  options: {},
@@ -50,6 +84,14 @@ export const subrouterPlugin = async () => {
50
84
  };
51
85
  },
52
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
+ },
53
95
  };
54
96
  };
55
97
  /**
@@ -72,7 +114,7 @@ function toOpencodeCredentials(account) {
72
114
  export const subrouterAuthPlugin = async () => {
73
115
  return {
74
116
  auth: {
75
- provider: 'subrouter',
117
+ provider: PROVIDER_ID,
76
118
  methods: [
77
119
  {
78
120
  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 { 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) => {
@@ -69,7 +100,7 @@ beforeAll(async () => {
69
100
  res.writeHead(429, { 'content-type': 'application/json' });
70
101
  res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }));
71
102
  });
72
- // Fake opencode zen: streams a canned completion
103
+ // Fake opencode-go: streams a canned completion
73
104
  zenMock = await startMockServer(({ body }, res) => {
74
105
  const streaming = body.includes('"stream":true');
75
106
  if (!streaming) {
@@ -110,33 +141,10 @@ beforeAll(async () => {
110
141
  // Subrouter state: one rate-limited anthropic account + one zen key
111
142
  const subrouterHome = path.join(home, 'subrouter');
112
143
  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
144
  for (const [key, value] of Object.entries({
137
145
  SUBROUTER_HOME: subrouterHome,
138
146
  SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
139
- SUBROUTER_OPENCODE_BASE_URL: `${zenMock.url}/v1`,
147
+ SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
140
148
  // Isolate opencode from the user's real global config and auth
141
149
  XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
142
150
  XDG_DATA_HOME: path.join(home, 'xdg-data'),
@@ -146,6 +154,22 @@ beforeAll(async () => {
146
154
  savedEnv[key] = process.env[key];
147
155
  process.env[key] = value;
148
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
+ });
149
173
  const providerEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'provider.js')).href;
150
174
  server = await createOpencodeServer({
151
175
  port: 0,
@@ -224,4 +248,65 @@ describe('opencode + subrouter provider', () => {
224
248
  expect(anthropicMock.requests.length).toBeGreaterThan(0);
225
249
  expect(zenMock.requests.length).toBeGreaterThan(0);
226
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);
227
312
  });
@@ -1,19 +1,24 @@
1
1
  import { createServer } from 'node:http';
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { afterEach, beforeEach, expect, test } from 'vitest';
6
- import { loadAccounts, PROVIDER_IDS } from '@subrouter/cli';
6
+ import { addAccount, adapters, loadAccounts, PROVIDER_DISPLAY_NAME, PROVIDER_IDS, savePreset, setSubrouterLog, } from '@subrouter/cli';
7
7
  import { subrouterAuthPlugin, subrouterPlugin } from "./index.js";
8
+ import { revealRoutedModel, rewritePoweredByModelLine } from "./provider.js";
8
9
  let home;
9
10
  const openServers = [];
11
+ const pluginInput = {};
10
12
  beforeEach(async () => {
11
13
  home = await mkdtemp(path.join(tmpdir(), 'subrouter-plugin-'));
12
14
  process.env.SUBROUTER_HOME = home;
15
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev();
13
16
  });
14
17
  afterEach(async () => {
18
+ setSubrouterLog(undefined);
15
19
  delete process.env.SUBROUTER_HOME;
16
20
  delete process.env.SUBROUTER_OPENAI_ISSUER_URL;
21
+ delete process.env.SUBROUTER_MODELS_DEV_URL;
17
22
  for (const server of openServers.splice(0)) {
18
23
  await new Promise((resolve) => {
19
24
  server.close(() => {
@@ -23,6 +28,33 @@ afterEach(async () => {
23
28
  }
24
29
  await rm(home, { recursive: true, force: true });
25
30
  });
31
+ async function startFakeModelsDev(providers = {}) {
32
+ const payload = {
33
+ anthropic: { models: {} },
34
+ openai: { models: {} },
35
+ xai: { models: {} },
36
+ 'opencode-go': { models: {} },
37
+ 'github-copilot': { models: {} },
38
+ poe: { models: {} },
39
+ 'minimax-coding-plan': { models: {} },
40
+ 'kimi-for-coding': { models: {} },
41
+ 'zai-coding-plan': { models: {} },
42
+ 'alibaba-coding-plan': { models: {} },
43
+ ...providers,
44
+ };
45
+ const server = createServer((_req, res) => {
46
+ res.writeHead(200, { 'Content-Type': 'application/json' });
47
+ res.end(JSON.stringify(payload));
48
+ });
49
+ await new Promise((resolve) => {
50
+ server.listen(0, '127.0.0.1', resolve);
51
+ });
52
+ const address = server.address();
53
+ if (typeof address === 'string' || !address)
54
+ throw new Error('failed to bind fake models.dev');
55
+ openServers.push(server);
56
+ return `http://127.0.0.1:${address.port}`;
57
+ }
26
58
  /** Minimal stand-in for the OpenAI Codex device endpoints. */
27
59
  async function startFakeOpenAIIssuer() {
28
60
  const server = createServer((req, res) => {
@@ -58,7 +90,7 @@ async function startFakeOpenAIIssuer() {
58
90
  return `http://127.0.0.1:${address.port}`;
59
91
  }
60
92
  function authMethod() {
61
- return subrouterAuthPlugin({}).then((hooks) => {
93
+ return subrouterAuthPlugin(pluginInput).then((hooks) => {
62
94
  const method = hooks.auth?.methods[0];
63
95
  if (!method || method.type !== 'oauth')
64
96
  throw new Error('expected an oauth method');
@@ -66,16 +98,133 @@ function authMethod() {
66
98
  });
67
99
  }
68
100
  test('config hook registers the subrouter provider with preset models', async () => {
69
- await writeFile(path.join(home, 'presets.json'), JSON.stringify({ version: 1, presets: { work: ['anthropic/claude-opus-4-6'] } }));
70
- const hooks = await subrouterPlugin({});
101
+ await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] });
102
+ const hooks = await subrouterPlugin(pluginInput);
71
103
  const config = {};
72
104
  await hooks.config?.(config);
73
105
  const provider = config.provider?.subrouter;
74
106
  expect(provider).toBeTruthy();
107
+ expect(provider.name).toBe(PROVIDER_DISPLAY_NAME);
75
108
  expect(provider.npm.startsWith('file://')).toBe(true);
76
109
  expect(provider.npm.endsWith('provider.ts') || provider.npm.endsWith('provider.js')).toBe(true);
77
110
  expect(Object.keys(provider.models).sort()).toEqual(['default', 'work']);
111
+ expect(provider.models.default.name).toBe('default');
112
+ expect(provider.models.work.name).toBe('work');
78
113
  expect(provider.models.default.cost).toEqual({ input: 0, output: 0, cache_read: 0, cache_write: 0 });
114
+ expect(provider.models.default.limit).toEqual({ context: 200_000, output: 64_000 });
115
+ expect(provider.models.work.limit).toEqual({ context: 200_000, output: 64_000 });
116
+ });
117
+ test('preset model names show the first live candidate', async () => {
118
+ await addAccount({
119
+ provider: 'anthropic',
120
+ account: {
121
+ type: 'oauth',
122
+ refresh: 'refresh-1',
123
+ access: 'access-1',
124
+ expires: Date.now() + 60_000,
125
+ email: 'a@x.com',
126
+ addedAt: 1,
127
+ lastUsed: 1,
128
+ },
129
+ });
130
+ await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] });
131
+ const hooks = await subrouterPlugin(pluginInput);
132
+ const config = {};
133
+ await hooks.config?.(config);
134
+ expect(config.provider?.subrouter?.name).toBe(PROVIDER_DISPLAY_NAME);
135
+ expect(config.provider?.subrouter?.models?.work?.name).toBe('work (claude-opus-4-6)');
136
+ });
137
+ test('preset model limits follow the first live candidate', async () => {
138
+ const modelId = adapters.anthropic.defaultModels[0];
139
+ if (!modelId)
140
+ throw new Error('anthropic adapter has no default model');
141
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
142
+ anthropic: {
143
+ models: {
144
+ [modelId]: {
145
+ id: modelId,
146
+ modalities: { output: ['text'] },
147
+ limit: { context: 1_000_000, output: 128_000 },
148
+ },
149
+ },
150
+ },
151
+ });
152
+ await addAccount({
153
+ provider: 'anthropic',
154
+ account: {
155
+ type: 'oauth',
156
+ refresh: 'refresh-1',
157
+ access: 'access-1',
158
+ expires: Date.now() + 60_000,
159
+ email: 'a@x.com',
160
+ addedAt: 1,
161
+ lastUsed: 1,
162
+ },
163
+ });
164
+ await savePreset({ name: 'work', models: [`anthropic/${modelId}`] });
165
+ const hooks = await subrouterPlugin(pluginInput);
166
+ const config = {};
167
+ await hooks.config?.(config);
168
+ expect(config.provider?.subrouter?.models?.work?.limit).toEqual({
169
+ context: 1_000_000,
170
+ output: 128_000,
171
+ });
172
+ expect(config.provider?.subrouter?.models?.default?.limit).toEqual({
173
+ context: 1_000_000,
174
+ output: 128_000,
175
+ });
176
+ });
177
+ test('preset models permit image and PDF attachments', async () => {
178
+ const hooks = await subrouterPlugin(pluginInput);
179
+ const config = {};
180
+ await hooks.config?.(config);
181
+ expect(config.provider?.subrouter?.models?.default).toMatchObject({
182
+ attachment: true,
183
+ modalities: {
184
+ input: ['text', 'image', 'pdf'],
185
+ output: ['text'],
186
+ },
187
+ });
188
+ });
189
+ test('rewrites the OpenCode powered-by line to the routed candidate', () => {
190
+ const system = [
191
+ 'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
192
+ ];
193
+ rewritePoweredByModelLine({
194
+ system,
195
+ candidate: { provider: 'anthropic', modelId: 'claude-opus-4-6' },
196
+ });
197
+ expect(system[0]).toContain('You are powered by the model named claude-opus-4-6.');
198
+ expect(system[0]).toContain('The exact model ID is anthropic/claude-opus-4-6');
199
+ expect(system[0]).not.toContain('subrouter/build');
200
+ });
201
+ test('system transform rewrites the powered-by line to the live candidate', async () => {
202
+ await addAccount({
203
+ provider: 'anthropic',
204
+ account: {
205
+ type: 'oauth',
206
+ refresh: 'refresh-1',
207
+ access: 'access-1',
208
+ expires: Date.now() + 60_000,
209
+ email: 'a@x.com',
210
+ addedAt: 1,
211
+ lastUsed: 1,
212
+ },
213
+ });
214
+ const system = [
215
+ 'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
216
+ ];
217
+ await revealRoutedModel({ providerID: 'subrouter', preset: 'default', system });
218
+ const modelId = adapters.anthropic.defaultModels[0];
219
+ expect(system[0]).toContain(`You are powered by the model named ${modelId}.`);
220
+ expect(system[0]).toContain(`The exact model ID is anthropic/${modelId}`);
221
+ expect(system[0]).not.toContain('subrouter/build');
222
+ });
223
+ test('system transform leaves other providers unchanged', async () => {
224
+ const original = 'You are powered by the model named claude-opus-4-6. The exact model ID is anthropic/claude-opus-4-6';
225
+ const system = [original];
226
+ await revealRoutedModel({ providerID: 'anthropic', preset: 'claude-opus-4-6', system });
227
+ expect(system[0]).toBe(original);
79
228
  });
80
229
  test('auth hook asks which subscription to add before authorizing', async () => {
81
230
  const { provider, method } = await authMethod();
@@ -112,22 +261,22 @@ test('authorize dispatches to the chosen adapter and the callback pools the acco
112
261
  { type: 'oauth', email: 'pool@example.com', accountId: 'acct-9' },
113
262
  ]);
114
263
  });
115
- test('opencode zen asks for a pasted key and stores it as an api account', async () => {
264
+ test('opencode go asks for a pasted key and stores it as an api account', async () => {
116
265
  const { method } = await authMethod();
117
- const result = await method.authorize({ provider: 'opencode' });
266
+ const result = await method.authorize({ provider: 'opencode-go' });
118
267
  expect(result.method).toBe('code');
119
268
  if (result.method !== 'code')
120
269
  throw new Error('expected a pasted-key flow');
121
- expect(await result.callback('zen-key-1')).toMatchObject({ type: 'success', key: 'zen-key-1' });
270
+ expect(await result.callback('go-key-1')).toMatchObject({ type: 'success', key: 'go-key-1' });
122
271
  const accounts = await loadAccounts();
123
- expect(accounts.providers.opencode?.accounts).toMatchObject([{ type: 'api', key: 'zen-key-1' }]);
272
+ expect(accounts.providers['opencode-go']?.accounts).toMatchObject([{ type: 'api', key: 'go-key-1' }]);
124
273
  });
125
274
  test('a failed login reports failure instead of pooling a broken account', async () => {
126
275
  const { method } = await authMethod();
127
- const result = await method.authorize({ provider: 'opencode' });
276
+ const result = await method.authorize({ provider: 'opencode-go' });
128
277
  if (result.method !== 'code')
129
278
  throw new Error('expected a pasted-key flow');
130
279
  expect(await result.callback(' ')).toEqual({ type: 'failed' });
131
280
  const accounts = await loadAccounts();
132
- expect(accounts.providers.opencode).toBeUndefined();
281
+ expect(accounts.providers['opencode-go']).toBeUndefined();
133
282
  });
@@ -2,8 +2,21 @@
2
2
  * Provider entry loaded by opencode via `provider.subrouter.npm` (file:// URL).
3
3
  * OpenCode imports this module, calls the first export starting with `create`,
4
4
  * then `sdk.languageModel(modelId)` where modelId is a subrouter preset name.
5
+ * Also rewrites OpenCode's powered-by identity to the live routed model.
5
6
  */
6
7
  export { createSubrouter } from '@subrouter/cli';
8
+ export declare function rewritePoweredByModelLine({ system, candidate, }: {
9
+ system: string[];
10
+ candidate: {
11
+ provider: string;
12
+ modelId: string;
13
+ };
14
+ }): void;
15
+ export declare function revealRoutedModel({ providerID, preset, system, }: {
16
+ providerID: string;
17
+ preset: string;
18
+ system: string[];
19
+ }): Promise<void>;
7
20
  export declare function addSubrouterHeaders(input: {
8
21
  sessionID: string;
9
22
  agent: string;
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAOH,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAEhD,wBAAgB,mBAAmB,CACjC,KAAK,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,EAC1E,MAAM,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,QAK5C"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAIhD,wBAAgB,yBAAyB,CAAC,EACxC,MAAM,EACN,SAAS,GACV,EAAE;IACD,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,SAAS,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CACjD,QAKA;AAED,wBAAsB,iBAAiB,CAAC,EACtC,UAAU,EACV,MAAM,EACN,MAAM,GACP,EAAE;IACD,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB,iBAKA;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,EAC1E,MAAM,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,QAK5C"}
package/dist/provider.js CHANGED
@@ -2,11 +2,27 @@
2
2
  * Provider entry loaded by opencode via `provider.subrouter.npm` (file:// URL).
3
3
  * OpenCode imports this module, calls the first export starting with `create`,
4
4
  * then `sdk.languageModel(modelId)` where modelId is a subrouter preset name.
5
+ * Also rewrites OpenCode's powered-by identity to the live routed model.
5
6
  */
6
- import { OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, } from '@subrouter/cli';
7
+ import { OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, PROVIDER_ID, resolveActiveCandidate, } from '@subrouter/cli';
7
8
  export { createSubrouter } from '@subrouter/cli';
9
+ const POWERED_BY_MODEL = /You are powered by the model named [^\n]+/;
10
+ export function rewritePoweredByModelLine({ system, candidate, }) {
11
+ const line = `You are powered by the model named ${candidate.modelId}. The exact model ID is ${candidate.provider}/${candidate.modelId}`;
12
+ for (let i = 0; i < system.length; i++) {
13
+ system[i] = system[i].replace(POWERED_BY_MODEL, line);
14
+ }
15
+ }
16
+ export async function revealRoutedModel({ providerID, preset, system, }) {
17
+ if (providerID !== PROVIDER_ID)
18
+ return;
19
+ const candidate = await resolveActiveCandidate(preset);
20
+ if (!candidate)
21
+ return;
22
+ rewritePoweredByModelLine({ system, candidate });
23
+ }
8
24
  export function addSubrouterHeaders(input, output) {
9
- if (input.model.providerID !== 'subrouter')
25
+ if (input.model.providerID !== PROVIDER_ID)
10
26
  return;
11
27
  output.headers[OPENAI_WEBSOCKET_SESSION_HEADER] = input.sessionID;
12
28
  if (input.agent === 'title')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@subrouter/opencode",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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.2.0"
47
+ "@subrouter/cli": "^0.4.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@opencode-ai/plugin": "^1.18.23",
package/src/index.ts CHANGED
@@ -5,7 +5,9 @@
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`. Model names, context limits, and
10
+ * `experimental.chat.system.transform` follow the first live routed candidate.
9
11
  *
10
12
  * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
11
13
  * (and any harness driving opencode's auth hook, like kimaki's Discord
@@ -22,41 +24,83 @@ import {
22
24
  addAccount,
23
25
  DEFAULT_PRESET_NAME,
24
26
  isProviderId,
27
+ loadModelsDevCatalog,
25
28
  loadPresets,
29
+ modelsDevLimit,
30
+ PROVIDER_DISPLAY_NAME,
31
+ PROVIDER_ID,
26
32
  PROVIDER_IDS,
33
+ resolveActiveCandidate,
34
+ setSubrouterLog,
27
35
  type StoredAccount,
28
36
  } from '@subrouter/cli'
29
- import { addSubrouterHeaders } from './provider.ts'
37
+ import { addSubrouterHeaders, revealRoutedModel } from './provider.ts'
30
38
 
31
39
  function providerEntryUrl() {
32
40
  const isDev = import.meta.url.endsWith('.ts')
33
41
  return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href
34
42
  }
35
43
 
36
- export const subrouterPlugin: Plugin = async () => {
44
+ export const subrouterPlugin: Plugin = async ({ client }) => {
45
+ // OpenCode loads this plugin and the provider module separately. Both import
46
+ // @subrouter/cli; this callback is the only log sink the router may use.
47
+ // Never console.log here. OpenCode prints plugin logs via client.app.log.
48
+ if (client?.app?.log) {
49
+ setSubrouterLog((entry) => {
50
+ void client.app
51
+ .log({
52
+ body: {
53
+ service: 'subrouter',
54
+ level: entry.level,
55
+ message: entry.message,
56
+ extra: entry.extra,
57
+ },
58
+ })
59
+ .catch(() => {})
60
+ })
61
+ }
37
62
  return {
38
63
  config: async (config) => {
39
64
  const presets = await loadPresets().catch(() => {
40
65
  return { version: 1 as const, presets: {} }
41
66
  })
42
67
  const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)])
68
+ const catalog = await loadModelsDevCatalog()
43
69
  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
- ]),
70
+ await Promise.all(
71
+ [...names].map(async (name) => {
72
+ const candidate = await resolveActiveCandidate(name)
73
+ const limit = candidate
74
+ ? modelsDevLimit({
75
+ provider: candidate.provider,
76
+ modelId: candidate.modelId,
77
+ catalog,
78
+ })
79
+ : null
80
+ return [
81
+ name,
82
+ {
83
+ name: candidate ? `${name} (${candidate.modelId})` : name,
84
+ tool_call: true,
85
+ attachment: true,
86
+ reasoning: false,
87
+ modalities: {
88
+ input: ['text', 'image', 'pdf'] satisfies Array<
89
+ 'text' | 'image' | 'pdf'
90
+ >,
91
+ output: ['text'] satisfies Array<'text'>,
92
+ },
93
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
94
+ limit: limit ?? { context: 200_000, output: 64_000 },
95
+ },
96
+ ]
97
+ }),
98
+ ),
55
99
  )
56
100
  config.provider = {
57
101
  ...config.provider,
58
- subrouter: {
59
- name: 'Subrouter',
102
+ [PROVIDER_ID]: {
103
+ name: PROVIDER_DISPLAY_NAME,
60
104
  npm: providerEntryUrl(),
61
105
  models,
62
106
  options: {},
@@ -64,6 +108,14 @@ export const subrouterPlugin: Plugin = async () => {
64
108
  }
65
109
  },
66
110
  'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
111
+ // OpenCode identity uses the preset id; rewrite it to the live routed model.
112
+ 'experimental.chat.system.transform': async (input, output) => {
113
+ await revealRoutedModel({
114
+ providerID: input.model.providerID,
115
+ preset: input.model.id,
116
+ system: output.system,
117
+ })
118
+ },
67
119
  }
68
120
  }
69
121
 
@@ -88,7 +140,7 @@ function toOpencodeCredentials(account: StoredAccount) {
88
140
  export const subrouterAuthPlugin: Plugin = async () => {
89
141
  return {
90
142
  auth: {
91
- provider: 'subrouter',
143
+ provider: PROVIDER_ID,
92
144
  methods: [
93
145
  {
94
146
  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,10 +10,10 @@
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'
@@ -21,9 +21,48 @@ import { afterAll, beforeAll, describe, expect, test } from 'vitest'
21
21
  import {
22
22
  OPENAI_WEBSOCKET_SESSION_HEADER,
23
23
  OPENAI_WEBSOCKET_TITLE_HEADER,
24
+ addAccount,
25
+ markCooldown,
24
26
  } from '@subrouter/cli'
25
27
  import { addSubrouterHeaders } from './provider.ts'
26
28
 
29
+ function summarizeSessionEvents(events: Event[]) {
30
+ const summary: Array<{
31
+ type: string
32
+ status?: string
33
+ message?: string
34
+ name?: string
35
+ statusCode?: number
36
+ isRetryable?: boolean
37
+ }> = []
38
+ for (const event of events) {
39
+ if (event.type === 'session.status') {
40
+ const status = event.properties.status
41
+ summary.push({
42
+ type: event.type,
43
+ status: status.type,
44
+ message: status.type === 'retry' ? status.message : undefined,
45
+ })
46
+ continue
47
+ }
48
+ if (event.type === 'session.error') {
49
+ const error = event.properties.error
50
+ if (!error) continue
51
+ const message = typeof error.data.message === 'string' ? error.data.message : undefined
52
+ summary.push({
53
+ type: event.type,
54
+ name: error.name,
55
+ message,
56
+ statusCode: error.name === 'APIError' ? error.data.statusCode : undefined,
57
+ isRetryable: error.name === 'APIError' ? error.data.isRetryable : undefined,
58
+ })
59
+ continue
60
+ }
61
+ if (event.type === 'session.idle') summary.push({ type: event.type })
62
+ }
63
+ return summary
64
+ }
65
+
27
66
  type MockServer = {
28
67
  url: string
29
68
  requests: string[]
@@ -86,7 +125,7 @@ beforeAll(async () => {
86
125
  res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }))
87
126
  })
88
127
 
89
- // Fake opencode zen: streams a canned completion
128
+ // Fake opencode-go: streams a canned completion
90
129
  zenMock = await startMockServer(({ body }, res) => {
91
130
  const streaming = body.includes('"stream":true')
92
131
  if (!streaming) {
@@ -134,37 +173,11 @@ beforeAll(async () => {
134
173
  // Subrouter state: one rate-limited anthropic account + one zen key
135
174
  const subrouterHome = path.join(home, 'subrouter')
136
175
  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
176
 
164
177
  for (const [key, value] of Object.entries({
165
178
  SUBROUTER_HOME: subrouterHome,
166
179
  SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
167
- SUBROUTER_OPENCODE_BASE_URL: `${zenMock.url}/v1`,
180
+ SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
168
181
  // Isolate opencode from the user's real global config and auth
169
182
  XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
170
183
  XDG_DATA_HOME: path.join(home, 'xdg-data'),
@@ -175,6 +188,23 @@ beforeAll(async () => {
175
188
  process.env[key] = value
176
189
  }
177
190
 
191
+ await addAccount({
192
+ provider: 'anthropic',
193
+ account: {
194
+ type: 'oauth',
195
+ refresh: 'fake-refresh',
196
+ access: 'fake-access',
197
+ expires: Date.now() + 1_000_000_000,
198
+ email: 'a@x.com',
199
+ addedAt: 1,
200
+ lastUsed: 1,
201
+ },
202
+ })
203
+ await addAccount({
204
+ provider: 'opencode-go',
205
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
206
+ })
207
+
178
208
  const providerEntry = pathToFileURL(
179
209
  path.join(import.meta.dirname, '..', 'dist', 'provider.js'),
180
210
  ).href
@@ -268,4 +298,73 @@ describe('opencode + subrouter provider', () => {
268
298
  expect(anthropicMock.requests.length).toBeGreaterThan(0)
269
299
  expect(zenMock.requests.length).toBeGreaterThan(0)
270
300
  }, 120_000)
301
+
302
+ test('all cooling-down accounts retry through opencode instead of dying', async () => {
303
+ const untilMs = Date.now() + 2_000
304
+ await markCooldown({
305
+ provider: 'anthropic',
306
+ account: { type: 'oauth', refresh: 'fake-refresh', access: 'fake-access', email: 'a@x.com', addedAt: 1, lastUsed: 1 },
307
+ untilMs,
308
+ })
309
+ await markCooldown({
310
+ provider: 'opencode-go',
311
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
312
+ untilMs,
313
+ })
314
+
315
+ const client = createOpencodeClient({ baseUrl: server.url })
316
+ const events: Event[] = []
317
+ const subscription = await client.event.subscribe({
318
+ query: { directory: projectDir },
319
+ })
320
+ void (async () => {
321
+ for await (const event of subscription.stream) {
322
+ events.push(event)
323
+ }
324
+ })()
325
+
326
+ const session = await client.session.create({
327
+ query: { directory: projectDir },
328
+ body: { title: 'subrouter cooldown retry' },
329
+ })
330
+ expect(session.data).toBeTruthy()
331
+
332
+ const result = await client.session.prompt({
333
+ path: { id: session.data!.id },
334
+ query: { directory: projectDir },
335
+ body: {
336
+ model: { providerID: 'subrouter', modelID: 'default' },
337
+ parts: [{ type: 'text', text: 'say hi' }],
338
+ },
339
+ })
340
+
341
+ const parts = result.data?.parts ?? []
342
+ const texts = parts
343
+ .filter((part) => part.type === 'text')
344
+ .map((part) => part.text)
345
+ .join('\n')
346
+ expect(texts).toContain('hello from fallback')
347
+
348
+ const summary = summarizeSessionEvents(events)
349
+ expect(summary.some((event) => event.status === 'retry')).toBe(true)
350
+ expect(summary.some((event) => event.name === 'UnknownError')).toBe(false)
351
+ expect(
352
+ summary.map((event) => {
353
+ if (event.status === 'retry') {
354
+ return { status: 'retry', coolingDown: event.message?.includes('cooling down') }
355
+ }
356
+ return event.status ?? event.type
357
+ }),
358
+ ).toMatchInlineSnapshot(`
359
+ [
360
+ "busy",
361
+ "busy",
362
+ {
363
+ "coolingDown": true,
364
+ "status": "retry",
365
+ },
366
+ "busy",
367
+ ]
368
+ `)
369
+ }, 120_000)
271
370
  })
@@ -1,23 +1,36 @@
1
1
  import { createServer, type Server } from 'node:http'
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { afterEach, beforeEach, expect, test } from 'vitest'
6
- import type { PluginInput } from '@opencode-ai/plugin'
7
- import { loadAccounts, PROVIDER_IDS } from '@subrouter/cli'
6
+ import type { Config, PluginInput } from '@opencode-ai/plugin'
7
+ import {
8
+ addAccount,
9
+ adapters,
10
+ loadAccounts,
11
+ PROVIDER_DISPLAY_NAME,
12
+ PROVIDER_IDS,
13
+ savePreset,
14
+ setSubrouterLog,
15
+ } from '@subrouter/cli'
8
16
  import { subrouterAuthPlugin, subrouterPlugin } from './index.ts'
17
+ import { revealRoutedModel, rewritePoweredByModelLine } from './provider.ts'
9
18
 
10
19
  let home: string
11
20
  const openServers: Server[] = []
21
+ const pluginInput = {} as PluginInput
12
22
 
13
23
  beforeEach(async () => {
14
24
  home = await mkdtemp(path.join(tmpdir(), 'subrouter-plugin-'))
15
25
  process.env.SUBROUTER_HOME = home
26
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev()
16
27
  })
17
28
 
18
29
  afterEach(async () => {
30
+ setSubrouterLog(undefined)
19
31
  delete process.env.SUBROUTER_HOME
20
32
  delete process.env.SUBROUTER_OPENAI_ISSUER_URL
33
+ delete process.env.SUBROUTER_MODELS_DEV_URL
21
34
  for (const server of openServers.splice(0)) {
22
35
  await new Promise<void>((resolve) => {
23
36
  server.close(() => {
@@ -28,6 +41,47 @@ afterEach(async () => {
28
41
  await rm(home, { recursive: true, force: true })
29
42
  })
30
43
 
44
+ async function startFakeModelsDev(
45
+ providers: Record<
46
+ string,
47
+ {
48
+ models: Record<
49
+ string,
50
+ {
51
+ id: string
52
+ modalities?: { output?: string[] }
53
+ limit?: { context?: number; output?: number; input?: number }
54
+ }
55
+ >
56
+ }
57
+ > = {},
58
+ ) {
59
+ const payload = {
60
+ anthropic: { models: {} },
61
+ openai: { models: {} },
62
+ xai: { models: {} },
63
+ 'opencode-go': { models: {} },
64
+ 'github-copilot': { models: {} },
65
+ poe: { models: {} },
66
+ 'minimax-coding-plan': { models: {} },
67
+ 'kimi-for-coding': { models: {} },
68
+ 'zai-coding-plan': { models: {} },
69
+ 'alibaba-coding-plan': { models: {} },
70
+ ...providers,
71
+ }
72
+ const server = createServer((_req, res) => {
73
+ res.writeHead(200, { 'Content-Type': 'application/json' })
74
+ res.end(JSON.stringify(payload))
75
+ })
76
+ await new Promise<void>((resolve) => {
77
+ server.listen(0, '127.0.0.1', resolve)
78
+ })
79
+ const address = server.address()
80
+ if (typeof address === 'string' || !address) throw new Error('failed to bind fake models.dev')
81
+ openServers.push(server)
82
+ return `http://127.0.0.1:${address.port}`
83
+ }
84
+
31
85
  /** Minimal stand-in for the OpenAI Codex device endpoints. */
32
86
  async function startFakeOpenAIIssuer() {
33
87
  const server = createServer((req, res) => {
@@ -65,7 +119,7 @@ async function startFakeOpenAIIssuer() {
65
119
  }
66
120
 
67
121
  function authMethod() {
68
- return subrouterAuthPlugin({} as PluginInput).then((hooks) => {
122
+ return subrouterAuthPlugin(pluginInput).then((hooks) => {
69
123
  const method = hooks.auth?.methods[0]
70
124
  if (!method || method.type !== 'oauth') throw new Error('expected an oauth method')
71
125
  return { provider: hooks.auth!.provider, method }
@@ -73,21 +127,148 @@ function authMethod() {
73
127
  }
74
128
 
75
129
  test('config hook registers the subrouter provider with preset models', async () => {
76
- await writeFile(
77
- path.join(home, 'presets.json'),
78
- JSON.stringify({ version: 1, presets: { work: ['anthropic/claude-opus-4-6'] } }),
79
- )
130
+ await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] })
80
131
 
81
- const hooks = await subrouterPlugin({} as PluginInput)
132
+ const hooks = await subrouterPlugin(pluginInput)
82
133
  const config: Record<string, any> = {}
83
134
  await hooks.config?.(config as any)
84
135
 
85
136
  const provider = config.provider?.subrouter
86
137
  expect(provider).toBeTruthy()
138
+ expect(provider.name).toBe(PROVIDER_DISPLAY_NAME)
87
139
  expect(provider.npm.startsWith('file://')).toBe(true)
88
140
  expect(provider.npm.endsWith('provider.ts') || provider.npm.endsWith('provider.js')).toBe(true)
89
141
  expect(Object.keys(provider.models).sort()).toEqual(['default', 'work'])
142
+ expect(provider.models.default.name).toBe('default')
143
+ expect(provider.models.work.name).toBe('work')
90
144
  expect(provider.models.default.cost).toEqual({ input: 0, output: 0, cache_read: 0, cache_write: 0 })
145
+ expect(provider.models.default.limit).toEqual({ context: 200_000, output: 64_000 })
146
+ expect(provider.models.work.limit).toEqual({ context: 200_000, output: 64_000 })
147
+ })
148
+
149
+ test('preset model names show the first live candidate', async () => {
150
+ await addAccount({
151
+ provider: 'anthropic',
152
+ account: {
153
+ type: 'oauth',
154
+ refresh: 'refresh-1',
155
+ access: 'access-1',
156
+ expires: Date.now() + 60_000,
157
+ email: 'a@x.com',
158
+ addedAt: 1,
159
+ lastUsed: 1,
160
+ },
161
+ })
162
+ await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] })
163
+
164
+ const hooks = await subrouterPlugin(pluginInput)
165
+ const config: Config = {}
166
+ await hooks.config?.(config)
167
+
168
+ expect(config.provider?.subrouter?.name).toBe(PROVIDER_DISPLAY_NAME)
169
+ expect(config.provider?.subrouter?.models?.work?.name).toBe('work (claude-opus-4-6)')
170
+ })
171
+
172
+ test('preset model limits follow the first live candidate', async () => {
173
+ const modelId = adapters.anthropic.defaultModels[0]
174
+ if (!modelId) throw new Error('anthropic adapter has no default model')
175
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
176
+ anthropic: {
177
+ models: {
178
+ [modelId]: {
179
+ id: modelId,
180
+ modalities: { output: ['text'] },
181
+ limit: { context: 1_000_000, output: 128_000 },
182
+ },
183
+ },
184
+ },
185
+ })
186
+ await addAccount({
187
+ provider: 'anthropic',
188
+ account: {
189
+ type: 'oauth',
190
+ refresh: 'refresh-1',
191
+ access: 'access-1',
192
+ expires: Date.now() + 60_000,
193
+ email: 'a@x.com',
194
+ addedAt: 1,
195
+ lastUsed: 1,
196
+ },
197
+ })
198
+ await savePreset({ name: 'work', models: [`anthropic/${modelId}`] })
199
+
200
+ const hooks = await subrouterPlugin(pluginInput)
201
+ const config: Config = {}
202
+ await hooks.config?.(config)
203
+
204
+ expect(config.provider?.subrouter?.models?.work?.limit).toEqual({
205
+ context: 1_000_000,
206
+ output: 128_000,
207
+ })
208
+ expect(config.provider?.subrouter?.models?.default?.limit).toEqual({
209
+ context: 1_000_000,
210
+ output: 128_000,
211
+ })
212
+ })
213
+
214
+ test('preset models permit image and PDF attachments', async () => {
215
+ const hooks = await subrouterPlugin(pluginInput)
216
+ const config: Config = {}
217
+ await hooks.config?.(config)
218
+
219
+ expect(config.provider?.subrouter?.models?.default).toMatchObject({
220
+ attachment: true,
221
+ modalities: {
222
+ input: ['text', 'image', 'pdf'],
223
+ output: ['text'],
224
+ },
225
+ })
226
+ })
227
+
228
+ test('rewrites the OpenCode powered-by line to the routed candidate', () => {
229
+ const system = [
230
+ 'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
231
+ ]
232
+ rewritePoweredByModelLine({
233
+ system,
234
+ candidate: { provider: 'anthropic', modelId: 'claude-opus-4-6' },
235
+ })
236
+ expect(system[0]).toContain('You are powered by the model named claude-opus-4-6.')
237
+ expect(system[0]).toContain('The exact model ID is anthropic/claude-opus-4-6')
238
+ expect(system[0]).not.toContain('subrouter/build')
239
+ })
240
+
241
+ test('system transform rewrites the powered-by line to the live candidate', async () => {
242
+ await addAccount({
243
+ provider: 'anthropic',
244
+ account: {
245
+ type: 'oauth',
246
+ refresh: 'refresh-1',
247
+ access: 'access-1',
248
+ expires: Date.now() + 60_000,
249
+ email: 'a@x.com',
250
+ addedAt: 1,
251
+ lastUsed: 1,
252
+ },
253
+ })
254
+
255
+ const system = [
256
+ 'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
257
+ ]
258
+ await revealRoutedModel({ providerID: 'subrouter', preset: 'default', system })
259
+
260
+ const modelId = adapters.anthropic.defaultModels[0]
261
+ expect(system[0]).toContain(`You are powered by the model named ${modelId}.`)
262
+ expect(system[0]).toContain(`The exact model ID is anthropic/${modelId}`)
263
+ expect(system[0]).not.toContain('subrouter/build')
264
+ })
265
+
266
+ test('system transform leaves other providers unchanged', async () => {
267
+ const original =
268
+ 'You are powered by the model named claude-opus-4-6. The exact model ID is anthropic/claude-opus-4-6'
269
+ const system = [original]
270
+ await revealRoutedModel({ providerID: 'anthropic', preset: 'claude-opus-4-6', system })
271
+ expect(system[0]).toBe(original)
91
272
  })
92
273
 
93
274
  test('auth hook asks which subscription to add before authorizing', async () => {
@@ -132,26 +313,26 @@ test('authorize dispatches to the chosen adapter and the callback pools the acco
132
313
  ])
133
314
  })
134
315
 
135
- test('opencode zen asks for a pasted key and stores it as an api account', async () => {
316
+ test('opencode go asks for a pasted key and stores it as an api account', async () => {
136
317
  const { method } = await authMethod()
137
318
 
138
- const result = await method.authorize({ provider: 'opencode' })
319
+ const result = await method.authorize({ provider: 'opencode-go' })
139
320
  expect(result.method).toBe('code')
140
321
 
141
322
  if (result.method !== 'code') throw new Error('expected a pasted-key flow')
142
- expect(await result.callback('zen-key-1')).toMatchObject({ type: 'success', key: 'zen-key-1' })
323
+ expect(await result.callback('go-key-1')).toMatchObject({ type: 'success', key: 'go-key-1' })
143
324
 
144
325
  const accounts = await loadAccounts()
145
- expect(accounts.providers.opencode?.accounts).toMatchObject([{ type: 'api', key: 'zen-key-1' }])
326
+ expect(accounts.providers['opencode-go']?.accounts).toMatchObject([{ type: 'api', key: 'go-key-1' }])
146
327
  })
147
328
 
148
329
  test('a failed login reports failure instead of pooling a broken account', async () => {
149
330
  const { method } = await authMethod()
150
331
 
151
- const result = await method.authorize({ provider: 'opencode' })
332
+ const result = await method.authorize({ provider: 'opencode-go' })
152
333
  if (result.method !== 'code') throw new Error('expected a pasted-key flow')
153
334
 
154
335
  expect(await result.callback(' ')).toEqual({ type: 'failed' })
155
336
  const accounts = await loadAccounts()
156
- expect(accounts.providers.opencode).toBeUndefined()
337
+ expect(accounts.providers['opencode-go']).toBeUndefined()
157
338
  })
package/src/provider.ts CHANGED
@@ -2,20 +2,53 @@
2
2
  * Provider entry loaded by opencode via `provider.subrouter.npm` (file:// URL).
3
3
  * OpenCode imports this module, calls the first export starting with `create`,
4
4
  * then `sdk.languageModel(modelId)` where modelId is a subrouter preset name.
5
+ * Also rewrites OpenCode's powered-by identity to the live routed model.
5
6
  */
6
7
 
7
8
  import {
8
9
  OPENAI_WEBSOCKET_SESSION_HEADER,
9
10
  OPENAI_WEBSOCKET_TITLE_HEADER,
11
+ PROVIDER_ID,
12
+ resolveActiveCandidate,
10
13
  } from '@subrouter/cli'
11
14
 
12
15
  export { createSubrouter } from '@subrouter/cli'
13
16
 
17
+ const POWERED_BY_MODEL = /You are powered by the model named [^\n]+/
18
+
19
+ export function rewritePoweredByModelLine({
20
+ system,
21
+ candidate,
22
+ }: {
23
+ system: string[]
24
+ candidate: { provider: string; modelId: string }
25
+ }) {
26
+ const line = `You are powered by the model named ${candidate.modelId}. The exact model ID is ${candidate.provider}/${candidate.modelId}`
27
+ for (let i = 0; i < system.length; i++) {
28
+ system[i] = system[i]!.replace(POWERED_BY_MODEL, line)
29
+ }
30
+ }
31
+
32
+ export async function revealRoutedModel({
33
+ providerID,
34
+ preset,
35
+ system,
36
+ }: {
37
+ providerID: string
38
+ preset: string
39
+ system: string[]
40
+ }) {
41
+ if (providerID !== PROVIDER_ID) return
42
+ const candidate = await resolveActiveCandidate(preset)
43
+ if (!candidate) return
44
+ rewritePoweredByModelLine({ system, candidate })
45
+ }
46
+
14
47
  export function addSubrouterHeaders(
15
48
  input: { sessionID: string; agent: string; model: { providerID: string } },
16
49
  output: { headers: Record<string, string> },
17
50
  ) {
18
- if (input.model.providerID !== 'subrouter') return
51
+ if (input.model.providerID !== PROVIDER_ID) return
19
52
  output.headers[OPENAI_WEBSOCKET_SESSION_HEADER] = input.sessionID
20
53
  if (input.agent === 'title') output.headers[OPENAI_WEBSOCKET_TITLE_HEADER] = 'true'
21
54
  }