@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.
@@ -1,26 +1,282 @@
1
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
1
+ import { createServer } from 'node:http';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
2
3
  import { tmpdir } from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { afterEach, beforeEach, expect, test } from 'vitest';
5
- import { subrouterPlugin } from "./index.js";
6
+ import { addAccount, adapters, loadAccounts, PROVIDER_DISPLAY_NAME, PROVIDER_IDS, savePreset, setSubrouterLog, } from '@subrouter/cli';
7
+ import { subrouterAuthPlugin, subrouterPlugin } from "./index.js";
8
+ import { revealRoutedModel, rewritePoweredByModelLine } from "./provider.js";
6
9
  let home;
10
+ const openServers = [];
11
+ const pluginInput = {};
7
12
  beforeEach(async () => {
8
13
  home = await mkdtemp(path.join(tmpdir(), 'subrouter-plugin-'));
9
14
  process.env.SUBROUTER_HOME = home;
15
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev();
10
16
  });
11
17
  afterEach(async () => {
18
+ setSubrouterLog(undefined);
12
19
  delete process.env.SUBROUTER_HOME;
20
+ delete process.env.SUBROUTER_OPENAI_ISSUER_URL;
21
+ delete process.env.SUBROUTER_MODELS_DEV_URL;
22
+ for (const server of openServers.splice(0)) {
23
+ await new Promise((resolve) => {
24
+ server.close(() => {
25
+ resolve();
26
+ });
27
+ });
28
+ }
13
29
  await rm(home, { recursive: true, force: true });
14
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
+ }
58
+ /** Minimal stand-in for the OpenAI Codex device endpoints. */
59
+ async function startFakeOpenAIIssuer() {
60
+ const server = createServer((req, res) => {
61
+ const url = new URL(req.url || '', 'http://localhost');
62
+ const send = (body) => {
63
+ res.writeHead(200, { 'Content-Type': 'application/json' });
64
+ res.end(JSON.stringify(body));
65
+ };
66
+ if (url.pathname === '/api/accounts/deviceauth/usercode') {
67
+ return send({ device_auth_id: 'dev-1', user_code: 'ABCD-9876', interval: '0' });
68
+ }
69
+ if (url.pathname === '/api/accounts/deviceauth/token') {
70
+ return send({ authorization_code: 'auth-code', code_verifier: 'verifier' });
71
+ }
72
+ if (url.pathname === '/oauth/token') {
73
+ const claims = Buffer.from(JSON.stringify({ email: 'pool@example.com', chatgpt_account_id: 'acct-9' })).toString('base64url');
74
+ return send({
75
+ access_token: 'access-9',
76
+ refresh_token: 'refresh-9',
77
+ expires_in: 3600,
78
+ id_token: `header.${claims}.signature`,
79
+ });
80
+ }
81
+ res.writeHead(404).end('{}');
82
+ });
83
+ await new Promise((resolve) => {
84
+ server.listen(0, '127.0.0.1', resolve);
85
+ });
86
+ const address = server.address();
87
+ if (typeof address === 'string' || !address)
88
+ throw new Error('failed to bind fake issuer');
89
+ openServers.push(server);
90
+ return `http://127.0.0.1:${address.port}`;
91
+ }
92
+ function authMethod() {
93
+ return subrouterAuthPlugin(pluginInput).then((hooks) => {
94
+ const method = hooks.auth?.methods[0];
95
+ if (!method || method.type !== 'oauth')
96
+ throw new Error('expected an oauth method');
97
+ return { provider: hooks.auth.provider, method };
98
+ });
99
+ }
15
100
  test('config hook registers the subrouter provider with preset models', async () => {
16
- await writeFile(path.join(home, 'presets.json'), JSON.stringify({ version: 1, presets: { work: ['anthropic/claude-opus-4-6'] } }));
17
- const hooks = await subrouterPlugin({});
101
+ await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] });
102
+ const hooks = await subrouterPlugin(pluginInput);
18
103
  const config = {};
19
104
  await hooks.config?.(config);
20
105
  const provider = config.provider?.subrouter;
21
106
  expect(provider).toBeTruthy();
107
+ expect(provider.name).toBe(PROVIDER_DISPLAY_NAME);
22
108
  expect(provider.npm.startsWith('file://')).toBe(true);
23
109
  expect(provider.npm.endsWith('provider.ts') || provider.npm.endsWith('provider.js')).toBe(true);
24
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');
25
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);
228
+ });
229
+ test('auth hook asks which subscription to add before authorizing', async () => {
230
+ const { provider, method } = await authMethod();
231
+ expect(provider).toBe('subrouter');
232
+ const prompt = method.prompts?.[0];
233
+ expect(prompt?.type).toBe('select');
234
+ expect(prompt?.key).toBe('provider');
235
+ expect(prompt?.type === 'select' && prompt.options.map((o) => o.value)).toEqual([...PROVIDER_IDS]);
236
+ const methodPrompt = method.prompts?.[1];
237
+ expect(methodPrompt?.type).toBe('select');
238
+ expect(methodPrompt?.key).toBe('method');
239
+ expect(methodPrompt?.type === 'select' && methodPrompt.options.map((o) => o.value)).toEqual([
240
+ 'browser',
241
+ 'device',
242
+ ]);
243
+ });
244
+ test('authorize rejects a missing or unknown subscription', async () => {
245
+ const { method } = await authMethod();
246
+ await expect(method.authorize({})).rejects.toThrow(/Pick a subscription/);
247
+ await expect(method.authorize({ provider: 'nope' })).rejects.toThrow(/Pick a subscription/);
248
+ });
249
+ test('authorize dispatches to the chosen adapter and the callback pools the account', async () => {
250
+ process.env.SUBROUTER_OPENAI_ISSUER_URL = await startFakeOpenAIIssuer();
251
+ const { method } = await authMethod();
252
+ const result = await method.authorize({ provider: 'openai', method: 'device' });
253
+ expect(result.method).toBe('auto');
254
+ expect(result.instructions).toMatch(/code:\s*ABCD-9876/);
255
+ if (result.method !== 'auto')
256
+ throw new Error('expected the device flow');
257
+ const credentials = await result.callback();
258
+ expect(credentials).toMatchObject({ type: 'success', access: 'access-9', refresh: 'refresh-9' });
259
+ const accounts = await loadAccounts();
260
+ expect(accounts.providers.openai?.accounts).toMatchObject([
261
+ { type: 'oauth', email: 'pool@example.com', accountId: 'acct-9' },
262
+ ]);
263
+ });
264
+ test('opencode go asks for a pasted key and stores it as an api account', async () => {
265
+ const { method } = await authMethod();
266
+ const result = await method.authorize({ provider: 'opencode-go' });
267
+ expect(result.method).toBe('code');
268
+ if (result.method !== 'code')
269
+ throw new Error('expected a pasted-key flow');
270
+ expect(await result.callback('go-key-1')).toMatchObject({ type: 'success', key: 'go-key-1' });
271
+ const accounts = await loadAccounts();
272
+ expect(accounts.providers['opencode-go']?.accounts).toMatchObject([{ type: 'api', key: 'go-key-1' }]);
273
+ });
274
+ test('a failed login reports failure instead of pooling a broken account', async () => {
275
+ const { method } = await authMethod();
276
+ const result = await method.authorize({ provider: 'opencode-go' });
277
+ if (result.method !== 'code')
278
+ throw new Error('expected a pasted-key flow');
279
+ expect(await result.callback(' ')).toEqual({ type: 'failed' });
280
+ const accounts = await loadAccounts();
281
+ expect(accounts.providers['opencode-go']).toBeUndefined();
26
282
  });
@@ -2,6 +2,28 @@
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>;
20
+ export declare function addSubrouterHeaders(input: {
21
+ sessionID: string;
22
+ agent: string;
23
+ model: {
24
+ providerID: string;
25
+ };
26
+ }, output: {
27
+ headers: Record<string, string>;
28
+ }): void;
7
29
  //# sourceMappingURL=provider.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA"}
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,5 +2,29 @@
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
  */
7
+ import { OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, PROVIDER_ID, resolveActiveCandidate, } from '@subrouter/cli';
6
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
+ }
24
+ export function addSubrouterHeaders(input, output) {
25
+ if (input.model.providerID !== PROVIDER_ID)
26
+ return;
27
+ output.headers[OPENAI_WEBSOCKET_SESSION_HEADER] = input.sessionID;
28
+ if (input.agent === 'title')
29
+ output.headers[OPENAI_WEBSOCKET_TITLE_HEADER] = 'true';
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@subrouter/opencode",
3
- "version": "0.1.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.1.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
@@ -1,52 +1,214 @@
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
 
13
21
  import type { Plugin } from '@opencode-ai/plugin'
14
- import { DEFAULT_PRESET_NAME, loadPresets } from '@subrouter/cli'
22
+ import {
23
+ adapters,
24
+ addAccount,
25
+ DEFAULT_PRESET_NAME,
26
+ isProviderId,
27
+ loadModelsDevCatalog,
28
+ loadPresets,
29
+ modelsDevLimit,
30
+ PROVIDER_DISPLAY_NAME,
31
+ PROVIDER_ID,
32
+ PROVIDER_IDS,
33
+ resolveActiveCandidate,
34
+ setSubrouterLog,
35
+ type StoredAccount,
36
+ } from '@subrouter/cli'
37
+ import { addSubrouterHeaders, revealRoutedModel } from './provider.ts'
15
38
 
16
39
  function providerEntryUrl() {
17
40
  const isDev = import.meta.url.endsWith('.ts')
18
41
  return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href
19
42
  }
20
43
 
21
- 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
+ }
22
62
  return {
23
63
  config: async (config) => {
24
64
  const presets = await loadPresets().catch(() => {
25
65
  return { version: 1 as const, presets: {} }
26
66
  })
27
67
  const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)])
68
+ const catalog = await loadModelsDevCatalog()
28
69
  const models = Object.fromEntries(
29
- [...names].map((name) => [
30
- name,
31
- {
32
- name: `subrouter ${name}`,
33
- tool_call: true,
34
- attachment: false,
35
- reasoning: false,
36
- cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
37
- limit: { context: 200_000, output: 64_000 },
38
- },
39
- ]),
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
+ ),
40
99
  )
41
100
  config.provider = {
42
101
  ...config.provider,
43
- subrouter: {
44
- name: 'Subrouter',
102
+ [PROVIDER_ID]: {
103
+ name: PROVIDER_DISPLAY_NAME,
45
104
  npm: providerEntryUrl(),
46
105
  models,
47
106
  options: {},
48
107
  },
49
108
  }
50
109
  },
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
+ },
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Map a subrouter account onto the credential shape opencode stores. The
124
+ * stored copy is redundant (RouterModel only ever reads ~/.subrouter), but
125
+ * opencode needs a success payload to close the login flow.
126
+ */
127
+ function toOpencodeCredentials(account: StoredAccount) {
128
+ if (account.type === 'api') {
129
+ return { type: 'success' as const, key: account.key ?? '' }
130
+ }
131
+ return {
132
+ type: 'success' as const,
133
+ refresh: account.refresh ?? '',
134
+ access: account.access ?? '',
135
+ expires: account.expires ?? 0,
136
+ accountId: account.accountId,
137
+ }
138
+ }
139
+
140
+ export const subrouterAuthPlugin: Plugin = async () => {
141
+ return {
142
+ auth: {
143
+ provider: PROVIDER_ID,
144
+ methods: [
145
+ {
146
+ type: 'oauth',
147
+ label: 'Add a subscription',
148
+ prompts: [
149
+ {
150
+ type: 'select',
151
+ key: 'provider',
152
+ message: 'Which subscription do you want to add?',
153
+ options: PROVIDER_IDS.map((id) => {
154
+ return { label: adapters[id].name, value: id }
155
+ }),
156
+ },
157
+ {
158
+ type: 'select',
159
+ key: 'method',
160
+ message: 'How do you want to log in to ChatGPT?',
161
+ options: [
162
+ { label: 'Browser (recommended)', value: 'browser' },
163
+ { label: 'Device code', value: 'device', hint: 'May be disabled for your account' },
164
+ ],
165
+ when: { key: 'provider', op: 'eq', value: 'openai' },
166
+ },
167
+ ],
168
+ authorize: async (inputs) => {
169
+ const providerId = inputs?.provider
170
+ if (!providerId || !isProviderId(providerId)) {
171
+ // No failure variant exists in AuthOAuthResult, so surface this
172
+ // as a request error instead. Harnesses render the message.
173
+ throw new Error(
174
+ `Pick a subscription to add. Expected one of: ${PROVIDER_IDS.join(', ')}`,
175
+ )
176
+ }
177
+
178
+ const adapter = adapters[providerId]
179
+ // Remote harnesses (a chat bot, a web UI) authorize in a browser
180
+ // that is not on this machine, so a localhost callback never fires
181
+ // and the user has to paste the redirect URL back instead.
182
+ const session = await adapter.beginLogin({
183
+ manualInput: Boolean(process.env.SUBROUTER_MANUAL_OAUTH),
184
+ method: inputs?.method,
185
+ })
186
+ if (session instanceof Error) throw session
187
+
188
+ const finish = async (input?: string) => {
189
+ const account = await session.complete(input)
190
+ if (account instanceof Error) return { type: 'failed' as const }
191
+ await addAccount({ provider: providerId, account })
192
+ return toOpencodeCredentials(account)
193
+ }
194
+
195
+ if (session.method === 'code') {
196
+ return {
197
+ url: session.url,
198
+ instructions: session.instructions,
199
+ method: 'code' as const,
200
+ callback: (code) => finish(code),
201
+ }
202
+ }
203
+ return {
204
+ url: session.url,
205
+ instructions: session.instructions,
206
+ method: 'auto' as const,
207
+ callback: () => finish(),
208
+ }
209
+ },
210
+ },
211
+ ],
212
+ },
51
213
  }
52
214
  }