@subrouter/opencode 0.2.0 → 0.4.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 +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +121 -19
- package/dist/opencode-e2e.test.d.ts +1 -1
- package/dist/opencode-e2e.test.js +245 -32
- package/dist/plugin.test.js +378 -11
- package/dist/provider.d.ts +21 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +24 -2
- package/package.json +2 -2
- package/src/index.ts +131 -19
- package/src/opencode-e2e.test.ts +289 -37
- package/src/plugin.test.ts +425 -15
- package/src/provider.ts +51 -2
package/dist/plugin.test.js
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
|
+
import childProcess from 'node:child_process';
|
|
1
2
|
import { createServer } from 'node:http';
|
|
2
|
-
import { mkdtemp, rm
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
6
|
+
import util from 'node:util';
|
|
5
7
|
import { afterEach, beforeEach, expect, test } from 'vitest';
|
|
6
|
-
import {
|
|
8
|
+
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
9
|
+
import { addAccount, adapters, loadAccounts, PROVIDER_DISPLAY_NAME, PROVIDER_IDS, savePreset, } from '@subrouter/cli';
|
|
7
10
|
import { subrouterAuthPlugin, subrouterPlugin } from "./index.js";
|
|
11
|
+
import { revealRoutedModel, rewritePoweredByModelLine } from "./provider.js";
|
|
12
|
+
const execFile = util.promisify(childProcess.execFile);
|
|
8
13
|
let home;
|
|
9
14
|
const openServers = [];
|
|
15
|
+
const pluginInput = {};
|
|
10
16
|
beforeEach(async () => {
|
|
11
17
|
home = await mkdtemp(path.join(tmpdir(), 'subrouter-plugin-'));
|
|
12
18
|
process.env.SUBROUTER_HOME = home;
|
|
19
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev();
|
|
13
20
|
});
|
|
14
21
|
afterEach(async () => {
|
|
15
22
|
delete process.env.SUBROUTER_HOME;
|
|
16
23
|
delete process.env.SUBROUTER_OPENAI_ISSUER_URL;
|
|
24
|
+
delete process.env.SUBROUTER_MODELS_DEV_URL;
|
|
17
25
|
for (const server of openServers.splice(0)) {
|
|
18
26
|
await new Promise((resolve) => {
|
|
19
27
|
server.close(() => {
|
|
@@ -23,6 +31,33 @@ afterEach(async () => {
|
|
|
23
31
|
}
|
|
24
32
|
await rm(home, { recursive: true, force: true });
|
|
25
33
|
});
|
|
34
|
+
async function startFakeModelsDev(providers = {}) {
|
|
35
|
+
const payload = {
|
|
36
|
+
anthropic: { models: {} },
|
|
37
|
+
openai: { models: {} },
|
|
38
|
+
xai: { models: {} },
|
|
39
|
+
'opencode-go': { models: {} },
|
|
40
|
+
'github-copilot': { models: {} },
|
|
41
|
+
poe: { models: {} },
|
|
42
|
+
'minimax-coding-plan': { models: {} },
|
|
43
|
+
'kimi-for-coding': { models: {} },
|
|
44
|
+
'zai-coding-plan': { models: {} },
|
|
45
|
+
'alibaba-coding-plan': { models: {} },
|
|
46
|
+
...providers,
|
|
47
|
+
};
|
|
48
|
+
const server = createServer((_req, res) => {
|
|
49
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
50
|
+
res.end(JSON.stringify(payload));
|
|
51
|
+
});
|
|
52
|
+
await new Promise((resolve) => {
|
|
53
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
54
|
+
});
|
|
55
|
+
const address = server.address();
|
|
56
|
+
if (typeof address === 'string' || !address)
|
|
57
|
+
throw new Error('failed to bind fake models.dev');
|
|
58
|
+
openServers.push(server);
|
|
59
|
+
return `http://127.0.0.1:${address.port}`;
|
|
60
|
+
}
|
|
26
61
|
/** Minimal stand-in for the OpenAI Codex device endpoints. */
|
|
27
62
|
async function startFakeOpenAIIssuer() {
|
|
28
63
|
const server = createServer((req, res) => {
|
|
@@ -58,24 +93,356 @@ async function startFakeOpenAIIssuer() {
|
|
|
58
93
|
return `http://127.0.0.1:${address.port}`;
|
|
59
94
|
}
|
|
60
95
|
function authMethod() {
|
|
61
|
-
return subrouterAuthPlugin(
|
|
96
|
+
return subrouterAuthPlugin(pluginInput).then((hooks) => {
|
|
62
97
|
const method = hooks.auth?.methods[0];
|
|
63
98
|
if (!method || method.type !== 'oauth')
|
|
64
99
|
throw new Error('expected an oauth method');
|
|
65
100
|
return { provider: hooks.auth.provider, method };
|
|
66
101
|
});
|
|
67
102
|
}
|
|
103
|
+
test('plugin load and config do not write stdout or stderr', async () => {
|
|
104
|
+
const script = "import('./src/index.ts').then(async ({ subrouterPlugin }) => { const hooks = await subrouterPlugin({}); await hooks.config?.({}) })";
|
|
105
|
+
const result = await execFile(process.execPath, ['--no-warnings', '--import', 'tsx', '--eval', script], {
|
|
106
|
+
cwd: process.cwd(),
|
|
107
|
+
env: process.env,
|
|
108
|
+
});
|
|
109
|
+
expect(result).toEqual({ stdout: '', stderr: '' });
|
|
110
|
+
});
|
|
111
|
+
test('provider log callback forwards only to client.app.log', async () => {
|
|
112
|
+
const received = Promise.withResolvers();
|
|
113
|
+
const server = createServer((req, res) => {
|
|
114
|
+
const chunks = [];
|
|
115
|
+
req.on('data', (chunk) => {
|
|
116
|
+
chunks.push(chunk);
|
|
117
|
+
});
|
|
118
|
+
req.on('end', () => {
|
|
119
|
+
received.resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
120
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
121
|
+
res.end('{}');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
await new Promise((resolve) => {
|
|
125
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
126
|
+
});
|
|
127
|
+
openServers.push(server);
|
|
128
|
+
const address = server.address();
|
|
129
|
+
if (typeof address === 'string' || !address)
|
|
130
|
+
throw new Error('failed to bind log server');
|
|
131
|
+
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` });
|
|
132
|
+
const hooks = await subrouterPlugin({ ...pluginInput, client });
|
|
133
|
+
const config = {};
|
|
134
|
+
await hooks.config?.(config);
|
|
135
|
+
const log = config.provider?.subrouter?.options?.log;
|
|
136
|
+
expect(log).toBeTypeOf('function');
|
|
137
|
+
if (typeof log !== 'function')
|
|
138
|
+
throw new Error('expected provider log callback');
|
|
139
|
+
await log({
|
|
140
|
+
level: 'warn',
|
|
141
|
+
message: 'failover openai/gpt-5.5',
|
|
142
|
+
extra: { provider: 'openai', modelId: 'gpt-5.5' },
|
|
143
|
+
});
|
|
144
|
+
expect(await received.promise).toEqual({
|
|
145
|
+
service: 'subrouter',
|
|
146
|
+
level: 'warn',
|
|
147
|
+
message: 'failover openai/gpt-5.5',
|
|
148
|
+
extra: { provider: 'openai', modelId: 'gpt-5.5' },
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
test('cooldown fallback creates only a persisted ignored notice after idle', async () => {
|
|
152
|
+
const requests = [];
|
|
153
|
+
const server = createServer((req, res) => {
|
|
154
|
+
const chunks = [];
|
|
155
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
156
|
+
req.on('end', () => {
|
|
157
|
+
requests.push({
|
|
158
|
+
path: req.url ?? '',
|
|
159
|
+
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
|
160
|
+
});
|
|
161
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
162
|
+
res.end('{}');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
166
|
+
openServers.push(server);
|
|
167
|
+
const address = server.address();
|
|
168
|
+
if (typeof address === 'string' || !address)
|
|
169
|
+
throw new Error('failed to bind notification server');
|
|
170
|
+
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` });
|
|
171
|
+
const hooks = await subrouterPlugin({ ...pluginInput, client, directory: '/tmp/project' });
|
|
172
|
+
const config = {};
|
|
173
|
+
await hooks.config?.(config);
|
|
174
|
+
const onCooldownFallback = config.provider?.subrouter?.options?.onCooldownFallback;
|
|
175
|
+
expect(onCooldownFallback).toBeTypeOf('function');
|
|
176
|
+
if (typeof onCooldownFallback !== 'function')
|
|
177
|
+
throw new Error('expected cooldown callback');
|
|
178
|
+
await onCooldownFallback({
|
|
179
|
+
sessionID: 'session-1',
|
|
180
|
+
agent: 'build',
|
|
181
|
+
variant: 'high',
|
|
182
|
+
preset: 'work',
|
|
183
|
+
preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 252_000 },
|
|
184
|
+
active: { provider: 'openai', modelId: 'gpt-5.6-sol' },
|
|
185
|
+
});
|
|
186
|
+
await onCooldownFallback({
|
|
187
|
+
sessionID: 'session-1',
|
|
188
|
+
agent: 'compaction',
|
|
189
|
+
preset: 'work',
|
|
190
|
+
preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 251_000 },
|
|
191
|
+
active: { provider: 'anthropic', modelId: 'claude-sonnet-5' },
|
|
192
|
+
});
|
|
193
|
+
const event = {
|
|
194
|
+
type: 'session.idle',
|
|
195
|
+
properties: { sessionID: 'session-1' },
|
|
196
|
+
};
|
|
197
|
+
await hooks.event?.({ event });
|
|
198
|
+
expect(requests).toEqual([
|
|
199
|
+
{
|
|
200
|
+
path: '/session/session-1/message?directory=%2Ftmp%2Fproject',
|
|
201
|
+
body: {
|
|
202
|
+
noReply: true,
|
|
203
|
+
agent: 'build',
|
|
204
|
+
model: { providerID: 'subrouter', modelID: 'work' },
|
|
205
|
+
variant: 'high',
|
|
206
|
+
parts: [
|
|
207
|
+
{
|
|
208
|
+
type: 'text',
|
|
209
|
+
text: 'Subrouter: xai/grok-4.6 was rate limited. This message started with openai/gpt-5.6-sol.',
|
|
210
|
+
ignored: true,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
68
217
|
test('config hook registers the subrouter provider with preset models', async () => {
|
|
69
|
-
await
|
|
70
|
-
const hooks = await subrouterPlugin(
|
|
218
|
+
await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] });
|
|
219
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
71
220
|
const config = {};
|
|
72
221
|
await hooks.config?.(config);
|
|
73
222
|
const provider = config.provider?.subrouter;
|
|
74
223
|
expect(provider).toBeTruthy();
|
|
224
|
+
expect(provider.name).toBe(PROVIDER_DISPLAY_NAME);
|
|
75
225
|
expect(provider.npm.startsWith('file://')).toBe(true);
|
|
76
226
|
expect(provider.npm.endsWith('provider.ts') || provider.npm.endsWith('provider.js')).toBe(true);
|
|
77
227
|
expect(Object.keys(provider.models).sort()).toEqual(['default', 'work']);
|
|
228
|
+
expect(provider.models.default.name).toBe('default');
|
|
229
|
+
expect(provider.models.work.name).toBe('work');
|
|
78
230
|
expect(provider.models.default.cost).toEqual({ input: 0, output: 0, cache_read: 0, cache_write: 0 });
|
|
231
|
+
expect(provider.models.default.limit).toEqual({ context: 200_000, output: 64_000 });
|
|
232
|
+
expect(provider.models.work.limit).toEqual({ context: 200_000, output: 64_000 });
|
|
233
|
+
});
|
|
234
|
+
test('preset model names stay stable when the routed candidate changes', async () => {
|
|
235
|
+
await addAccount({
|
|
236
|
+
provider: 'anthropic',
|
|
237
|
+
account: {
|
|
238
|
+
type: 'oauth',
|
|
239
|
+
refresh: 'refresh-1',
|
|
240
|
+
access: 'access-1',
|
|
241
|
+
expires: Date.now() + 60_000,
|
|
242
|
+
email: 'a@x.com',
|
|
243
|
+
addedAt: 1,
|
|
244
|
+
lastUsed: 1,
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] });
|
|
248
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
249
|
+
const config = {};
|
|
250
|
+
await hooks.config?.(config);
|
|
251
|
+
expect(config.provider?.subrouter?.name).toBe(PROVIDER_DISPLAY_NAME);
|
|
252
|
+
expect(config.provider?.subrouter?.models?.work?.name).toBe('work');
|
|
253
|
+
});
|
|
254
|
+
test('preset model limits follow the first live candidate', async () => {
|
|
255
|
+
const modelId = adapters.anthropic.defaultModels[0];
|
|
256
|
+
if (!modelId)
|
|
257
|
+
throw new Error('anthropic adapter has no default model');
|
|
258
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
259
|
+
anthropic: {
|
|
260
|
+
models: {
|
|
261
|
+
[modelId]: {
|
|
262
|
+
id: modelId,
|
|
263
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
264
|
+
limit: { context: 1_000_000, input: 900_000, output: 128_000 },
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
await addAccount({
|
|
270
|
+
provider: 'anthropic',
|
|
271
|
+
account: {
|
|
272
|
+
type: 'oauth',
|
|
273
|
+
refresh: 'refresh-1',
|
|
274
|
+
access: 'access-1',
|
|
275
|
+
expires: Date.now() + 60_000,
|
|
276
|
+
email: 'a@x.com',
|
|
277
|
+
addedAt: 1,
|
|
278
|
+
lastUsed: 1,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
await savePreset({ name: 'work', models: [`anthropic/${modelId}`] });
|
|
282
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
283
|
+
const config = {};
|
|
284
|
+
await hooks.config?.(config);
|
|
285
|
+
expect(config.provider?.subrouter?.models?.work?.limit).toEqual({
|
|
286
|
+
context: 1_000_000,
|
|
287
|
+
input: 900_000,
|
|
288
|
+
output: 128_000,
|
|
289
|
+
});
|
|
290
|
+
expect(config.provider?.subrouter?.models?.default?.limit).toEqual({
|
|
291
|
+
context: 1_000_000,
|
|
292
|
+
input: 900_000,
|
|
293
|
+
output: 128_000,
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
test('preset model input modalities are the union of usable candidates', async () => {
|
|
297
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
298
|
+
openai: {
|
|
299
|
+
models: {
|
|
300
|
+
'gpt-pdf': {
|
|
301
|
+
id: 'gpt-pdf',
|
|
302
|
+
attachment: true,
|
|
303
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
'kimi-for-coding': {
|
|
308
|
+
models: {
|
|
309
|
+
'kimi-image': {
|
|
310
|
+
id: 'kimi-image',
|
|
311
|
+
attachment: true,
|
|
312
|
+
modalities: { input: ['text', 'image'], output: ['text'] },
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
await addAccount({
|
|
318
|
+
provider: 'openai',
|
|
319
|
+
account: {
|
|
320
|
+
type: 'oauth',
|
|
321
|
+
access: 'access-1',
|
|
322
|
+
refresh: 'refresh-1',
|
|
323
|
+
expires: Date.now() + 60_000,
|
|
324
|
+
addedAt: 1,
|
|
325
|
+
lastUsed: 1,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
await addAccount({
|
|
329
|
+
provider: 'kimi',
|
|
330
|
+
account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
|
|
331
|
+
});
|
|
332
|
+
await savePreset({ name: 'work', models: ['openai/gpt-pdf', 'kimi/kimi-image'] });
|
|
333
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
334
|
+
const config = {};
|
|
335
|
+
await hooks.config?.(config);
|
|
336
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
337
|
+
attachment: true,
|
|
338
|
+
modalities: {
|
|
339
|
+
input: ['text', 'image', 'pdf'],
|
|
340
|
+
output: ['text'],
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
test('xAI presets do not advertise inline PDF support that its SDK cannot encode', async () => {
|
|
345
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
346
|
+
xai: {
|
|
347
|
+
models: {
|
|
348
|
+
'grok-pdf': {
|
|
349
|
+
id: 'grok-pdf',
|
|
350
|
+
attachment: true,
|
|
351
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
await addAccount({
|
|
357
|
+
provider: 'xai',
|
|
358
|
+
account: {
|
|
359
|
+
type: 'oauth',
|
|
360
|
+
access: 'access-1',
|
|
361
|
+
refresh: 'refresh-1',
|
|
362
|
+
expires: Date.now() + 60_000,
|
|
363
|
+
addedAt: 1,
|
|
364
|
+
lastUsed: 1,
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
await savePreset({ name: 'work', models: ['xai/grok-pdf'] });
|
|
368
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
369
|
+
const config = {};
|
|
370
|
+
await hooks.config?.(config);
|
|
371
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
372
|
+
attachment: true,
|
|
373
|
+
modalities: {
|
|
374
|
+
input: ['text', 'image'],
|
|
375
|
+
output: ['text'],
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
test('Anthropic-compatible coding plans do not advertise video input', async () => {
|
|
380
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
381
|
+
'kimi-for-coding': {
|
|
382
|
+
models: {
|
|
383
|
+
'kimi-video': {
|
|
384
|
+
id: 'kimi-video',
|
|
385
|
+
attachment: true,
|
|
386
|
+
modalities: { input: ['text', 'image', 'video'], output: ['text'] },
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
await addAccount({
|
|
392
|
+
provider: 'kimi',
|
|
393
|
+
account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
|
|
394
|
+
});
|
|
395
|
+
await savePreset({ name: 'work', models: ['kimi/kimi-video'] });
|
|
396
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
397
|
+
const config = {};
|
|
398
|
+
await hooks.config?.(config);
|
|
399
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
400
|
+
attachment: true,
|
|
401
|
+
modalities: {
|
|
402
|
+
input: ['text', 'image'],
|
|
403
|
+
output: ['text'],
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
test('rewrites the OpenCode powered-by line to the routed candidate', () => {
|
|
408
|
+
const system = [
|
|
409
|
+
'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
|
|
410
|
+
];
|
|
411
|
+
rewritePoweredByModelLine({
|
|
412
|
+
system,
|
|
413
|
+
candidate: { provider: 'anthropic', modelId: 'claude-opus-4-6' },
|
|
414
|
+
});
|
|
415
|
+
expect(system[0]).toContain('You are powered by the model named claude-opus-4-6.');
|
|
416
|
+
expect(system[0]).toContain('The exact model ID is anthropic/claude-opus-4-6');
|
|
417
|
+
expect(system[0]).not.toContain('subrouter/build');
|
|
418
|
+
});
|
|
419
|
+
test('system transform rewrites the powered-by line to the live candidate', async () => {
|
|
420
|
+
await addAccount({
|
|
421
|
+
provider: 'anthropic',
|
|
422
|
+
account: {
|
|
423
|
+
type: 'oauth',
|
|
424
|
+
refresh: 'refresh-1',
|
|
425
|
+
access: 'access-1',
|
|
426
|
+
expires: Date.now() + 60_000,
|
|
427
|
+
email: 'a@x.com',
|
|
428
|
+
addedAt: 1,
|
|
429
|
+
lastUsed: 1,
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
const system = [
|
|
433
|
+
'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
|
|
434
|
+
];
|
|
435
|
+
await revealRoutedModel({ providerID: 'subrouter', preset: 'default', system });
|
|
436
|
+
const modelId = adapters.anthropic.defaultModels[0];
|
|
437
|
+
expect(system[0]).toContain(`You are powered by the model named ${modelId}.`);
|
|
438
|
+
expect(system[0]).toContain(`The exact model ID is anthropic/${modelId}`);
|
|
439
|
+
expect(system[0]).not.toContain('subrouter/build');
|
|
440
|
+
});
|
|
441
|
+
test('system transform leaves other providers unchanged', async () => {
|
|
442
|
+
const original = 'You are powered by the model named claude-opus-4-6. The exact model ID is anthropic/claude-opus-4-6';
|
|
443
|
+
const system = [original];
|
|
444
|
+
await revealRoutedModel({ providerID: 'anthropic', preset: 'claude-opus-4-6', system });
|
|
445
|
+
expect(system[0]).toBe(original);
|
|
79
446
|
});
|
|
80
447
|
test('auth hook asks which subscription to add before authorizing', async () => {
|
|
81
448
|
const { provider, method } = await authMethod();
|
|
@@ -112,22 +479,22 @@ test('authorize dispatches to the chosen adapter and the callback pools the acco
|
|
|
112
479
|
{ type: 'oauth', email: 'pool@example.com', accountId: 'acct-9' },
|
|
113
480
|
]);
|
|
114
481
|
});
|
|
115
|
-
test('opencode
|
|
482
|
+
test('opencode go asks for a pasted key and stores it as an api account', async () => {
|
|
116
483
|
const { method } = await authMethod();
|
|
117
|
-
const result = await method.authorize({ provider: 'opencode' });
|
|
484
|
+
const result = await method.authorize({ provider: 'opencode-go' });
|
|
118
485
|
expect(result.method).toBe('code');
|
|
119
486
|
if (result.method !== 'code')
|
|
120
487
|
throw new Error('expected a pasted-key flow');
|
|
121
|
-
expect(await result.callback('
|
|
488
|
+
expect(await result.callback('go-key-1')).toMatchObject({ type: 'success', key: 'go-key-1' });
|
|
122
489
|
const accounts = await loadAccounts();
|
|
123
|
-
expect(accounts.providers
|
|
490
|
+
expect(accounts.providers['opencode-go']?.accounts).toMatchObject([{ type: 'api', key: 'go-key-1' }]);
|
|
124
491
|
});
|
|
125
492
|
test('a failed login reports failure instead of pooling a broken account', async () => {
|
|
126
493
|
const { method } = await authMethod();
|
|
127
|
-
const result = await method.authorize({ provider: 'opencode' });
|
|
494
|
+
const result = await method.authorize({ provider: 'opencode-go' });
|
|
128
495
|
if (result.method !== 'code')
|
|
129
496
|
throw new Error('expected a pasted-key flow');
|
|
130
497
|
expect(await result.callback(' ')).toEqual({ type: 'failed' });
|
|
131
498
|
const accounts = await loadAccounts();
|
|
132
|
-
expect(accounts.providers
|
|
499
|
+
expect(accounts.providers['opencode-go']).toBeUndefined();
|
|
133
500
|
});
|
package/dist/provider.d.ts
CHANGED
|
@@ -2,14 +2,35 @@
|
|
|
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;
|
|
10
23
|
model: {
|
|
11
24
|
providerID: string;
|
|
12
25
|
};
|
|
26
|
+
message: {
|
|
27
|
+
agent: string;
|
|
28
|
+
model: {
|
|
29
|
+
providerID: string;
|
|
30
|
+
modelID: string;
|
|
31
|
+
variant?: string;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
13
34
|
}, output: {
|
|
14
35
|
headers: Record<string, string>;
|
|
15
36
|
}): void;
|
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;AAWH,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;IACL,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAA;IAC7B,OAAO,EAAE;QACP,KAAK,EAAE,MAAM,CAAA;QACb,KAAK,EAAE;YAAE,UAAU,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAC;YAAC,OAAO,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KACjE,CAAA;CACF,EACD,MAAM,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,QAW5C"}
|
package/dist/provider.js
CHANGED
|
@@ -2,13 +2,35 @@
|
|
|
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 { OPENCODE_AGENT_HEADER, OPENCODE_VARIANT_HEADER, 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;
|
|
28
|
+
if (input.agent === input.message.agent) {
|
|
29
|
+
output.headers[OPENCODE_AGENT_HEADER] = input.message.agent;
|
|
30
|
+
if (input.message.model.variant) {
|
|
31
|
+
output.headers[OPENCODE_VARIANT_HEADER] = input.message.model.variant;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
12
34
|
if (input.agent === 'title')
|
|
13
35
|
output.headers[OPENAI_WEBSOCKET_TITLE_HEADER] = 'true';
|
|
14
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@subrouter/opencode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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.5.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@opencode-ai/plugin": "^1.18.23",
|