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