@subrouter/opencode 0.1.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.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * OpenCode plugin that registers the `subrouter` provider.
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.
8
+ *
9
+ * NOTE: only plugin initializer functions may be exported from this module.
10
+ * OpenCode calls every export as a plugin.
11
+ */
12
+ import type { Plugin } from '@opencode-ai/plugin';
13
+ export declare const subrouterPlugin: Plugin;
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * OpenCode plugin that registers the `subrouter` provider.
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.
8
+ *
9
+ * NOTE: only plugin initializer functions may be exported from this module.
10
+ * OpenCode calls every export as a plugin.
11
+ */
12
+ import { DEFAULT_PRESET_NAME, loadPresets } from '@subrouter/cli';
13
+ function providerEntryUrl() {
14
+ const isDev = import.meta.url.endsWith('.ts');
15
+ return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href;
16
+ }
17
+ export const subrouterPlugin = async () => {
18
+ return {
19
+ config: async (config) => {
20
+ const presets = await loadPresets().catch(() => {
21
+ return { version: 1, presets: {} };
22
+ });
23
+ const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)]);
24
+ const models = Object.fromEntries([...names].map((name) => [
25
+ name,
26
+ {
27
+ name: `subrouter ${name}`,
28
+ tool_call: true,
29
+ attachment: false,
30
+ reasoning: false,
31
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
32
+ limit: { context: 200_000, output: 64_000 },
33
+ },
34
+ ]));
35
+ config.provider = {
36
+ ...config.provider,
37
+ subrouter: {
38
+ name: 'Subrouter',
39
+ npm: providerEntryUrl(),
40
+ models,
41
+ options: {},
42
+ },
43
+ };
44
+ },
45
+ };
46
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * End-to-end test: a real opencode server drives the subrouter provider.
3
+ *
4
+ * No real API requests. Fake HTTP servers play the provider endpoints:
5
+ * anthropic always answers 429 (rate limited), the opencode zen mock streams
6
+ * a canned completion. The test prompts opencode with model subrouter/default
7
+ * and asserts the reply came from the fallback provider, proving the cycling
8
+ * works through the whole opencode -> provider -> router pipeline.
9
+ *
10
+ * Requires built dist (pnpm build) because opencode loads dist/provider.js.
11
+ */
12
+ export {};
13
+ //# sourceMappingURL=opencode-e2e.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opencode-e2e.test.d.ts","sourceRoot":"","sources":["../src/opencode-e2e.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
@@ -0,0 +1,207 @@
1
+ /**
2
+ * End-to-end test: a real opencode server drives the subrouter provider.
3
+ *
4
+ * No real API requests. Fake HTTP servers play the provider endpoints:
5
+ * anthropic always answers 429 (rate limited), the opencode zen mock streams
6
+ * a canned completion. The test prompts opencode with model subrouter/default
7
+ * and asserts the reply came from the fallback provider, proving the cycling
8
+ * works through the whole opencode -> provider -> router pipeline.
9
+ *
10
+ * Requires built dist (pnpm build) because opencode loads dist/provider.js.
11
+ */
12
+ import { createOpencodeClient } from '@opencode-ai/sdk';
13
+ import { createOpencodeServer } from '@opencode-ai/sdk/server';
14
+ import { createServer } from 'node:http';
15
+ import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
16
+ import { tmpdir } from 'node:os';
17
+ import path from 'node:path';
18
+ import { pathToFileURL } from 'node:url';
19
+ import { afterAll, beforeAll, describe, expect, test } from 'vitest';
20
+ async function startMockServer(handler) {
21
+ const requests = [];
22
+ const server = createServer((req, res) => {
23
+ let body = '';
24
+ req.on('data', (chunk) => {
25
+ body += String(chunk);
26
+ });
27
+ req.on('end', () => {
28
+ requests.push(req.url ?? '');
29
+ handler({ path: req.url ?? '', body }, res);
30
+ });
31
+ });
32
+ await new Promise((resolve) => {
33
+ server.listen(0, '127.0.0.1', () => {
34
+ resolve();
35
+ });
36
+ });
37
+ const address = server.address();
38
+ if (!address || typeof address === 'string')
39
+ throw new Error('no address');
40
+ return {
41
+ url: `http://127.0.0.1:${address.port}`,
42
+ requests,
43
+ close: async () => {
44
+ await new Promise((resolve) => {
45
+ server.close(() => {
46
+ resolve();
47
+ });
48
+ });
49
+ },
50
+ };
51
+ }
52
+ function sseChunk(data) {
53
+ return `data: ${JSON.stringify(data)}\n\n`;
54
+ }
55
+ let home;
56
+ let projectDir;
57
+ let anthropicMock;
58
+ let zenMock;
59
+ let server;
60
+ const savedEnv = {};
61
+ beforeAll(async () => {
62
+ home = await mkdtemp(path.join(tmpdir(), 'subrouter-e2e-'));
63
+ projectDir = path.join(home, 'project');
64
+ await mkdir(projectDir, { recursive: true });
65
+ // Fake anthropic: always rate limited
66
+ anthropicMock = await startMockServer((_request, res) => {
67
+ res.writeHead(429, { 'content-type': 'application/json' });
68
+ res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }));
69
+ });
70
+ // Fake opencode zen: streams a canned completion
71
+ zenMock = await startMockServer(({ body }, res) => {
72
+ const streaming = body.includes('"stream":true');
73
+ if (!streaming) {
74
+ res.writeHead(200, { 'content-type': 'application/json' });
75
+ res.end(JSON.stringify({
76
+ id: 'chatcmpl-1',
77
+ object: 'chat.completion',
78
+ created: 1,
79
+ model: 'fake-model',
80
+ choices: [
81
+ { index: 0, message: { role: 'assistant', content: 'hello from fallback' }, finish_reason: 'stop' },
82
+ ],
83
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
84
+ }));
85
+ return;
86
+ }
87
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
88
+ res.write(sseChunk({
89
+ id: '1',
90
+ object: 'chat.completion.chunk',
91
+ created: 1,
92
+ model: 'fake-model',
93
+ choices: [
94
+ { index: 0, delta: { role: 'assistant', content: 'hello from fallback' }, finish_reason: null },
95
+ ],
96
+ }));
97
+ res.write(sseChunk({
98
+ id: '1',
99
+ object: 'chat.completion.chunk',
100
+ created: 1,
101
+ model: 'fake-model',
102
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
103
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
104
+ }));
105
+ res.write('data: [DONE]\n\n');
106
+ res.end();
107
+ });
108
+ // Subrouter state: one rate-limited anthropic account + one zen key
109
+ const subrouterHome = path.join(home, 'subrouter');
110
+ await mkdir(subrouterHome, { recursive: true });
111
+ await writeFile(path.join(subrouterHome, 'accounts.json'), JSON.stringify({
112
+ version: 1,
113
+ providers: {
114
+ anthropic: {
115
+ activeIndex: 0,
116
+ accounts: [
117
+ {
118
+ type: 'oauth',
119
+ refresh: 'fake-refresh',
120
+ access: 'fake-access',
121
+ expires: Date.now() + 1_000_000_000,
122
+ email: 'a@x.com',
123
+ addedAt: 1,
124
+ lastUsed: 1,
125
+ },
126
+ ],
127
+ },
128
+ opencode: {
129
+ activeIndex: 0,
130
+ accounts: [{ type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 }],
131
+ },
132
+ },
133
+ }));
134
+ for (const [key, value] of Object.entries({
135
+ SUBROUTER_HOME: subrouterHome,
136
+ SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
137
+ SUBROUTER_OPENCODE_BASE_URL: `${zenMock.url}/v1`,
138
+ // Isolate opencode from the user's real global config and auth
139
+ XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
140
+ XDG_DATA_HOME: path.join(home, 'xdg-data'),
141
+ XDG_CACHE_HOME: path.join(home, 'xdg-cache'),
142
+ XDG_STATE_HOME: path.join(home, 'xdg-state'),
143
+ })) {
144
+ savedEnv[key] = process.env[key];
145
+ process.env[key] = value;
146
+ }
147
+ const providerEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'provider.js')).href;
148
+ server = await createOpencodeServer({
149
+ port: 0,
150
+ timeout: 60_000,
151
+ config: {
152
+ provider: {
153
+ subrouter: {
154
+ name: 'Subrouter',
155
+ npm: providerEntry,
156
+ models: {
157
+ default: {
158
+ name: 'subrouter default',
159
+ tool_call: true,
160
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
161
+ limit: { context: 200_000, output: 64_000 },
162
+ },
163
+ },
164
+ },
165
+ },
166
+ },
167
+ });
168
+ }, 120_000);
169
+ afterAll(async () => {
170
+ server?.close();
171
+ await anthropicMock?.close();
172
+ await zenMock?.close();
173
+ for (const [key, value] of Object.entries(savedEnv)) {
174
+ if (value === undefined)
175
+ delete process.env[key];
176
+ else
177
+ process.env[key] = value;
178
+ }
179
+ await rm(home, { recursive: true, force: true });
180
+ });
181
+ describe('opencode + subrouter provider', () => {
182
+ test('rate-limited provider is cycled to the fallback through opencode', async () => {
183
+ const client = createOpencodeClient({ baseUrl: server.url });
184
+ const session = await client.session.create({
185
+ query: { directory: projectDir },
186
+ body: { title: 'subrouter e2e' },
187
+ });
188
+ expect(session.data).toBeTruthy();
189
+ const result = await client.session.prompt({
190
+ path: { id: session.data.id },
191
+ query: { directory: projectDir },
192
+ body: {
193
+ model: { providerID: 'subrouter', modelID: 'default' },
194
+ parts: [{ type: 'text', text: 'say hi' }],
195
+ },
196
+ });
197
+ const parts = result.data?.parts ?? [];
198
+ const texts = parts
199
+ .filter((part) => part.type === 'text')
200
+ .map((part) => part.text)
201
+ .join('\n');
202
+ expect(texts).toContain('hello from fallback');
203
+ // The anthropic mock was tried first and rate limited
204
+ expect(anthropicMock.requests.length).toBeGreaterThan(0);
205
+ expect(zenMock.requests.length).toBeGreaterThan(0);
206
+ }, 120_000);
207
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=plugin.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.test.d.ts","sourceRoot":"","sources":["../src/plugin.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,26 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, expect, test } from 'vitest';
5
+ import { subrouterPlugin } from "./index.js";
6
+ let home;
7
+ beforeEach(async () => {
8
+ home = await mkdtemp(path.join(tmpdir(), 'subrouter-plugin-'));
9
+ process.env.SUBROUTER_HOME = home;
10
+ });
11
+ afterEach(async () => {
12
+ delete process.env.SUBROUTER_HOME;
13
+ await rm(home, { recursive: true, force: true });
14
+ });
15
+ 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({});
18
+ const config = {};
19
+ await hooks.config?.(config);
20
+ const provider = config.provider?.subrouter;
21
+ expect(provider).toBeTruthy();
22
+ expect(provider.npm.startsWith('file://')).toBe(true);
23
+ expect(provider.npm.endsWith('provider.ts') || provider.npm.endsWith('provider.js')).toBe(true);
24
+ expect(Object.keys(provider.models).sort()).toEqual(['default', 'work']);
25
+ expect(provider.models.default.cost).toEqual({ input: 0, output: 0, cache_read: 0, cache_write: 0 });
26
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Provider entry loaded by opencode via `provider.subrouter.npm` (file:// URL).
3
+ * OpenCode imports this module, calls the first export starting with `create`,
4
+ * then `sdk.languageModel(modelId)` where modelId is a subrouter preset name.
5
+ */
6
+ export { createSubrouter } from '@subrouter/cli';
7
+ //# sourceMappingURL=provider.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Provider entry loaded by opencode via `provider.subrouter.npm` (file:// URL).
3
+ * OpenCode imports this module, calls the first export starting with `create`,
4
+ * then `sdk.languageModel(modelId)` where modelId is a subrouter preset name.
5
+ */
6
+ export { createSubrouter } from '@subrouter/cli';
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@subrouter/opencode",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "OpenCode plugin that registers the subrouter provider: cycle through your personal AI subscriptions when one hits rate limits.",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/remorses/subrouter.git",
11
+ "directory": "opencode"
12
+ },
13
+ "homepage": "https://github.com/remorses/subrouter",
14
+ "bugs": "https://github.com/remorses/subrouter/issues",
15
+ "keywords": [
16
+ "opencode",
17
+ "opencode-plugin",
18
+ "ai",
19
+ "router",
20
+ "subscriptions"
21
+ ],
22
+ "files": [
23
+ "src",
24
+ "dist"
25
+ ],
26
+ "exports": {
27
+ "./package.json": "./package.json",
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./provider": {
33
+ "types": "./dist/provider.d.ts",
34
+ "default": "./dist/provider.js"
35
+ },
36
+ "./src": {
37
+ "types": "./src/index.ts",
38
+ "default": "./src/index.ts"
39
+ },
40
+ "./src/*": {
41
+ "types": "./src/*.ts",
42
+ "default": "./src/*.ts"
43
+ }
44
+ },
45
+ "dependencies": {
46
+ "errore": "^0.14.1",
47
+ "@subrouter/cli": "^0.1.0"
48
+ },
49
+ "devDependencies": {
50
+ "@opencode-ai/plugin": "^1.18.23",
51
+ "@opencode-ai/sdk": "^1.18.23",
52
+ "@types/node": "^24.0.0",
53
+ "rimraf": "^6.0.1"
54
+ },
55
+ "scripts": {
56
+ "build": "tsc",
57
+ "typecheck": "tsc",
58
+ "test": "vitest run"
59
+ }
60
+ }
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * OpenCode plugin that registers the `subrouter` provider.
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.
8
+ *
9
+ * NOTE: only plugin initializer functions may be exported from this module.
10
+ * OpenCode calls every export as a plugin.
11
+ */
12
+
13
+ import type { Plugin } from '@opencode-ai/plugin'
14
+ import { DEFAULT_PRESET_NAME, loadPresets } from '@subrouter/cli'
15
+
16
+ function providerEntryUrl() {
17
+ const isDev = import.meta.url.endsWith('.ts')
18
+ return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href
19
+ }
20
+
21
+ export const subrouterPlugin: Plugin = async () => {
22
+ return {
23
+ config: async (config) => {
24
+ const presets = await loadPresets().catch(() => {
25
+ return { version: 1 as const, presets: {} }
26
+ })
27
+ const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)])
28
+ 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
+ ]),
40
+ )
41
+ config.provider = {
42
+ ...config.provider,
43
+ subrouter: {
44
+ name: 'Subrouter',
45
+ npm: providerEntryUrl(),
46
+ models,
47
+ options: {},
48
+ },
49
+ }
50
+ },
51
+ }
52
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * End-to-end test: a real opencode server drives the subrouter provider.
3
+ *
4
+ * No real API requests. Fake HTTP servers play the provider endpoints:
5
+ * anthropic always answers 429 (rate limited), the opencode zen mock streams
6
+ * a canned completion. The test prompts opencode with model subrouter/default
7
+ * and asserts the reply came from the fallback provider, proving the cycling
8
+ * works through the whole opencode -> provider -> router pipeline.
9
+ *
10
+ * Requires built dist (pnpm build) because opencode loads dist/provider.js.
11
+ */
12
+
13
+ import { createOpencodeClient } from '@opencode-ai/sdk'
14
+ import { createOpencodeServer } from '@opencode-ai/sdk/server'
15
+ import { createServer, type Server } from 'node:http'
16
+ import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'
17
+ import { tmpdir } from 'node:os'
18
+ import path from 'node:path'
19
+ import { pathToFileURL } from 'node:url'
20
+ import { afterAll, beforeAll, describe, expect, test } from 'vitest'
21
+
22
+ type MockServer = {
23
+ url: string
24
+ requests: string[]
25
+ close: () => Promise<void>
26
+ }
27
+
28
+ async function startMockServer(
29
+ handler: (args: { path: string; body: string }, res: import('node:http').ServerResponse) => void,
30
+ ): Promise<MockServer> {
31
+ const requests: string[] = []
32
+ const server: Server = createServer((req, res) => {
33
+ let body = ''
34
+ req.on('data', (chunk) => {
35
+ body += String(chunk)
36
+ })
37
+ req.on('end', () => {
38
+ requests.push(req.url ?? '')
39
+ handler({ path: req.url ?? '', body }, res)
40
+ })
41
+ })
42
+ await new Promise<void>((resolve) => {
43
+ server.listen(0, '127.0.0.1', () => {
44
+ resolve()
45
+ })
46
+ })
47
+ const address = server.address()
48
+ if (!address || typeof address === 'string') throw new Error('no address')
49
+ return {
50
+ url: `http://127.0.0.1:${address.port}`,
51
+ requests,
52
+ close: async () => {
53
+ await new Promise<void>((resolve) => {
54
+ server.close(() => {
55
+ resolve()
56
+ })
57
+ })
58
+ },
59
+ }
60
+ }
61
+
62
+ function sseChunk(data: unknown) {
63
+ return `data: ${JSON.stringify(data)}\n\n`
64
+ }
65
+
66
+ let home: string
67
+ let projectDir: string
68
+ let anthropicMock: MockServer
69
+ let zenMock: MockServer
70
+ let server: { url: string; close: () => void }
71
+ const savedEnv: Record<string, string | undefined> = {}
72
+
73
+ beforeAll(async () => {
74
+ home = await mkdtemp(path.join(tmpdir(), 'subrouter-e2e-'))
75
+ projectDir = path.join(home, 'project')
76
+ await mkdir(projectDir, { recursive: true })
77
+
78
+ // Fake anthropic: always rate limited
79
+ anthropicMock = await startMockServer((_request, res) => {
80
+ res.writeHead(429, { 'content-type': 'application/json' })
81
+ res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }))
82
+ })
83
+
84
+ // Fake opencode zen: streams a canned completion
85
+ zenMock = await startMockServer(({ body }, res) => {
86
+ const streaming = body.includes('"stream":true')
87
+ if (!streaming) {
88
+ res.writeHead(200, { 'content-type': 'application/json' })
89
+ res.end(
90
+ JSON.stringify({
91
+ id: 'chatcmpl-1',
92
+ object: 'chat.completion',
93
+ created: 1,
94
+ model: 'fake-model',
95
+ choices: [
96
+ { index: 0, message: { role: 'assistant', content: 'hello from fallback' }, finish_reason: 'stop' },
97
+ ],
98
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
99
+ }),
100
+ )
101
+ return
102
+ }
103
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
104
+ res.write(
105
+ sseChunk({
106
+ id: '1',
107
+ object: 'chat.completion.chunk',
108
+ created: 1,
109
+ model: 'fake-model',
110
+ choices: [
111
+ { index: 0, delta: { role: 'assistant', content: 'hello from fallback' }, finish_reason: null },
112
+ ],
113
+ }),
114
+ )
115
+ res.write(
116
+ sseChunk({
117
+ id: '1',
118
+ object: 'chat.completion.chunk',
119
+ created: 1,
120
+ model: 'fake-model',
121
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
122
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
123
+ }),
124
+ )
125
+ res.write('data: [DONE]\n\n')
126
+ res.end()
127
+ })
128
+
129
+ // Subrouter state: one rate-limited anthropic account + one zen key
130
+ const subrouterHome = path.join(home, 'subrouter')
131
+ await mkdir(subrouterHome, { recursive: true })
132
+ await writeFile(
133
+ path.join(subrouterHome, 'accounts.json'),
134
+ JSON.stringify({
135
+ version: 1,
136
+ providers: {
137
+ anthropic: {
138
+ activeIndex: 0,
139
+ accounts: [
140
+ {
141
+ type: 'oauth',
142
+ refresh: 'fake-refresh',
143
+ access: 'fake-access',
144
+ expires: Date.now() + 1_000_000_000,
145
+ email: 'a@x.com',
146
+ addedAt: 1,
147
+ lastUsed: 1,
148
+ },
149
+ ],
150
+ },
151
+ opencode: {
152
+ activeIndex: 0,
153
+ accounts: [{ type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 }],
154
+ },
155
+ },
156
+ }),
157
+ )
158
+
159
+ for (const [key, value] of Object.entries({
160
+ SUBROUTER_HOME: subrouterHome,
161
+ SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
162
+ SUBROUTER_OPENCODE_BASE_URL: `${zenMock.url}/v1`,
163
+ // Isolate opencode from the user's real global config and auth
164
+ XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
165
+ XDG_DATA_HOME: path.join(home, 'xdg-data'),
166
+ XDG_CACHE_HOME: path.join(home, 'xdg-cache'),
167
+ XDG_STATE_HOME: path.join(home, 'xdg-state'),
168
+ })) {
169
+ savedEnv[key] = process.env[key]
170
+ process.env[key] = value
171
+ }
172
+
173
+ const providerEntry = pathToFileURL(
174
+ path.join(import.meta.dirname, '..', 'dist', 'provider.js'),
175
+ ).href
176
+
177
+ server = await createOpencodeServer({
178
+ port: 0,
179
+ timeout: 60_000,
180
+ config: {
181
+ provider: {
182
+ subrouter: {
183
+ name: 'Subrouter',
184
+ npm: providerEntry,
185
+ models: {
186
+ default: {
187
+ name: 'subrouter default',
188
+ tool_call: true,
189
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
190
+ limit: { context: 200_000, output: 64_000 },
191
+ },
192
+ },
193
+ },
194
+ },
195
+ },
196
+ })
197
+ }, 120_000)
198
+
199
+ afterAll(async () => {
200
+ server?.close()
201
+ await anthropicMock?.close()
202
+ await zenMock?.close()
203
+ for (const [key, value] of Object.entries(savedEnv)) {
204
+ if (value === undefined) delete process.env[key]
205
+ else process.env[key] = value
206
+ }
207
+ await rm(home, { recursive: true, force: true })
208
+ })
209
+
210
+ describe('opencode + subrouter provider', () => {
211
+ test('rate-limited provider is cycled to the fallback through opencode', async () => {
212
+ const client = createOpencodeClient({ baseUrl: server.url })
213
+
214
+ const session = await client.session.create({
215
+ query: { directory: projectDir },
216
+ body: { title: 'subrouter e2e' },
217
+ })
218
+ expect(session.data).toBeTruthy()
219
+
220
+ const result = await client.session.prompt({
221
+ path: { id: session.data!.id },
222
+ query: { directory: projectDir },
223
+ body: {
224
+ model: { providerID: 'subrouter', modelID: 'default' },
225
+ parts: [{ type: 'text', text: 'say hi' }],
226
+ },
227
+ })
228
+
229
+ const parts = result.data?.parts ?? []
230
+ const texts = parts
231
+ .filter((part) => part.type === 'text')
232
+ .map((part) => (part as { text: string }).text)
233
+ .join('\n')
234
+ expect(texts).toContain('hello from fallback')
235
+
236
+ // The anthropic mock was tried first and rate limited
237
+ expect(anthropicMock.requests.length).toBeGreaterThan(0)
238
+ expect(zenMock.requests.length).toBeGreaterThan(0)
239
+ }, 120_000)
240
+ })
@@ -0,0 +1,36 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, beforeEach, expect, test } from 'vitest'
5
+ import type { PluginInput } from '@opencode-ai/plugin'
6
+ import { subrouterPlugin } from './index.ts'
7
+
8
+ let home: string
9
+
10
+ beforeEach(async () => {
11
+ home = await mkdtemp(path.join(tmpdir(), 'subrouter-plugin-'))
12
+ process.env.SUBROUTER_HOME = home
13
+ })
14
+
15
+ afterEach(async () => {
16
+ delete process.env.SUBROUTER_HOME
17
+ await rm(home, { recursive: true, force: true })
18
+ })
19
+
20
+ test('config hook registers the subrouter provider with preset models', async () => {
21
+ await writeFile(
22
+ path.join(home, 'presets.json'),
23
+ JSON.stringify({ version: 1, presets: { work: ['anthropic/claude-opus-4-6'] } }),
24
+ )
25
+
26
+ const hooks = await subrouterPlugin({} as PluginInput)
27
+ const config: Record<string, any> = {}
28
+ await hooks.config?.(config as any)
29
+
30
+ const provider = config.provider?.subrouter
31
+ expect(provider).toBeTruthy()
32
+ expect(provider.npm.startsWith('file://')).toBe(true)
33
+ expect(provider.npm.endsWith('provider.ts') || provider.npm.endsWith('provider.js')).toBe(true)
34
+ expect(Object.keys(provider.models).sort()).toEqual(['default', 'work'])
35
+ expect(provider.models.default.cost).toEqual({ input: 0, output: 0, cache_read: 0, cache_write: 0 })
36
+ })
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Provider entry loaded by opencode via `provider.subrouter.npm` (file:// URL).
3
+ * OpenCode imports this module, calls the first export starting with `create`,
4
+ * then `sdk.languageModel(modelId)` where modelId is a subrouter preset name.
5
+ */
6
+
7
+ export { createSubrouter } from '@subrouter/cli'