@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.
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/opencode-e2e.test.d.ts +13 -0
- package/dist/opencode-e2e.test.d.ts.map +1 -0
- package/dist/opencode-e2e.test.js +207 -0
- package/dist/plugin.test.d.ts +2 -0
- package/dist/plugin.test.d.ts.map +1 -0
- package/dist/plugin.test.js +26 -0
- package/dist/provider.d.ts +7 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +6 -0
- package/package.json +60 -0
- package/src/index.ts +52 -0
- package/src/opencode-e2e.test.ts +240 -0
- package/src/plugin.test.ts +36 -0
- package/src/provider.ts +7 -0
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
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"}
|
package/dist/provider.js
ADDED
|
@@ -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
|
+
})
|
package/src/provider.ts
ADDED
|
@@ -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'
|