@subrouter/opencode 0.1.0 → 0.2.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,21 @@
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.
9
+ *
10
+ * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
11
+ * (and any harness driving opencode's auth hook, like kimaki's Discord
12
+ * `/login`) can add subscriptions to the pool without leaving the harness.
13
+ * It asks which subscription to add first, then defers to that adapter.
8
14
  *
9
15
  * NOTE: only plugin initializer functions may be exported from this module.
10
16
  * OpenCode calls every export as a plugin.
11
17
  */
12
18
  import type { Plugin } from '@opencode-ai/plugin';
13
19
  export declare const subrouterPlugin: Plugin;
20
+ export declare const subrouterAuthPlugin: Plugin;
14
21
  //# 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;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAiBjD,eAAO,MAAM,eAAe,EAAE,MAgC7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MA0EjC,CAAA"}
package/dist/index.js CHANGED
@@ -1,15 +1,22 @@
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.
9
+ *
10
+ * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
11
+ * (and any harness driving opencode's auth hook, like kimaki's Discord
12
+ * `/login`) can add subscriptions to the pool without leaving the harness.
13
+ * It asks which subscription to add first, then defers to that adapter.
8
14
  *
9
15
  * NOTE: only plugin initializer functions may be exported from this module.
10
16
  * OpenCode calls every export as a plugin.
11
17
  */
12
- import { DEFAULT_PRESET_NAME, loadPresets } from '@subrouter/cli';
18
+ import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadPresets, PROVIDER_IDS, } from '@subrouter/cli';
19
+ import { addSubrouterHeaders } from "./provider.js";
13
20
  function providerEntryUrl() {
14
21
  const isDev = import.meta.url.endsWith('.ts');
15
22
  return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href;
@@ -42,5 +49,95 @@ export const subrouterPlugin = async () => {
42
49
  },
43
50
  };
44
51
  },
52
+ 'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
53
+ };
54
+ };
55
+ /**
56
+ * Map a subrouter account onto the credential shape opencode stores. The
57
+ * stored copy is redundant (RouterModel only ever reads ~/.subrouter), but
58
+ * opencode needs a success payload to close the login flow.
59
+ */
60
+ function toOpencodeCredentials(account) {
61
+ if (account.type === 'api') {
62
+ return { type: 'success', key: account.key ?? '' };
63
+ }
64
+ return {
65
+ type: 'success',
66
+ refresh: account.refresh ?? '',
67
+ access: account.access ?? '',
68
+ expires: account.expires ?? 0,
69
+ accountId: account.accountId,
70
+ };
71
+ }
72
+ export const subrouterAuthPlugin = async () => {
73
+ return {
74
+ auth: {
75
+ provider: 'subrouter',
76
+ methods: [
77
+ {
78
+ type: 'oauth',
79
+ label: 'Add a subscription',
80
+ prompts: [
81
+ {
82
+ type: 'select',
83
+ key: 'provider',
84
+ message: 'Which subscription do you want to add?',
85
+ options: PROVIDER_IDS.map((id) => {
86
+ return { label: adapters[id].name, value: id };
87
+ }),
88
+ },
89
+ {
90
+ type: 'select',
91
+ key: 'method',
92
+ message: 'How do you want to log in to ChatGPT?',
93
+ options: [
94
+ { label: 'Browser (recommended)', value: 'browser' },
95
+ { label: 'Device code', value: 'device', hint: 'May be disabled for your account' },
96
+ ],
97
+ when: { key: 'provider', op: 'eq', value: 'openai' },
98
+ },
99
+ ],
100
+ authorize: async (inputs) => {
101
+ const providerId = inputs?.provider;
102
+ if (!providerId || !isProviderId(providerId)) {
103
+ // No failure variant exists in AuthOAuthResult, so surface this
104
+ // as a request error instead. Harnesses render the message.
105
+ throw new Error(`Pick a subscription to add. Expected one of: ${PROVIDER_IDS.join(', ')}`);
106
+ }
107
+ const adapter = adapters[providerId];
108
+ // Remote harnesses (a chat bot, a web UI) authorize in a browser
109
+ // that is not on this machine, so a localhost callback never fires
110
+ // and the user has to paste the redirect URL back instead.
111
+ const session = await adapter.beginLogin({
112
+ manualInput: Boolean(process.env.SUBROUTER_MANUAL_OAUTH),
113
+ method: inputs?.method,
114
+ });
115
+ if (session instanceof Error)
116
+ throw session;
117
+ const finish = async (input) => {
118
+ const account = await session.complete(input);
119
+ if (account instanceof Error)
120
+ return { type: 'failed' };
121
+ await addAccount({ provider: providerId, account });
122
+ return toOpencodeCredentials(account);
123
+ };
124
+ if (session.method === 'code') {
125
+ return {
126
+ url: session.url,
127
+ instructions: session.instructions,
128
+ method: 'code',
129
+ callback: (code) => finish(code),
130
+ };
131
+ }
132
+ return {
133
+ url: session.url,
134
+ instructions: session.instructions,
135
+ method: 'auto',
136
+ callback: () => finish(),
137
+ };
138
+ },
139
+ },
140
+ ],
141
+ },
45
142
  };
46
143
  };
@@ -17,6 +17,8 @@ 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';
21
+ import { addSubrouterHeaders } from "./provider.js";
20
22
  async function startMockServer(handler) {
21
23
  const requests = [];
22
24
  const server = createServer((req, res) => {
@@ -179,6 +181,24 @@ afterAll(async () => {
179
181
  await rm(home, { recursive: true, force: true });
180
182
  });
181
183
  describe('opencode + subrouter provider', () => {
184
+ test('adds session affinity headers for subrouter models', async () => {
185
+ const output = { headers: {} };
186
+ addSubrouterHeaders({
187
+ sessionID: 'session-1',
188
+ agent: 'build',
189
+ model: { providerID: 'subrouter' },
190
+ }, output);
191
+ expect(output.headers).toEqual({ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1' });
192
+ addSubrouterHeaders({
193
+ sessionID: 'session-2',
194
+ agent: 'title',
195
+ model: { providerID: 'subrouter' },
196
+ }, output);
197
+ expect(output.headers).toEqual({
198
+ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
199
+ [OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
200
+ });
201
+ });
182
202
  test('rate-limited provider is cycled to the fallback through opencode', async () => {
183
203
  const client = createOpencodeClient({ baseUrl: server.url });
184
204
  const session = await client.session.create({
@@ -1,17 +1,70 @@
1
+ import { createServer } from 'node:http';
1
2
  import { mkdtemp, rm, writeFile } 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 { loadAccounts, PROVIDER_IDS } from '@subrouter/cli';
7
+ import { subrouterAuthPlugin, subrouterPlugin } from "./index.js";
6
8
  let home;
9
+ const openServers = [];
7
10
  beforeEach(async () => {
8
11
  home = await mkdtemp(path.join(tmpdir(), 'subrouter-plugin-'));
9
12
  process.env.SUBROUTER_HOME = home;
10
13
  });
11
14
  afterEach(async () => {
12
15
  delete process.env.SUBROUTER_HOME;
16
+ delete process.env.SUBROUTER_OPENAI_ISSUER_URL;
17
+ for (const server of openServers.splice(0)) {
18
+ await new Promise((resolve) => {
19
+ server.close(() => {
20
+ resolve();
21
+ });
22
+ });
23
+ }
13
24
  await rm(home, { recursive: true, force: true });
14
25
  });
26
+ /** Minimal stand-in for the OpenAI Codex device endpoints. */
27
+ async function startFakeOpenAIIssuer() {
28
+ const server = createServer((req, res) => {
29
+ const url = new URL(req.url || '', 'http://localhost');
30
+ const send = (body) => {
31
+ res.writeHead(200, { 'Content-Type': 'application/json' });
32
+ res.end(JSON.stringify(body));
33
+ };
34
+ if (url.pathname === '/api/accounts/deviceauth/usercode') {
35
+ return send({ device_auth_id: 'dev-1', user_code: 'ABCD-9876', interval: '0' });
36
+ }
37
+ if (url.pathname === '/api/accounts/deviceauth/token') {
38
+ return send({ authorization_code: 'auth-code', code_verifier: 'verifier' });
39
+ }
40
+ if (url.pathname === '/oauth/token') {
41
+ const claims = Buffer.from(JSON.stringify({ email: 'pool@example.com', chatgpt_account_id: 'acct-9' })).toString('base64url');
42
+ return send({
43
+ access_token: 'access-9',
44
+ refresh_token: 'refresh-9',
45
+ expires_in: 3600,
46
+ id_token: `header.${claims}.signature`,
47
+ });
48
+ }
49
+ res.writeHead(404).end('{}');
50
+ });
51
+ await new Promise((resolve) => {
52
+ server.listen(0, '127.0.0.1', resolve);
53
+ });
54
+ const address = server.address();
55
+ if (typeof address === 'string' || !address)
56
+ throw new Error('failed to bind fake issuer');
57
+ openServers.push(server);
58
+ return `http://127.0.0.1:${address.port}`;
59
+ }
60
+ function authMethod() {
61
+ return subrouterAuthPlugin({}).then((hooks) => {
62
+ const method = hooks.auth?.methods[0];
63
+ if (!method || method.type !== 'oauth')
64
+ throw new Error('expected an oauth method');
65
+ return { provider: hooks.auth.provider, method };
66
+ });
67
+ }
15
68
  test('config hook registers the subrouter provider with preset models', async () => {
16
69
  await writeFile(path.join(home, 'presets.json'), JSON.stringify({ version: 1, presets: { work: ['anthropic/claude-opus-4-6'] } }));
17
70
  const hooks = await subrouterPlugin({});
@@ -24,3 +77,57 @@ test('config hook registers the subrouter provider with preset models', async ()
24
77
  expect(Object.keys(provider.models).sort()).toEqual(['default', 'work']);
25
78
  expect(provider.models.default.cost).toEqual({ input: 0, output: 0, cache_read: 0, cache_write: 0 });
26
79
  });
80
+ test('auth hook asks which subscription to add before authorizing', async () => {
81
+ const { provider, method } = await authMethod();
82
+ expect(provider).toBe('subrouter');
83
+ const prompt = method.prompts?.[0];
84
+ expect(prompt?.type).toBe('select');
85
+ expect(prompt?.key).toBe('provider');
86
+ expect(prompt?.type === 'select' && prompt.options.map((o) => o.value)).toEqual([...PROVIDER_IDS]);
87
+ const methodPrompt = method.prompts?.[1];
88
+ expect(methodPrompt?.type).toBe('select');
89
+ expect(methodPrompt?.key).toBe('method');
90
+ expect(methodPrompt?.type === 'select' && methodPrompt.options.map((o) => o.value)).toEqual([
91
+ 'browser',
92
+ 'device',
93
+ ]);
94
+ });
95
+ test('authorize rejects a missing or unknown subscription', async () => {
96
+ const { method } = await authMethod();
97
+ await expect(method.authorize({})).rejects.toThrow(/Pick a subscription/);
98
+ await expect(method.authorize({ provider: 'nope' })).rejects.toThrow(/Pick a subscription/);
99
+ });
100
+ test('authorize dispatches to the chosen adapter and the callback pools the account', async () => {
101
+ process.env.SUBROUTER_OPENAI_ISSUER_URL = await startFakeOpenAIIssuer();
102
+ const { method } = await authMethod();
103
+ const result = await method.authorize({ provider: 'openai', method: 'device' });
104
+ expect(result.method).toBe('auto');
105
+ expect(result.instructions).toMatch(/code:\s*ABCD-9876/);
106
+ if (result.method !== 'auto')
107
+ throw new Error('expected the device flow');
108
+ const credentials = await result.callback();
109
+ expect(credentials).toMatchObject({ type: 'success', access: 'access-9', refresh: 'refresh-9' });
110
+ const accounts = await loadAccounts();
111
+ expect(accounts.providers.openai?.accounts).toMatchObject([
112
+ { type: 'oauth', email: 'pool@example.com', accountId: 'acct-9' },
113
+ ]);
114
+ });
115
+ test('opencode zen asks for a pasted key and stores it as an api account', async () => {
116
+ const { method } = await authMethod();
117
+ const result = await method.authorize({ provider: 'opencode' });
118
+ expect(result.method).toBe('code');
119
+ if (result.method !== 'code')
120
+ throw new Error('expected a pasted-key flow');
121
+ expect(await result.callback('zen-key-1')).toMatchObject({ type: 'success', key: 'zen-key-1' });
122
+ const accounts = await loadAccounts();
123
+ expect(accounts.providers.opencode?.accounts).toMatchObject([{ type: 'api', key: 'zen-key-1' }]);
124
+ });
125
+ test('a failed login reports failure instead of pooling a broken account', async () => {
126
+ const { method } = await authMethod();
127
+ const result = await method.authorize({ provider: 'opencode' });
128
+ if (result.method !== 'code')
129
+ throw new Error('expected a pasted-key flow');
130
+ expect(await result.callback(' ')).toEqual({ type: 'failed' });
131
+ const accounts = await loadAccounts();
132
+ expect(accounts.providers.opencode).toBeUndefined();
133
+ });
@@ -4,4 +4,13 @@
4
4
  * then `sdk.languageModel(modelId)` where modelId is a subrouter preset name.
5
5
  */
6
6
  export { createSubrouter } from '@subrouter/cli';
7
+ export declare function addSubrouterHeaders(input: {
8
+ sessionID: string;
9
+ agent: string;
10
+ model: {
11
+ providerID: string;
12
+ };
13
+ }, output: {
14
+ headers: Record<string, string>;
15
+ }): void;
7
16
  //# 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;;;;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"}
package/dist/provider.js CHANGED
@@ -3,4 +3,12 @@
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
5
  */
6
+ import { OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, } from '@subrouter/cli';
6
7
  export { createSubrouter } from '@subrouter/cli';
8
+ export function addSubrouterHeaders(input, output) {
9
+ if (input.model.providerID !== 'subrouter')
10
+ return;
11
+ output.headers[OPENAI_WEBSOCKET_SESSION_HEADER] = input.sessionID;
12
+ if (input.agent === 'title')
13
+ output.headers[OPENAI_WEBSOCKET_TITLE_HEADER] = 'true';
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@subrouter/opencode",
3
- "version": "0.1.0",
3
+ "version": "0.2.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.2.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@opencode-ai/plugin": "^1.18.23",
package/src/index.ts CHANGED
@@ -1,17 +1,32 @@
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.
9
+ *
10
+ * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
11
+ * (and any harness driving opencode's auth hook, like kimaki's Discord
12
+ * `/login`) can add subscriptions to the pool without leaving the harness.
13
+ * It asks which subscription to add first, then defers to that adapter.
8
14
  *
9
15
  * NOTE: only plugin initializer functions may be exported from this module.
10
16
  * OpenCode calls every export as a plugin.
11
17
  */
12
18
 
13
19
  import type { Plugin } from '@opencode-ai/plugin'
14
- import { DEFAULT_PRESET_NAME, loadPresets } from '@subrouter/cli'
20
+ import {
21
+ adapters,
22
+ addAccount,
23
+ DEFAULT_PRESET_NAME,
24
+ isProviderId,
25
+ loadPresets,
26
+ PROVIDER_IDS,
27
+ type StoredAccount,
28
+ } from '@subrouter/cli'
29
+ import { addSubrouterHeaders } from './provider.ts'
15
30
 
16
31
  function providerEntryUrl() {
17
32
  const isDev = import.meta.url.endsWith('.ts')
@@ -48,5 +63,100 @@ export const subrouterPlugin: Plugin = async () => {
48
63
  },
49
64
  }
50
65
  },
66
+ 'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Map a subrouter account onto the credential shape opencode stores. The
72
+ * stored copy is redundant (RouterModel only ever reads ~/.subrouter), but
73
+ * opencode needs a success payload to close the login flow.
74
+ */
75
+ function toOpencodeCredentials(account: StoredAccount) {
76
+ if (account.type === 'api') {
77
+ return { type: 'success' as const, key: account.key ?? '' }
78
+ }
79
+ return {
80
+ type: 'success' as const,
81
+ refresh: account.refresh ?? '',
82
+ access: account.access ?? '',
83
+ expires: account.expires ?? 0,
84
+ accountId: account.accountId,
85
+ }
86
+ }
87
+
88
+ export const subrouterAuthPlugin: Plugin = async () => {
89
+ return {
90
+ auth: {
91
+ provider: 'subrouter',
92
+ methods: [
93
+ {
94
+ type: 'oauth',
95
+ label: 'Add a subscription',
96
+ prompts: [
97
+ {
98
+ type: 'select',
99
+ key: 'provider',
100
+ message: 'Which subscription do you want to add?',
101
+ options: PROVIDER_IDS.map((id) => {
102
+ return { label: adapters[id].name, value: id }
103
+ }),
104
+ },
105
+ {
106
+ type: 'select',
107
+ key: 'method',
108
+ message: 'How do you want to log in to ChatGPT?',
109
+ options: [
110
+ { label: 'Browser (recommended)', value: 'browser' },
111
+ { label: 'Device code', value: 'device', hint: 'May be disabled for your account' },
112
+ ],
113
+ when: { key: 'provider', op: 'eq', value: 'openai' },
114
+ },
115
+ ],
116
+ authorize: async (inputs) => {
117
+ const providerId = inputs?.provider
118
+ if (!providerId || !isProviderId(providerId)) {
119
+ // No failure variant exists in AuthOAuthResult, so surface this
120
+ // as a request error instead. Harnesses render the message.
121
+ throw new Error(
122
+ `Pick a subscription to add. Expected one of: ${PROVIDER_IDS.join(', ')}`,
123
+ )
124
+ }
125
+
126
+ const adapter = adapters[providerId]
127
+ // Remote harnesses (a chat bot, a web UI) authorize in a browser
128
+ // that is not on this machine, so a localhost callback never fires
129
+ // and the user has to paste the redirect URL back instead.
130
+ const session = await adapter.beginLogin({
131
+ manualInput: Boolean(process.env.SUBROUTER_MANUAL_OAUTH),
132
+ method: inputs?.method,
133
+ })
134
+ if (session instanceof Error) throw session
135
+
136
+ const finish = async (input?: string) => {
137
+ const account = await session.complete(input)
138
+ if (account instanceof Error) return { type: 'failed' as const }
139
+ await addAccount({ provider: providerId, account })
140
+ return toOpencodeCredentials(account)
141
+ }
142
+
143
+ if (session.method === 'code') {
144
+ return {
145
+ url: session.url,
146
+ instructions: session.instructions,
147
+ method: 'code' as const,
148
+ callback: (code) => finish(code),
149
+ }
150
+ }
151
+ return {
152
+ url: session.url,
153
+ instructions: session.instructions,
154
+ method: 'auto' as const,
155
+ callback: () => finish(),
156
+ }
157
+ },
158
+ },
159
+ ],
160
+ },
51
161
  }
52
162
  }
@@ -18,6 +18,11 @@ import { tmpdir } from 'node:os'
18
18
  import path from 'node:path'
19
19
  import { pathToFileURL } from 'node:url'
20
20
  import { afterAll, beforeAll, describe, expect, test } from 'vitest'
21
+ import {
22
+ OPENAI_WEBSOCKET_SESSION_HEADER,
23
+ OPENAI_WEBSOCKET_TITLE_HEADER,
24
+ } from '@subrouter/cli'
25
+ import { addSubrouterHeaders } from './provider.ts'
21
26
 
22
27
  type MockServer = {
23
28
  url: string
@@ -59,7 +64,7 @@ async function startMockServer(
59
64
  }
60
65
  }
61
66
 
62
- function sseChunk(data: unknown) {
67
+ function sseChunk(data: object) {
63
68
  return `data: ${JSON.stringify(data)}\n\n`
64
69
  }
65
70
 
@@ -208,6 +213,32 @@ afterAll(async () => {
208
213
  })
209
214
 
210
215
  describe('opencode + subrouter provider', () => {
216
+ test('adds session affinity headers for subrouter models', async () => {
217
+ const output = { headers: {} }
218
+ addSubrouterHeaders(
219
+ {
220
+ sessionID: 'session-1',
221
+ agent: 'build',
222
+ model: { providerID: 'subrouter' },
223
+ },
224
+ output,
225
+ )
226
+ expect(output.headers).toEqual({ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1' })
227
+
228
+ addSubrouterHeaders(
229
+ {
230
+ sessionID: 'session-2',
231
+ agent: 'title',
232
+ model: { providerID: 'subrouter' },
233
+ },
234
+ output,
235
+ )
236
+ expect(output.headers).toEqual({
237
+ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
238
+ [OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
239
+ })
240
+ })
241
+
211
242
  test('rate-limited provider is cycled to the fallback through opencode', async () => {
212
243
  const client = createOpencodeClient({ baseUrl: server.url })
213
244
 
@@ -229,7 +260,7 @@ describe('opencode + subrouter provider', () => {
229
260
  const parts = result.data?.parts ?? []
230
261
  const texts = parts
231
262
  .filter((part) => part.type === 'text')
232
- .map((part) => (part as { text: string }).text)
263
+ .map((part) => part.text)
233
264
  .join('\n')
234
265
  expect(texts).toContain('hello from fallback')
235
266
 
@@ -1,11 +1,14 @@
1
+ import { createServer, type Server } from 'node:http'
1
2
  import { mkdtemp, rm, writeFile } 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
6
  import type { PluginInput } from '@opencode-ai/plugin'
6
- import { subrouterPlugin } from './index.ts'
7
+ import { loadAccounts, PROVIDER_IDS } from '@subrouter/cli'
8
+ import { subrouterAuthPlugin, subrouterPlugin } from './index.ts'
7
9
 
8
10
  let home: string
11
+ const openServers: Server[] = []
9
12
 
10
13
  beforeEach(async () => {
11
14
  home = await mkdtemp(path.join(tmpdir(), 'subrouter-plugin-'))
@@ -14,9 +17,61 @@ beforeEach(async () => {
14
17
 
15
18
  afterEach(async () => {
16
19
  delete process.env.SUBROUTER_HOME
20
+ delete process.env.SUBROUTER_OPENAI_ISSUER_URL
21
+ for (const server of openServers.splice(0)) {
22
+ await new Promise<void>((resolve) => {
23
+ server.close(() => {
24
+ resolve()
25
+ })
26
+ })
27
+ }
17
28
  await rm(home, { recursive: true, force: true })
18
29
  })
19
30
 
31
+ /** Minimal stand-in for the OpenAI Codex device endpoints. */
32
+ async function startFakeOpenAIIssuer() {
33
+ const server = createServer((req, res) => {
34
+ const url = new URL(req.url || '', 'http://localhost')
35
+ const send = (body: unknown) => {
36
+ res.writeHead(200, { 'Content-Type': 'application/json' })
37
+ res.end(JSON.stringify(body))
38
+ }
39
+ if (url.pathname === '/api/accounts/deviceauth/usercode') {
40
+ return send({ device_auth_id: 'dev-1', user_code: 'ABCD-9876', interval: '0' })
41
+ }
42
+ if (url.pathname === '/api/accounts/deviceauth/token') {
43
+ return send({ authorization_code: 'auth-code', code_verifier: 'verifier' })
44
+ }
45
+ if (url.pathname === '/oauth/token') {
46
+ const claims = Buffer.from(
47
+ JSON.stringify({ email: 'pool@example.com', chatgpt_account_id: 'acct-9' }),
48
+ ).toString('base64url')
49
+ return send({
50
+ access_token: 'access-9',
51
+ refresh_token: 'refresh-9',
52
+ expires_in: 3600,
53
+ id_token: `header.${claims}.signature`,
54
+ })
55
+ }
56
+ res.writeHead(404).end('{}')
57
+ })
58
+ await new Promise<void>((resolve) => {
59
+ server.listen(0, '127.0.0.1', resolve)
60
+ })
61
+ const address = server.address()
62
+ if (typeof address === 'string' || !address) throw new Error('failed to bind fake issuer')
63
+ openServers.push(server)
64
+ return `http://127.0.0.1:${address.port}`
65
+ }
66
+
67
+ function authMethod() {
68
+ return subrouterAuthPlugin({} as PluginInput).then((hooks) => {
69
+ const method = hooks.auth?.methods[0]
70
+ if (!method || method.type !== 'oauth') throw new Error('expected an oauth method')
71
+ return { provider: hooks.auth!.provider, method }
72
+ })
73
+ }
74
+
20
75
  test('config hook registers the subrouter provider with preset models', async () => {
21
76
  await writeFile(
22
77
  path.join(home, 'presets.json'),
@@ -34,3 +89,69 @@ test('config hook registers the subrouter provider with preset models', async ()
34
89
  expect(Object.keys(provider.models).sort()).toEqual(['default', 'work'])
35
90
  expect(provider.models.default.cost).toEqual({ input: 0, output: 0, cache_read: 0, cache_write: 0 })
36
91
  })
92
+
93
+ test('auth hook asks which subscription to add before authorizing', async () => {
94
+ const { provider, method } = await authMethod()
95
+
96
+ expect(provider).toBe('subrouter')
97
+ const prompt = method.prompts?.[0]
98
+ expect(prompt?.type).toBe('select')
99
+ expect(prompt?.key).toBe('provider')
100
+ expect(prompt?.type === 'select' && prompt.options.map((o) => o.value)).toEqual([...PROVIDER_IDS])
101
+ const methodPrompt = method.prompts?.[1]
102
+ expect(methodPrompt?.type).toBe('select')
103
+ expect(methodPrompt?.key).toBe('method')
104
+ expect(methodPrompt?.type === 'select' && methodPrompt.options.map((o) => o.value)).toEqual([
105
+ 'browser',
106
+ 'device',
107
+ ])
108
+ })
109
+
110
+ test('authorize rejects a missing or unknown subscription', async () => {
111
+ const { method } = await authMethod()
112
+
113
+ await expect(method.authorize({})).rejects.toThrow(/Pick a subscription/)
114
+ await expect(method.authorize({ provider: 'nope' })).rejects.toThrow(/Pick a subscription/)
115
+ })
116
+
117
+ test('authorize dispatches to the chosen adapter and the callback pools the account', async () => {
118
+ process.env.SUBROUTER_OPENAI_ISSUER_URL = await startFakeOpenAIIssuer()
119
+ const { method } = await authMethod()
120
+
121
+ const result = await method.authorize({ provider: 'openai', method: 'device' })
122
+ expect(result.method).toBe('auto')
123
+ expect(result.instructions).toMatch(/code:\s*ABCD-9876/)
124
+
125
+ if (result.method !== 'auto') throw new Error('expected the device flow')
126
+ const credentials = await result.callback()
127
+
128
+ expect(credentials).toMatchObject({ type: 'success', access: 'access-9', refresh: 'refresh-9' })
129
+ const accounts = await loadAccounts()
130
+ expect(accounts.providers.openai?.accounts).toMatchObject([
131
+ { type: 'oauth', email: 'pool@example.com', accountId: 'acct-9' },
132
+ ])
133
+ })
134
+
135
+ test('opencode zen asks for a pasted key and stores it as an api account', async () => {
136
+ const { method } = await authMethod()
137
+
138
+ const result = await method.authorize({ provider: 'opencode' })
139
+ expect(result.method).toBe('code')
140
+
141
+ 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' })
143
+
144
+ const accounts = await loadAccounts()
145
+ expect(accounts.providers.opencode?.accounts).toMatchObject([{ type: 'api', key: 'zen-key-1' }])
146
+ })
147
+
148
+ test('a failed login reports failure instead of pooling a broken account', async () => {
149
+ const { method } = await authMethod()
150
+
151
+ const result = await method.authorize({ provider: 'opencode' })
152
+ if (result.method !== 'code') throw new Error('expected a pasted-key flow')
153
+
154
+ expect(await result.callback(' ')).toEqual({ type: 'failed' })
155
+ const accounts = await loadAccounts()
156
+ expect(accounts.providers.opencode).toBeUndefined()
157
+ })
package/src/provider.ts CHANGED
@@ -4,4 +4,18 @@
4
4
  * then `sdk.languageModel(modelId)` where modelId is a subrouter preset name.
5
5
  */
6
6
 
7
+ import {
8
+ OPENAI_WEBSOCKET_SESSION_HEADER,
9
+ OPENAI_WEBSOCKET_TITLE_HEADER,
10
+ } from '@subrouter/cli'
11
+
7
12
  export { createSubrouter } from '@subrouter/cli'
13
+
14
+ export function addSubrouterHeaders(
15
+ input: { sessionID: string; agent: string; model: { providerID: string } },
16
+ output: { headers: Record<string, string> },
17
+ ) {
18
+ if (input.model.providerID !== 'subrouter') return
19
+ output.headers[OPENAI_WEBSOCKET_SESSION_HEADER] = input.sessionID
20
+ if (input.agent === 'title') output.headers[OPENAI_WEBSOCKET_TITLE_HEADER] = 'true'
21
+ }