@subrouter/opencode 0.3.0 → 0.5.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 +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +164 -42
- package/dist/opencode-e2e.test.js +354 -15
- package/dist/plugin.test.js +436 -13
- package/dist/provider.d.ts +38 -11
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +42 -7
- package/package.json +2 -2
- package/src/index.ts +195 -54
- package/src/opencode-e2e.test.ts +397 -16
- package/src/plugin.test.ts +481 -21
- package/src/provider.ts +81 -9
package/dist/plugin.test.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import childProcess from 'node:child_process';
|
|
1
2
|
import { createServer } from 'node:http';
|
|
2
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, setLiveRoute, } from '@subrouter/cli';
|
|
7
10
|
import { subrouterAuthPlugin, subrouterPlugin } from "./index.js";
|
|
8
|
-
import { revealRoutedModel, rewritePoweredByModelLine } from "./provider.js";
|
|
11
|
+
import { appendApplyPatchConstraint, applyPatchApiId, applyPatchConstraint, revealRoutedModel, rewritePoweredByModelLine, shouldUseApplyPatch, } from "./provider.js";
|
|
12
|
+
const execFile = util.promisify(childProcess.execFile);
|
|
9
13
|
let home;
|
|
10
14
|
const openServers = [];
|
|
11
15
|
const pluginInput = {};
|
|
@@ -15,8 +19,6 @@ beforeEach(async () => {
|
|
|
15
19
|
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev();
|
|
16
20
|
});
|
|
17
21
|
afterEach(async () => {
|
|
18
|
-
setSubrouterLog(undefined);
|
|
19
|
-
delete process.env.SUBROUTER_HOME;
|
|
20
22
|
delete process.env.SUBROUTER_OPENAI_ISSUER_URL;
|
|
21
23
|
delete process.env.SUBROUTER_MODELS_DEV_URL;
|
|
22
24
|
for (const server of openServers.splice(0)) {
|
|
@@ -97,6 +99,110 @@ function authMethod() {
|
|
|
97
99
|
return { provider: hooks.auth.provider, method };
|
|
98
100
|
});
|
|
99
101
|
}
|
|
102
|
+
test('plugin load and config do not write stdout or stderr', async () => {
|
|
103
|
+
const script = "import('./src/index.ts').then(async ({ subrouterPlugin }) => { const hooks = await subrouterPlugin({}); await hooks.config?.({}) })";
|
|
104
|
+
const result = await execFile(process.execPath, ['--no-warnings', '--import', 'tsx', '--eval', script], {
|
|
105
|
+
cwd: process.cwd(),
|
|
106
|
+
env: process.env,
|
|
107
|
+
});
|
|
108
|
+
expect(result).toEqual({ stdout: '', stderr: '' });
|
|
109
|
+
});
|
|
110
|
+
test('provider log callback forwards only to client.app.log', async () => {
|
|
111
|
+
const received = Promise.withResolvers();
|
|
112
|
+
const server = createServer((req, res) => {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
req.on('data', (chunk) => {
|
|
115
|
+
chunks.push(chunk);
|
|
116
|
+
});
|
|
117
|
+
req.on('end', () => {
|
|
118
|
+
received.resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
119
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
120
|
+
res.end('{}');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
await new Promise((resolve) => {
|
|
124
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
125
|
+
});
|
|
126
|
+
openServers.push(server);
|
|
127
|
+
const address = server.address();
|
|
128
|
+
if (typeof address === 'string' || !address)
|
|
129
|
+
throw new Error('failed to bind log server');
|
|
130
|
+
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` });
|
|
131
|
+
const hooks = await subrouterPlugin({ ...pluginInput, client });
|
|
132
|
+
const config = {};
|
|
133
|
+
await hooks.config?.(config);
|
|
134
|
+
const log = config.provider?.subrouter?.options?.log;
|
|
135
|
+
expect(log).toBeTypeOf('function');
|
|
136
|
+
if (typeof log !== 'function')
|
|
137
|
+
throw new Error('expected provider log callback');
|
|
138
|
+
await log({
|
|
139
|
+
level: 'warn',
|
|
140
|
+
message: 'failover openai/gpt-5.5',
|
|
141
|
+
extra: { provider: 'openai', modelId: 'gpt-5.5' },
|
|
142
|
+
});
|
|
143
|
+
expect(await received.promise).toEqual({
|
|
144
|
+
service: 'subrouter',
|
|
145
|
+
level: 'warn',
|
|
146
|
+
message: 'failover openai/gpt-5.5',
|
|
147
|
+
extra: { provider: 'openai', modelId: 'gpt-5.5' },
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
test('cooldown fallback persists one ignored notice during the active run', async () => {
|
|
151
|
+
const requests = [];
|
|
152
|
+
const server = createServer((req, res) => {
|
|
153
|
+
const chunks = [];
|
|
154
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
155
|
+
req.on('end', () => {
|
|
156
|
+
requests.push({
|
|
157
|
+
path: req.url ?? '',
|
|
158
|
+
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
|
159
|
+
});
|
|
160
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
161
|
+
res.end('{}');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
165
|
+
openServers.push(server);
|
|
166
|
+
const address = server.address();
|
|
167
|
+
if (typeof address === 'string' || !address)
|
|
168
|
+
throw new Error('failed to bind notification server');
|
|
169
|
+
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` });
|
|
170
|
+
const hooks = await subrouterPlugin({ ...pluginInput, client, directory: '/tmp/project' });
|
|
171
|
+
const config = {};
|
|
172
|
+
await hooks.config?.(config);
|
|
173
|
+
const onCooldownFallback = config.provider?.subrouter?.options?.onCooldownFallback;
|
|
174
|
+
expect(onCooldownFallback).toBeTypeOf('function');
|
|
175
|
+
if (typeof onCooldownFallback !== 'function')
|
|
176
|
+
throw new Error('expected cooldown callback');
|
|
177
|
+
const notice = {
|
|
178
|
+
sessionID: 'session-1',
|
|
179
|
+
agent: 'build',
|
|
180
|
+
variant: 'high',
|
|
181
|
+
preset: 'work',
|
|
182
|
+
preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 250_000 },
|
|
183
|
+
active: { provider: 'openai', modelId: 'gpt-5.6-sol' },
|
|
184
|
+
};
|
|
185
|
+
await onCooldownFallback(notice);
|
|
186
|
+
await onCooldownFallback(notice);
|
|
187
|
+
expect(requests).toEqual([
|
|
188
|
+
{
|
|
189
|
+
path: '/session/session-1/message?directory=%2Ftmp%2Fproject',
|
|
190
|
+
body: {
|
|
191
|
+
noReply: true,
|
|
192
|
+
agent: 'build',
|
|
193
|
+
model: { providerID: 'subrouter', modelID: 'work' },
|
|
194
|
+
variant: 'high',
|
|
195
|
+
parts: [
|
|
196
|
+
{
|
|
197
|
+
type: 'text',
|
|
198
|
+
text: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.',
|
|
199
|
+
ignored: true,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
100
206
|
test('config hook registers the subrouter provider with preset models', async () => {
|
|
101
207
|
await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] });
|
|
102
208
|
const hooks = await subrouterPlugin(pluginInput);
|
|
@@ -114,7 +220,7 @@ test('config hook registers the subrouter provider with preset models', async ()
|
|
|
114
220
|
expect(provider.models.default.limit).toEqual({ context: 200_000, output: 64_000 });
|
|
115
221
|
expect(provider.models.work.limit).toEqual({ context: 200_000, output: 64_000 });
|
|
116
222
|
});
|
|
117
|
-
test('preset model names
|
|
223
|
+
test('preset model names stay stable when the routed candidate changes', async () => {
|
|
118
224
|
await addAccount({
|
|
119
225
|
provider: 'anthropic',
|
|
120
226
|
account: {
|
|
@@ -132,7 +238,7 @@ test('preset model names show the first live candidate', async () => {
|
|
|
132
238
|
const config = {};
|
|
133
239
|
await hooks.config?.(config);
|
|
134
240
|
expect(config.provider?.subrouter?.name).toBe(PROVIDER_DISPLAY_NAME);
|
|
135
|
-
expect(config.provider?.subrouter?.models?.work?.name).toBe('work
|
|
241
|
+
expect(config.provider?.subrouter?.models?.work?.name).toBe('work');
|
|
136
242
|
});
|
|
137
243
|
test('preset model limits follow the first live candidate', async () => {
|
|
138
244
|
const modelId = adapters.anthropic.defaultModels[0];
|
|
@@ -143,8 +249,8 @@ test('preset model limits follow the first live candidate', async () => {
|
|
|
143
249
|
models: {
|
|
144
250
|
[modelId]: {
|
|
145
251
|
id: modelId,
|
|
146
|
-
modalities: { output: ['text'] },
|
|
147
|
-
limit: { context: 1_000_000, output: 128_000 },
|
|
252
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
253
|
+
limit: { context: 1_000_000, input: 900_000, output: 128_000 },
|
|
148
254
|
},
|
|
149
255
|
},
|
|
150
256
|
},
|
|
@@ -167,18 +273,126 @@ test('preset model limits follow the first live candidate', async () => {
|
|
|
167
273
|
await hooks.config?.(config);
|
|
168
274
|
expect(config.provider?.subrouter?.models?.work?.limit).toEqual({
|
|
169
275
|
context: 1_000_000,
|
|
276
|
+
input: 900_000,
|
|
170
277
|
output: 128_000,
|
|
171
278
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
279
|
+
});
|
|
280
|
+
test('preset reasoning follows the first live candidate', async () => {
|
|
281
|
+
const modelId = adapters.openai.defaultModels[0];
|
|
282
|
+
if (!modelId)
|
|
283
|
+
throw new Error('openai adapter has no default model');
|
|
284
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
285
|
+
openai: {
|
|
286
|
+
models: {
|
|
287
|
+
[modelId]: {
|
|
288
|
+
id: modelId,
|
|
289
|
+
reasoning: true,
|
|
290
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
291
|
+
limit: { context: 200_000, output: 64_000 },
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
},
|
|
175
295
|
});
|
|
296
|
+
await addAccount({
|
|
297
|
+
provider: 'openai',
|
|
298
|
+
account: {
|
|
299
|
+
type: 'oauth',
|
|
300
|
+
refresh: 'refresh-1',
|
|
301
|
+
access: 'access-1',
|
|
302
|
+
expires: Date.now() + 60_000,
|
|
303
|
+
email: 'a@x.com',
|
|
304
|
+
addedAt: 1,
|
|
305
|
+
lastUsed: 1,
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
await savePreset({ name: 'work', models: [`openai/${modelId}`] });
|
|
309
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
310
|
+
const config = {};
|
|
311
|
+
await hooks.config?.(config);
|
|
312
|
+
expect(config.provider?.subrouter?.models?.work?.reasoning).toBe(true);
|
|
176
313
|
});
|
|
177
|
-
test('preset
|
|
314
|
+
test('preset variants follow the first live candidate', async () => {
|
|
315
|
+
const modelId = adapters.openai.defaultModels[0];
|
|
316
|
+
if (!modelId)
|
|
317
|
+
throw new Error('openai adapter has no default model');
|
|
318
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
319
|
+
openai: {
|
|
320
|
+
models: {
|
|
321
|
+
[modelId]: {
|
|
322
|
+
id: modelId,
|
|
323
|
+
reasoning: true,
|
|
324
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
325
|
+
reasoning_options: [{ type: 'effort', values: ['none', 'low', 'medium', 'high'] }],
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
await addAccount({
|
|
331
|
+
provider: 'openai',
|
|
332
|
+
account: {
|
|
333
|
+
type: 'oauth',
|
|
334
|
+
refresh: 'refresh-1',
|
|
335
|
+
access: 'access-1',
|
|
336
|
+
expires: Date.now() + 60_000,
|
|
337
|
+
email: 'a@x.com',
|
|
338
|
+
addedAt: 1,
|
|
339
|
+
lastUsed: 1,
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
await savePreset({ name: 'work', models: [`openai/${modelId}#high`] });
|
|
178
343
|
const hooks = await subrouterPlugin(pluginInput);
|
|
179
344
|
const config = {};
|
|
180
345
|
await hooks.config?.(config);
|
|
181
|
-
expect(config.provider?.subrouter?.models?.
|
|
346
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
347
|
+
variants: {
|
|
348
|
+
none: { reasoningEffort: 'none', reasoningSummary: 'auto' },
|
|
349
|
+
low: { reasoningEffort: 'low', reasoningSummary: 'auto' },
|
|
350
|
+
medium: { reasoningEffort: 'medium', reasoningSummary: 'auto' },
|
|
351
|
+
high: { reasoningEffort: 'high', reasoningSummary: 'auto' },
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
test('preset model input modalities are the union of usable candidates', async () => {
|
|
356
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
357
|
+
openai: {
|
|
358
|
+
models: {
|
|
359
|
+
'gpt-pdf': {
|
|
360
|
+
id: 'gpt-pdf',
|
|
361
|
+
attachment: true,
|
|
362
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
'kimi-for-coding': {
|
|
367
|
+
models: {
|
|
368
|
+
'kimi-image': {
|
|
369
|
+
id: 'kimi-image',
|
|
370
|
+
attachment: true,
|
|
371
|
+
modalities: { input: ['text', 'image'], output: ['text'] },
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
await addAccount({
|
|
377
|
+
provider: 'openai',
|
|
378
|
+
account: {
|
|
379
|
+
type: 'oauth',
|
|
380
|
+
access: 'access-1',
|
|
381
|
+
refresh: 'refresh-1',
|
|
382
|
+
expires: Date.now() + 60_000,
|
|
383
|
+
addedAt: 1,
|
|
384
|
+
lastUsed: 1,
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
await addAccount({
|
|
388
|
+
provider: 'kimi',
|
|
389
|
+
account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
|
|
390
|
+
});
|
|
391
|
+
await savePreset({ name: 'work', models: ['openai/gpt-pdf', 'kimi/kimi-image'] });
|
|
392
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
393
|
+
const config = {};
|
|
394
|
+
await hooks.config?.(config);
|
|
395
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
182
396
|
attachment: true,
|
|
183
397
|
modalities: {
|
|
184
398
|
input: ['text', 'image', 'pdf'],
|
|
@@ -186,6 +400,143 @@ test('preset models permit image and PDF attachments', async () => {
|
|
|
186
400
|
},
|
|
187
401
|
});
|
|
188
402
|
});
|
|
403
|
+
test('xAI presets do not advertise inline PDF support that its SDK cannot encode', async () => {
|
|
404
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
405
|
+
xai: {
|
|
406
|
+
models: {
|
|
407
|
+
'grok-pdf': {
|
|
408
|
+
id: 'grok-pdf',
|
|
409
|
+
attachment: true,
|
|
410
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
await addAccount({
|
|
416
|
+
provider: 'xai',
|
|
417
|
+
account: {
|
|
418
|
+
type: 'oauth',
|
|
419
|
+
access: 'access-1',
|
|
420
|
+
refresh: 'refresh-1',
|
|
421
|
+
expires: Date.now() + 60_000,
|
|
422
|
+
addedAt: 1,
|
|
423
|
+
lastUsed: 1,
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
await savePreset({ name: 'work', models: ['xai/grok-pdf'] });
|
|
427
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
428
|
+
const config = {};
|
|
429
|
+
await hooks.config?.(config);
|
|
430
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
431
|
+
attachment: true,
|
|
432
|
+
modalities: {
|
|
433
|
+
input: ['text', 'image'],
|
|
434
|
+
output: ['text'],
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
test('Anthropic-compatible coding plans do not advertise video input', async () => {
|
|
439
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
440
|
+
'kimi-for-coding': {
|
|
441
|
+
models: {
|
|
442
|
+
'kimi-video': {
|
|
443
|
+
id: 'kimi-video',
|
|
444
|
+
attachment: true,
|
|
445
|
+
modalities: { input: ['text', 'image', 'video'], output: ['text'] },
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
await addAccount({
|
|
451
|
+
provider: 'kimi',
|
|
452
|
+
account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
|
|
453
|
+
});
|
|
454
|
+
await savePreset({ name: 'work', models: ['kimi/kimi-video'] });
|
|
455
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
456
|
+
const config = {};
|
|
457
|
+
await hooks.config?.(config);
|
|
458
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
459
|
+
attachment: true,
|
|
460
|
+
modalities: {
|
|
461
|
+
input: ['text', 'image'],
|
|
462
|
+
output: ['text'],
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
test('OpenCode apply_patch matcher follows gpt- and skips gpt-4 and gpt-oss', () => {
|
|
467
|
+
expect(shouldUseApplyPatch('gpt-5.5')).toBe(true);
|
|
468
|
+
expect(shouldUseApplyPatch('gpt-5.4')).toBe(true);
|
|
469
|
+
expect(shouldUseApplyPatch('gpt-5.3-codex')).toBe(true);
|
|
470
|
+
expect(shouldUseApplyPatch('gpt-4.1')).toBe(false);
|
|
471
|
+
expect(shouldUseApplyPatch('gpt-oss-120b')).toBe(false);
|
|
472
|
+
expect(shouldUseApplyPatch('claude-opus-4-6')).toBe(false);
|
|
473
|
+
});
|
|
474
|
+
test('apply_patch api ids stay unique across presets that share a model', () => {
|
|
475
|
+
const taken = new Set();
|
|
476
|
+
const first = applyPatchApiId({ preset: 'codex-a', modelId: 'gpt-5.5', taken });
|
|
477
|
+
taken.add(first);
|
|
478
|
+
const second = applyPatchApiId({ preset: 'codex-b', modelId: 'gpt-5.5', taken });
|
|
479
|
+
expect(first).toBe('gpt-5.5');
|
|
480
|
+
expect(second).toBe('gpt-5.5:codex-b');
|
|
481
|
+
expect(shouldUseApplyPatch(second)).toBe(true);
|
|
482
|
+
});
|
|
483
|
+
test('appendApplyPatchConstraint adds the OpenCode GPT line once', () => {
|
|
484
|
+
const system = ['You are powered by the model named gpt-5.5.'];
|
|
485
|
+
appendApplyPatchConstraint({ system, modelId: 'gpt-5.5' });
|
|
486
|
+
appendApplyPatchConstraint({ system, modelId: 'gpt-5.5' });
|
|
487
|
+
expect(system).toEqual(['You are powered by the model named gpt-5.5.', applyPatchConstraint('gpt-5.5')]);
|
|
488
|
+
});
|
|
489
|
+
test('appendApplyPatchConstraint skips non-GPT models', () => {
|
|
490
|
+
const system = ['You are powered by the model named claude-opus-4-6.'];
|
|
491
|
+
appendApplyPatchConstraint({ system, modelId: 'claude-opus-4-6' });
|
|
492
|
+
expect(system).toEqual(['You are powered by the model named claude-opus-4-6.']);
|
|
493
|
+
});
|
|
494
|
+
test('openai live candidate spoofs a gpt api id so OpenCode prefers apply_patch', async () => {
|
|
495
|
+
await addAccount({
|
|
496
|
+
provider: 'anthropic',
|
|
497
|
+
account: {
|
|
498
|
+
type: 'oauth',
|
|
499
|
+
refresh: 'refresh-1',
|
|
500
|
+
access: 'access-1',
|
|
501
|
+
expires: Date.now() + 60_000,
|
|
502
|
+
email: 'a@x.com',
|
|
503
|
+
addedAt: 1,
|
|
504
|
+
lastUsed: 1,
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
await addAccount({
|
|
508
|
+
provider: 'openai',
|
|
509
|
+
account: {
|
|
510
|
+
type: 'oauth',
|
|
511
|
+
refresh: 'refresh-o',
|
|
512
|
+
access: 'access-o',
|
|
513
|
+
expires: Date.now() + 60_000,
|
|
514
|
+
email: 'o@x.com',
|
|
515
|
+
addedAt: 1,
|
|
516
|
+
lastUsed: 1,
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
await savePreset({ name: 'default', models: ['anthropic/claude-opus-4-6'] });
|
|
520
|
+
await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] });
|
|
521
|
+
await savePreset({ name: 'codex-b', models: ['openai/gpt-5.5'] });
|
|
522
|
+
const hooks = await subrouterPlugin(pluginInput);
|
|
523
|
+
const config = {};
|
|
524
|
+
await hooks.config?.(config);
|
|
525
|
+
expect(config.provider?.subrouter?.models?.work).toBeUndefined();
|
|
526
|
+
expect(config.provider?.subrouter?.models?.['openai-only']).toMatchObject({
|
|
527
|
+
name: 'openai-only',
|
|
528
|
+
id: 'gpt-5.5',
|
|
529
|
+
});
|
|
530
|
+
expect(config.provider?.subrouter?.models?.['codex-b']).toMatchObject({
|
|
531
|
+
name: 'codex-b',
|
|
532
|
+
id: 'gpt-5.5:codex-b',
|
|
533
|
+
});
|
|
534
|
+
expect(config.provider?.subrouter?.models?.default?.id).toBeUndefined();
|
|
535
|
+
expect(config.provider?.subrouter?.options?.presetByApiId).toEqual({
|
|
536
|
+
'gpt-5.5': 'openai-only',
|
|
537
|
+
'gpt-5.5:codex-b': 'codex-b',
|
|
538
|
+
});
|
|
539
|
+
});
|
|
189
540
|
test('rewrites the OpenCode powered-by line to the routed candidate', () => {
|
|
190
541
|
const system = [
|
|
191
542
|
'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
|
|
@@ -220,6 +571,78 @@ test('system transform rewrites the powered-by line to the live candidate', asyn
|
|
|
220
571
|
expect(system[0]).toContain(`The exact model ID is anthropic/${modelId}`);
|
|
221
572
|
expect(system[0]).not.toContain('subrouter/build');
|
|
222
573
|
});
|
|
574
|
+
test('system transform uses the in-flight session route, not the first free preset model', async () => {
|
|
575
|
+
await addAccount({
|
|
576
|
+
provider: 'anthropic',
|
|
577
|
+
account: {
|
|
578
|
+
type: 'oauth',
|
|
579
|
+
refresh: 'refresh-1',
|
|
580
|
+
access: 'access-1',
|
|
581
|
+
expires: Date.now() + 60_000,
|
|
582
|
+
email: 'a@x.com',
|
|
583
|
+
addedAt: 1,
|
|
584
|
+
lastUsed: 1,
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
await addAccount({
|
|
588
|
+
provider: 'opencode-go',
|
|
589
|
+
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
590
|
+
});
|
|
591
|
+
await savePreset({
|
|
592
|
+
name: 'default',
|
|
593
|
+
models: ['anthropic/claude-fake', 'opencode-go/fake-model'],
|
|
594
|
+
});
|
|
595
|
+
await setLiveRoute({
|
|
596
|
+
sessionID: 'ses_1',
|
|
597
|
+
preset: 'default',
|
|
598
|
+
provider: 'opencode-go',
|
|
599
|
+
modelId: 'fake-model',
|
|
600
|
+
});
|
|
601
|
+
const system = [
|
|
602
|
+
'You are powered by the model named default. The exact model ID is subrouter/default',
|
|
603
|
+
];
|
|
604
|
+
await revealRoutedModel({
|
|
605
|
+
providerID: 'subrouter',
|
|
606
|
+
preset: 'default',
|
|
607
|
+
sessionID: 'ses_1',
|
|
608
|
+
system,
|
|
609
|
+
});
|
|
610
|
+
expect(system[0]).toContain('You are powered by the model named fake-model.');
|
|
611
|
+
expect(system[0]).toContain('The exact model ID is opencode-go/fake-model');
|
|
612
|
+
expect(system[0]).not.toContain('claude-fake');
|
|
613
|
+
});
|
|
614
|
+
test('system transform appends the apply_patch constraint for a live GPT route', async () => {
|
|
615
|
+
await addAccount({
|
|
616
|
+
provider: 'openai',
|
|
617
|
+
account: {
|
|
618
|
+
type: 'oauth',
|
|
619
|
+
refresh: 'refresh-o',
|
|
620
|
+
access: 'access-o',
|
|
621
|
+
expires: Date.now() + 60_000,
|
|
622
|
+
email: 'o@x.com',
|
|
623
|
+
addedAt: 1,
|
|
624
|
+
lastUsed: 1,
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] });
|
|
628
|
+
await setLiveRoute({
|
|
629
|
+
sessionID: 'ses_gpt',
|
|
630
|
+
preset: 'openai-only',
|
|
631
|
+
provider: 'openai',
|
|
632
|
+
modelId: 'gpt-5.5',
|
|
633
|
+
});
|
|
634
|
+
const system = [
|
|
635
|
+
'You are powered by the model named openai-only. The exact model ID is subrouter/openai-only',
|
|
636
|
+
];
|
|
637
|
+
await revealRoutedModel({
|
|
638
|
+
providerID: 'subrouter',
|
|
639
|
+
preset: 'openai-only',
|
|
640
|
+
sessionID: 'ses_gpt',
|
|
641
|
+
system,
|
|
642
|
+
});
|
|
643
|
+
expect(system[0]).toContain('You are powered by the model named gpt-5.5.');
|
|
644
|
+
expect(system).toContain(applyPatchConstraint('gpt-5.5'));
|
|
645
|
+
});
|
|
223
646
|
test('system transform leaves other providers unchanged', async () => {
|
|
224
647
|
const original = 'You are powered by the model named claude-opus-4-6. The exact model ID is anthropic/claude-opus-4-6';
|
|
225
648
|
const system = [original];
|
package/dist/provider.d.ts
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
/**
|
|
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
|
-
* then `sdk.languageModel(modelId)
|
|
5
|
-
*
|
|
4
|
+
* then `sdk.languageModel(modelId)`. For GPT presets that id is a spoofed
|
|
5
|
+
* `gpt-*` api id so OpenCode prefers apply_patch; createSubrouter maps it
|
|
6
|
+
* back to the preset. Also rewrites OpenCode's powered-by identity to the
|
|
7
|
+
* live routed model and appends the GPT apply_patch constraint when needed.
|
|
6
8
|
*/
|
|
7
9
|
export { createSubrouter } from '@subrouter/cli';
|
|
10
|
+
export declare function shouldUseApplyPatch(modelId: string): boolean;
|
|
11
|
+
export declare function applyPatchApiId({ preset, modelId, taken, }: {
|
|
12
|
+
preset: string;
|
|
13
|
+
modelId: string;
|
|
14
|
+
taken: Set<string>;
|
|
15
|
+
}): string;
|
|
16
|
+
export declare function applyPatchConstraint(modelId: string): "Always use apply_patch for manual code edits. Do not use cat or any other commands when creating or editing files. Formatting commands or bulk edits don't need to be done with apply_patch." | "Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).";
|
|
17
|
+
export declare function appendApplyPatchConstraint({ system, modelId, }: {
|
|
18
|
+
system: string[];
|
|
19
|
+
modelId: string;
|
|
20
|
+
}): void;
|
|
8
21
|
export declare function rewritePoweredByModelLine({ system, candidate, }: {
|
|
9
22
|
system: string[];
|
|
10
23
|
candidate: {
|
|
@@ -12,18 +25,32 @@ export declare function rewritePoweredByModelLine({ system, candidate, }: {
|
|
|
12
25
|
modelId: string;
|
|
13
26
|
};
|
|
14
27
|
}): void;
|
|
15
|
-
export declare function revealRoutedModel({ providerID, preset, system, }: {
|
|
28
|
+
export declare function revealRoutedModel({ providerID, preset, sessionID, system, }: {
|
|
16
29
|
providerID: string;
|
|
17
30
|
preset: string;
|
|
31
|
+
sessionID?: string;
|
|
18
32
|
system: string[];
|
|
19
33
|
}): Promise<void>;
|
|
20
|
-
export declare function addSubrouterHeaders(input: {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
export declare function addSubrouterHeaders({ input, output, affinityKey, }: {
|
|
35
|
+
input: {
|
|
36
|
+
sessionID: string;
|
|
37
|
+
agent: string;
|
|
38
|
+
model: {
|
|
39
|
+
providerID: string;
|
|
40
|
+
};
|
|
41
|
+
message: {
|
|
42
|
+
id: string;
|
|
43
|
+
agent: string;
|
|
44
|
+
model: {
|
|
45
|
+
providerID: string;
|
|
46
|
+
modelID: string;
|
|
47
|
+
variant?: string;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
25
50
|
};
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
51
|
+
output: {
|
|
52
|
+
headers: Record<string, string>;
|
|
53
|
+
};
|
|
54
|
+
affinityKey?: string;
|
|
55
|
+
}): string | null;
|
|
29
56
|
//# sourceMappingURL=provider.d.ts.map
|
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;;;;;;;GAOG;AAYH,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAKhD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,WAElD;AAED,wBAAgB,eAAe,CAAC,EAC9B,MAAM,EACN,OAAO,EACP,KAAK,GACN,EAAE;IACD,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CACnB,UAGA;AAQD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,+iBAGnD;AAED,wBAAgB,0BAA0B,CAAC,EACzC,MAAM,EACN,OAAO,GACR,EAAE;IACD,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB,QAKA;AAED,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,SAAS,EACT,MAAM,GACP,EAAE;IACD,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB,iBAMA;AAED,wBAAgB,mBAAmB,CAAC,EAClC,KAAK,EACL,MAAM,EACN,WAA8B,GAC/B,EAAE;IACD,KAAK,EAAE;QACL,SAAS,EAAE,MAAM,CAAA;QACjB,KAAK,EAAE,MAAM,CAAA;QACb,KAAK,EAAE;YAAE,UAAU,EAAE,MAAM,CAAA;SAAE,CAAA;QAC7B,OAAO,EAAE;YACP,EAAE,EAAE,MAAM,CAAA;YACV,KAAK,EAAE,MAAM,CAAA;YACb,KAAK,EAAE;gBAAE,UAAU,EAAE,MAAM,CAAC;gBAAC,OAAO,EAAE,MAAM,CAAC;gBAAC,OAAO,CAAC,EAAE,MAAM,CAAA;aAAE,CAAA;SACjE,CAAA;KACF,CAAA;IACD,MAAM,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAA;IAC3C,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,iBAYA"}
|
package/dist/provider.js
CHANGED
|
@@ -1,30 +1,65 @@
|
|
|
1
1
|
/**
|
|
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
|
-
* then `sdk.languageModel(modelId)
|
|
5
|
-
*
|
|
4
|
+
* then `sdk.languageModel(modelId)`. For GPT presets that id is a spoofed
|
|
5
|
+
* `gpt-*` api id so OpenCode prefers apply_patch; createSubrouter maps it
|
|
6
|
+
* back to the preset. Also rewrites OpenCode's powered-by identity to the
|
|
7
|
+
* live routed model and appends the GPT apply_patch constraint when needed.
|
|
6
8
|
*/
|
|
7
|
-
import { OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, PROVIDER_ID,
|
|
9
|
+
import { OPENCODE_AGENT_HEADER, OPENCODE_VARIANT_HEADER, OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, PROVIDER_ID, resolveLiveModel, ROUTE_AFFINITY_HEADER, } from '@subrouter/cli';
|
|
8
10
|
export { createSubrouter } from '@subrouter/cli';
|
|
9
11
|
const POWERED_BY_MODEL = /You are powered by the model named [^\n]+/;
|
|
12
|
+
// OpenCode v1 registry.ts and v2 patch.ts: GPT (not gpt-4 / gpt-oss) uses apply_patch.
|
|
13
|
+
export function shouldUseApplyPatch(modelId) {
|
|
14
|
+
return modelId.includes('gpt-') && !modelId.includes('oss') && !modelId.includes('gpt-4');
|
|
15
|
+
}
|
|
16
|
+
export function applyPatchApiId({ preset, modelId, taken, }) {
|
|
17
|
+
if (!taken.has(modelId))
|
|
18
|
+
return modelId;
|
|
19
|
+
return `${modelId}:${preset}`;
|
|
20
|
+
}
|
|
21
|
+
const APPLY_PATCH_GPT = 'Always use apply_patch for manual code edits. Do not use cat or any other commands when creating or editing files. Formatting commands or bulk edits don\'t need to be done with apply_patch.';
|
|
22
|
+
const APPLY_PATCH_CODEX = 'Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).';
|
|
23
|
+
export function applyPatchConstraint(modelId) {
|
|
24
|
+
if (modelId.includes('codex'))
|
|
25
|
+
return APPLY_PATCH_CODEX;
|
|
26
|
+
return APPLY_PATCH_GPT;
|
|
27
|
+
}
|
|
28
|
+
export function appendApplyPatchConstraint({ system, modelId, }) {
|
|
29
|
+
if (!shouldUseApplyPatch(modelId))
|
|
30
|
+
return;
|
|
31
|
+
const constraint = applyPatchConstraint(modelId);
|
|
32
|
+
if (system.some((line) => line.includes('use apply_patch')))
|
|
33
|
+
return;
|
|
34
|
+
system.push(constraint);
|
|
35
|
+
}
|
|
10
36
|
export function rewritePoweredByModelLine({ system, candidate, }) {
|
|
11
37
|
const line = `You are powered by the model named ${candidate.modelId}. The exact model ID is ${candidate.provider}/${candidate.modelId}`;
|
|
12
38
|
for (let i = 0; i < system.length; i++) {
|
|
13
39
|
system[i] = system[i].replace(POWERED_BY_MODEL, line);
|
|
14
40
|
}
|
|
15
41
|
}
|
|
16
|
-
export async function revealRoutedModel({ providerID, preset, system, }) {
|
|
42
|
+
export async function revealRoutedModel({ providerID, preset, sessionID, system, }) {
|
|
17
43
|
if (providerID !== PROVIDER_ID)
|
|
18
44
|
return;
|
|
19
|
-
const candidate = await
|
|
45
|
+
const candidate = await resolveLiveModel({ preset, sessionID });
|
|
20
46
|
if (!candidate)
|
|
21
47
|
return;
|
|
22
48
|
rewritePoweredByModelLine({ system, candidate });
|
|
49
|
+
appendApplyPatchConstraint({ system, modelId: candidate.modelId });
|
|
23
50
|
}
|
|
24
|
-
export function addSubrouterHeaders(input, output) {
|
|
51
|
+
export function addSubrouterHeaders({ input, output, affinityKey = input.message.id, }) {
|
|
25
52
|
if (input.model.providerID !== PROVIDER_ID)
|
|
26
|
-
return;
|
|
53
|
+
return null;
|
|
27
54
|
output.headers[OPENAI_WEBSOCKET_SESSION_HEADER] = input.sessionID;
|
|
55
|
+
if (input.agent === input.message.agent) {
|
|
56
|
+
output.headers[ROUTE_AFFINITY_HEADER] = affinityKey;
|
|
57
|
+
output.headers[OPENCODE_AGENT_HEADER] = input.message.agent;
|
|
58
|
+
if (input.message.model.variant) {
|
|
59
|
+
output.headers[OPENCODE_VARIANT_HEADER] = input.message.model.variant;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
28
62
|
if (input.agent === 'title')
|
|
29
63
|
output.headers[OPENAI_WEBSOCKET_TITLE_HEADER] = 'true';
|
|
64
|
+
return output.headers[ROUTE_AFFINITY_HEADER] ?? null;
|
|
30
65
|
}
|