@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/src/plugin.test.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import childProcess from 'node:child_process'
|
|
1
2
|
import { createServer, type Server } 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
8
|
import type { Config, PluginInput } from '@opencode-ai/plugin'
|
|
9
|
+
import { createOpencodeClient } from '@opencode-ai/sdk'
|
|
7
10
|
import {
|
|
8
11
|
addAccount,
|
|
9
12
|
adapters,
|
|
@@ -11,11 +14,19 @@ import {
|
|
|
11
14
|
PROVIDER_DISPLAY_NAME,
|
|
12
15
|
PROVIDER_IDS,
|
|
13
16
|
savePreset,
|
|
14
|
-
|
|
17
|
+
setLiveRoute,
|
|
15
18
|
} from '@subrouter/cli'
|
|
16
19
|
import { subrouterAuthPlugin, subrouterPlugin } from './index.ts'
|
|
17
|
-
import {
|
|
18
|
-
|
|
20
|
+
import {
|
|
21
|
+
appendApplyPatchConstraint,
|
|
22
|
+
applyPatchApiId,
|
|
23
|
+
applyPatchConstraint,
|
|
24
|
+
revealRoutedModel,
|
|
25
|
+
rewritePoweredByModelLine,
|
|
26
|
+
shouldUseApplyPatch,
|
|
27
|
+
} from './provider.ts'
|
|
28
|
+
|
|
29
|
+
const execFile = util.promisify(childProcess.execFile)
|
|
19
30
|
let home: string
|
|
20
31
|
const openServers: Server[] = []
|
|
21
32
|
const pluginInput = {} as PluginInput
|
|
@@ -27,8 +38,6 @@ beforeEach(async () => {
|
|
|
27
38
|
})
|
|
28
39
|
|
|
29
40
|
afterEach(async () => {
|
|
30
|
-
setSubrouterLog(undefined)
|
|
31
|
-
delete process.env.SUBROUTER_HOME
|
|
32
41
|
delete process.env.SUBROUTER_OPENAI_ISSUER_URL
|
|
33
42
|
delete process.env.SUBROUTER_MODELS_DEV_URL
|
|
34
43
|
for (const server of openServers.splice(0)) {
|
|
@@ -49,13 +58,18 @@ async function startFakeModelsDev(
|
|
|
49
58
|
string,
|
|
50
59
|
{
|
|
51
60
|
id: string
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
attachment?: boolean
|
|
62
|
+
reasoning?: boolean
|
|
63
|
+
temperature?: boolean
|
|
64
|
+
tool_call?: boolean
|
|
65
|
+
modalities?: { input?: string[]; output?: string[] }
|
|
66
|
+
limit?: { context?: number; output?: number; input?: number }
|
|
67
|
+
reasoning_options?: Array<{ type: string; values?: Array<string | null> }>
|
|
68
|
+
}
|
|
69
|
+
>
|
|
70
|
+
}
|
|
71
|
+
> = {},
|
|
72
|
+
) {
|
|
59
73
|
const payload = {
|
|
60
74
|
anthropic: { models: {} },
|
|
61
75
|
openai: { models: {} },
|
|
@@ -126,6 +140,111 @@ function authMethod() {
|
|
|
126
140
|
})
|
|
127
141
|
}
|
|
128
142
|
|
|
143
|
+
test('plugin load and config do not write stdout or stderr', async () => {
|
|
144
|
+
const script = "import('./src/index.ts').then(async ({ subrouterPlugin }) => { const hooks = await subrouterPlugin({}); await hooks.config?.({}) })"
|
|
145
|
+
const result = await execFile(process.execPath, ['--no-warnings', '--import', 'tsx', '--eval', script], {
|
|
146
|
+
cwd: process.cwd(),
|
|
147
|
+
env: process.env,
|
|
148
|
+
})
|
|
149
|
+
expect(result).toEqual({ stdout: '', stderr: '' })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('provider log callback forwards only to client.app.log', async () => {
|
|
153
|
+
const received = Promise.withResolvers<object>()
|
|
154
|
+
const server = createServer((req, res) => {
|
|
155
|
+
const chunks: Buffer[] = []
|
|
156
|
+
req.on('data', (chunk: Buffer) => {
|
|
157
|
+
chunks.push(chunk)
|
|
158
|
+
})
|
|
159
|
+
req.on('end', () => {
|
|
160
|
+
received.resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as object)
|
|
161
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
162
|
+
res.end('{}')
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
await new Promise<void>((resolve) => {
|
|
166
|
+
server.listen(0, '127.0.0.1', resolve)
|
|
167
|
+
})
|
|
168
|
+
openServers.push(server)
|
|
169
|
+
const address = server.address()
|
|
170
|
+
if (typeof address === 'string' || !address) throw new Error('failed to bind log server')
|
|
171
|
+
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` })
|
|
172
|
+
const hooks = await subrouterPlugin({ ...pluginInput, client })
|
|
173
|
+
const config: Config = {}
|
|
174
|
+
await hooks.config?.(config)
|
|
175
|
+
const log = config.provider?.subrouter?.options?.log
|
|
176
|
+
expect(log).toBeTypeOf('function')
|
|
177
|
+
if (typeof log !== 'function') throw new Error('expected provider log callback')
|
|
178
|
+
await log({
|
|
179
|
+
level: 'warn',
|
|
180
|
+
message: 'failover openai/gpt-5.5',
|
|
181
|
+
extra: { provider: 'openai', modelId: 'gpt-5.5' },
|
|
182
|
+
})
|
|
183
|
+
expect(await received.promise).toEqual({
|
|
184
|
+
service: 'subrouter',
|
|
185
|
+
level: 'warn',
|
|
186
|
+
message: 'failover openai/gpt-5.5',
|
|
187
|
+
extra: { provider: 'openai', modelId: 'gpt-5.5' },
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('cooldown fallback persists one ignored notice during the active run', async () => {
|
|
192
|
+
const requests: Array<{ path: string; body: Record<string, unknown> }> = []
|
|
193
|
+
const server = createServer((req, res) => {
|
|
194
|
+
const chunks: Buffer[] = []
|
|
195
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
196
|
+
req.on('end', () => {
|
|
197
|
+
requests.push({
|
|
198
|
+
path: req.url ?? '',
|
|
199
|
+
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
|
200
|
+
})
|
|
201
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
202
|
+
res.end('{}')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
206
|
+
openServers.push(server)
|
|
207
|
+
const address = server.address()
|
|
208
|
+
if (typeof address === 'string' || !address) throw new Error('failed to bind notification server')
|
|
209
|
+
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` })
|
|
210
|
+
const hooks = await subrouterPlugin({ ...pluginInput, client, directory: '/tmp/project' })
|
|
211
|
+
const config: Config = {}
|
|
212
|
+
await hooks.config?.(config)
|
|
213
|
+
const onCooldownFallback = config.provider?.subrouter?.options?.onCooldownFallback
|
|
214
|
+
expect(onCooldownFallback).toBeTypeOf('function')
|
|
215
|
+
if (typeof onCooldownFallback !== 'function') throw new Error('expected cooldown callback')
|
|
216
|
+
const notice = {
|
|
217
|
+
sessionID: 'session-1',
|
|
218
|
+
agent: 'build',
|
|
219
|
+
variant: 'high',
|
|
220
|
+
preset: 'work',
|
|
221
|
+
preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 250_000 },
|
|
222
|
+
active: { provider: 'openai', modelId: 'gpt-5.6-sol' },
|
|
223
|
+
} as const
|
|
224
|
+
|
|
225
|
+
await onCooldownFallback(notice)
|
|
226
|
+
await onCooldownFallback(notice)
|
|
227
|
+
|
|
228
|
+
expect(requests).toEqual([
|
|
229
|
+
{
|
|
230
|
+
path: '/session/session-1/message?directory=%2Ftmp%2Fproject',
|
|
231
|
+
body: {
|
|
232
|
+
noReply: true,
|
|
233
|
+
agent: 'build',
|
|
234
|
+
model: { providerID: 'subrouter', modelID: 'work' },
|
|
235
|
+
variant: 'high',
|
|
236
|
+
parts: [
|
|
237
|
+
{
|
|
238
|
+
type: 'text',
|
|
239
|
+
text: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.',
|
|
240
|
+
ignored: true,
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
])
|
|
246
|
+
})
|
|
247
|
+
|
|
129
248
|
test('config hook registers the subrouter provider with preset models', async () => {
|
|
130
249
|
await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] })
|
|
131
250
|
|
|
@@ -146,7 +265,7 @@ test('config hook registers the subrouter provider with preset models', async ()
|
|
|
146
265
|
expect(provider.models.work.limit).toEqual({ context: 200_000, output: 64_000 })
|
|
147
266
|
})
|
|
148
267
|
|
|
149
|
-
test('preset model names
|
|
268
|
+
test('preset model names stay stable when the routed candidate changes', async () => {
|
|
150
269
|
await addAccount({
|
|
151
270
|
provider: 'anthropic',
|
|
152
271
|
account: {
|
|
@@ -166,7 +285,7 @@ test('preset model names show the first live candidate', async () => {
|
|
|
166
285
|
await hooks.config?.(config)
|
|
167
286
|
|
|
168
287
|
expect(config.provider?.subrouter?.name).toBe(PROVIDER_DISPLAY_NAME)
|
|
169
|
-
expect(config.provider?.subrouter?.models?.work?.name).toBe('work
|
|
288
|
+
expect(config.provider?.subrouter?.models?.work?.name).toBe('work')
|
|
170
289
|
})
|
|
171
290
|
|
|
172
291
|
test('preset model limits follow the first live candidate', async () => {
|
|
@@ -177,8 +296,8 @@ test('preset model limits follow the first live candidate', async () => {
|
|
|
177
296
|
models: {
|
|
178
297
|
[modelId]: {
|
|
179
298
|
id: modelId,
|
|
180
|
-
modalities: { output: ['text'] },
|
|
181
|
-
limit: { context: 1_000_000, output: 128_000 },
|
|
299
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
300
|
+
limit: { context: 1_000_000, input: 900_000, output: 128_000 },
|
|
182
301
|
},
|
|
183
302
|
},
|
|
184
303
|
},
|
|
@@ -203,20 +322,133 @@ test('preset model limits follow the first live candidate', async () => {
|
|
|
203
322
|
|
|
204
323
|
expect(config.provider?.subrouter?.models?.work?.limit).toEqual({
|
|
205
324
|
context: 1_000_000,
|
|
325
|
+
input: 900_000,
|
|
206
326
|
output: 128_000,
|
|
207
327
|
})
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('preset reasoning follows the first live candidate', async () => {
|
|
331
|
+
const modelId = adapters.openai.defaultModels[0]
|
|
332
|
+
if (!modelId) throw new Error('openai adapter has no default model')
|
|
333
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
334
|
+
openai: {
|
|
335
|
+
models: {
|
|
336
|
+
[modelId]: {
|
|
337
|
+
id: modelId,
|
|
338
|
+
reasoning: true,
|
|
339
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
340
|
+
limit: { context: 200_000, output: 64_000 },
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
})
|
|
345
|
+
await addAccount({
|
|
346
|
+
provider: 'openai',
|
|
347
|
+
account: {
|
|
348
|
+
type: 'oauth',
|
|
349
|
+
refresh: 'refresh-1',
|
|
350
|
+
access: 'access-1',
|
|
351
|
+
expires: Date.now() + 60_000,
|
|
352
|
+
email: 'a@x.com',
|
|
353
|
+
addedAt: 1,
|
|
354
|
+
lastUsed: 1,
|
|
355
|
+
},
|
|
356
|
+
})
|
|
357
|
+
await savePreset({ name: 'work', models: [`openai/${modelId}`] })
|
|
358
|
+
|
|
359
|
+
const hooks = await subrouterPlugin(pluginInput)
|
|
360
|
+
const config: Config = {}
|
|
361
|
+
await hooks.config?.(config)
|
|
362
|
+
|
|
363
|
+
expect(config.provider?.subrouter?.models?.work?.reasoning).toBe(true)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('preset variants follow the first live candidate', async () => {
|
|
367
|
+
const modelId = adapters.openai.defaultModels[0]
|
|
368
|
+
if (!modelId) throw new Error('openai adapter has no default model')
|
|
369
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
370
|
+
openai: {
|
|
371
|
+
models: {
|
|
372
|
+
[modelId]: {
|
|
373
|
+
id: modelId,
|
|
374
|
+
reasoning: true,
|
|
375
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
376
|
+
reasoning_options: [{ type: 'effort', values: ['none', 'low', 'medium', 'high'] }],
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
})
|
|
381
|
+
await addAccount({
|
|
382
|
+
provider: 'openai',
|
|
383
|
+
account: {
|
|
384
|
+
type: 'oauth',
|
|
385
|
+
refresh: 'refresh-1',
|
|
386
|
+
access: 'access-1',
|
|
387
|
+
expires: Date.now() + 60_000,
|
|
388
|
+
email: 'a@x.com',
|
|
389
|
+
addedAt: 1,
|
|
390
|
+
lastUsed: 1,
|
|
391
|
+
},
|
|
392
|
+
})
|
|
393
|
+
await savePreset({ name: 'work', models: [`openai/${modelId}#high`] })
|
|
394
|
+
|
|
395
|
+
const hooks = await subrouterPlugin(pluginInput)
|
|
396
|
+
const config: Config = {}
|
|
397
|
+
await hooks.config?.(config)
|
|
398
|
+
|
|
399
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
400
|
+
variants: {
|
|
401
|
+
none: { reasoningEffort: 'none', reasoningSummary: 'auto' },
|
|
402
|
+
low: { reasoningEffort: 'low', reasoningSummary: 'auto' },
|
|
403
|
+
medium: { reasoningEffort: 'medium', reasoningSummary: 'auto' },
|
|
404
|
+
high: { reasoningEffort: 'high', reasoningSummary: 'auto' },
|
|
405
|
+
},
|
|
211
406
|
})
|
|
212
407
|
})
|
|
213
408
|
|
|
214
|
-
test('preset
|
|
409
|
+
test('preset model input modalities are the union of usable candidates', async () => {
|
|
410
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
411
|
+
openai: {
|
|
412
|
+
models: {
|
|
413
|
+
'gpt-pdf': {
|
|
414
|
+
id: 'gpt-pdf',
|
|
415
|
+
attachment: true,
|
|
416
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
'kimi-for-coding': {
|
|
421
|
+
models: {
|
|
422
|
+
'kimi-image': {
|
|
423
|
+
id: 'kimi-image',
|
|
424
|
+
attachment: true,
|
|
425
|
+
modalities: { input: ['text', 'image'], output: ['text'] },
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
})
|
|
430
|
+
await addAccount({
|
|
431
|
+
provider: 'openai',
|
|
432
|
+
account: {
|
|
433
|
+
type: 'oauth',
|
|
434
|
+
access: 'access-1',
|
|
435
|
+
refresh: 'refresh-1',
|
|
436
|
+
expires: Date.now() + 60_000,
|
|
437
|
+
addedAt: 1,
|
|
438
|
+
lastUsed: 1,
|
|
439
|
+
},
|
|
440
|
+
})
|
|
441
|
+
await addAccount({
|
|
442
|
+
provider: 'kimi',
|
|
443
|
+
account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
|
|
444
|
+
})
|
|
445
|
+
await savePreset({ name: 'work', models: ['openai/gpt-pdf', 'kimi/kimi-image'] })
|
|
446
|
+
|
|
215
447
|
const hooks = await subrouterPlugin(pluginInput)
|
|
216
448
|
const config: Config = {}
|
|
217
449
|
await hooks.config?.(config)
|
|
218
450
|
|
|
219
|
-
expect(config.provider?.subrouter?.models?.
|
|
451
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
220
452
|
attachment: true,
|
|
221
453
|
modalities: {
|
|
222
454
|
input: ['text', 'image', 'pdf'],
|
|
@@ -225,6 +457,156 @@ test('preset models permit image and PDF attachments', async () => {
|
|
|
225
457
|
})
|
|
226
458
|
})
|
|
227
459
|
|
|
460
|
+
test('xAI presets do not advertise inline PDF support that its SDK cannot encode', async () => {
|
|
461
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
462
|
+
xai: {
|
|
463
|
+
models: {
|
|
464
|
+
'grok-pdf': {
|
|
465
|
+
id: 'grok-pdf',
|
|
466
|
+
attachment: true,
|
|
467
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
})
|
|
472
|
+
await addAccount({
|
|
473
|
+
provider: 'xai',
|
|
474
|
+
account: {
|
|
475
|
+
type: 'oauth',
|
|
476
|
+
access: 'access-1',
|
|
477
|
+
refresh: 'refresh-1',
|
|
478
|
+
expires: Date.now() + 60_000,
|
|
479
|
+
addedAt: 1,
|
|
480
|
+
lastUsed: 1,
|
|
481
|
+
},
|
|
482
|
+
})
|
|
483
|
+
await savePreset({ name: 'work', models: ['xai/grok-pdf'] })
|
|
484
|
+
|
|
485
|
+
const hooks = await subrouterPlugin(pluginInput)
|
|
486
|
+
const config: Config = {}
|
|
487
|
+
await hooks.config?.(config)
|
|
488
|
+
|
|
489
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
490
|
+
attachment: true,
|
|
491
|
+
modalities: {
|
|
492
|
+
input: ['text', 'image'],
|
|
493
|
+
output: ['text'],
|
|
494
|
+
},
|
|
495
|
+
})
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
test('Anthropic-compatible coding plans do not advertise video input', async () => {
|
|
499
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
500
|
+
'kimi-for-coding': {
|
|
501
|
+
models: {
|
|
502
|
+
'kimi-video': {
|
|
503
|
+
id: 'kimi-video',
|
|
504
|
+
attachment: true,
|
|
505
|
+
modalities: { input: ['text', 'image', 'video'], output: ['text'] },
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
})
|
|
510
|
+
await addAccount({
|
|
511
|
+
provider: 'kimi',
|
|
512
|
+
account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
|
|
513
|
+
})
|
|
514
|
+
await savePreset({ name: 'work', models: ['kimi/kimi-video'] })
|
|
515
|
+
|
|
516
|
+
const hooks = await subrouterPlugin(pluginInput)
|
|
517
|
+
const config: Config = {}
|
|
518
|
+
await hooks.config?.(config)
|
|
519
|
+
|
|
520
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
521
|
+
attachment: true,
|
|
522
|
+
modalities: {
|
|
523
|
+
input: ['text', 'image'],
|
|
524
|
+
output: ['text'],
|
|
525
|
+
},
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
test('OpenCode apply_patch matcher follows gpt- and skips gpt-4 and gpt-oss', () => {
|
|
530
|
+
expect(shouldUseApplyPatch('gpt-5.5')).toBe(true)
|
|
531
|
+
expect(shouldUseApplyPatch('gpt-5.4')).toBe(true)
|
|
532
|
+
expect(shouldUseApplyPatch('gpt-5.3-codex')).toBe(true)
|
|
533
|
+
expect(shouldUseApplyPatch('gpt-4.1')).toBe(false)
|
|
534
|
+
expect(shouldUseApplyPatch('gpt-oss-120b')).toBe(false)
|
|
535
|
+
expect(shouldUseApplyPatch('claude-opus-4-6')).toBe(false)
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
test('apply_patch api ids stay unique across presets that share a model', () => {
|
|
539
|
+
const taken = new Set<string>()
|
|
540
|
+
const first = applyPatchApiId({ preset: 'codex-a', modelId: 'gpt-5.5', taken })
|
|
541
|
+
taken.add(first)
|
|
542
|
+
const second = applyPatchApiId({ preset: 'codex-b', modelId: 'gpt-5.5', taken })
|
|
543
|
+
expect(first).toBe('gpt-5.5')
|
|
544
|
+
expect(second).toBe('gpt-5.5:codex-b')
|
|
545
|
+
expect(shouldUseApplyPatch(second)).toBe(true)
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
test('appendApplyPatchConstraint adds the OpenCode GPT line once', () => {
|
|
549
|
+
const system = ['You are powered by the model named gpt-5.5.']
|
|
550
|
+
appendApplyPatchConstraint({ system, modelId: 'gpt-5.5' })
|
|
551
|
+
appendApplyPatchConstraint({ system, modelId: 'gpt-5.5' })
|
|
552
|
+
expect(system).toEqual(['You are powered by the model named gpt-5.5.', applyPatchConstraint('gpt-5.5')])
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
test('appendApplyPatchConstraint skips non-GPT models', () => {
|
|
556
|
+
const system = ['You are powered by the model named claude-opus-4-6.']
|
|
557
|
+
appendApplyPatchConstraint({ system, modelId: 'claude-opus-4-6' })
|
|
558
|
+
expect(system).toEqual(['You are powered by the model named claude-opus-4-6.'])
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
test('openai live candidate spoofs a gpt api id so OpenCode prefers apply_patch', async () => {
|
|
562
|
+
await addAccount({
|
|
563
|
+
provider: 'anthropic',
|
|
564
|
+
account: {
|
|
565
|
+
type: 'oauth',
|
|
566
|
+
refresh: 'refresh-1',
|
|
567
|
+
access: 'access-1',
|
|
568
|
+
expires: Date.now() + 60_000,
|
|
569
|
+
email: 'a@x.com',
|
|
570
|
+
addedAt: 1,
|
|
571
|
+
lastUsed: 1,
|
|
572
|
+
},
|
|
573
|
+
})
|
|
574
|
+
await addAccount({
|
|
575
|
+
provider: 'openai',
|
|
576
|
+
account: {
|
|
577
|
+
type: 'oauth',
|
|
578
|
+
refresh: 'refresh-o',
|
|
579
|
+
access: 'access-o',
|
|
580
|
+
expires: Date.now() + 60_000,
|
|
581
|
+
email: 'o@x.com',
|
|
582
|
+
addedAt: 1,
|
|
583
|
+
lastUsed: 1,
|
|
584
|
+
},
|
|
585
|
+
})
|
|
586
|
+
await savePreset({ name: 'default', models: ['anthropic/claude-opus-4-6'] })
|
|
587
|
+
await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] })
|
|
588
|
+
await savePreset({ name: 'codex-b', models: ['openai/gpt-5.5'] })
|
|
589
|
+
|
|
590
|
+
const hooks = await subrouterPlugin(pluginInput)
|
|
591
|
+
const config: Config = {}
|
|
592
|
+
await hooks.config?.(config)
|
|
593
|
+
|
|
594
|
+
expect(config.provider?.subrouter?.models?.work).toBeUndefined()
|
|
595
|
+
expect(config.provider?.subrouter?.models?.['openai-only']).toMatchObject({
|
|
596
|
+
name: 'openai-only',
|
|
597
|
+
id: 'gpt-5.5',
|
|
598
|
+
})
|
|
599
|
+
expect(config.provider?.subrouter?.models?.['codex-b']).toMatchObject({
|
|
600
|
+
name: 'codex-b',
|
|
601
|
+
id: 'gpt-5.5:codex-b',
|
|
602
|
+
})
|
|
603
|
+
expect(config.provider?.subrouter?.models?.default?.id).toBeUndefined()
|
|
604
|
+
expect(config.provider?.subrouter?.options?.presetByApiId).toEqual({
|
|
605
|
+
'gpt-5.5': 'openai-only',
|
|
606
|
+
'gpt-5.5:codex-b': 'codex-b',
|
|
607
|
+
})
|
|
608
|
+
})
|
|
609
|
+
|
|
228
610
|
test('rewrites the OpenCode powered-by line to the routed candidate', () => {
|
|
229
611
|
const system = [
|
|
230
612
|
'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
|
|
@@ -263,6 +645,84 @@ test('system transform rewrites the powered-by line to the live candidate', asyn
|
|
|
263
645
|
expect(system[0]).not.toContain('subrouter/build')
|
|
264
646
|
})
|
|
265
647
|
|
|
648
|
+
test('system transform uses the in-flight session route, not the first free preset model', async () => {
|
|
649
|
+
await addAccount({
|
|
650
|
+
provider: 'anthropic',
|
|
651
|
+
account: {
|
|
652
|
+
type: 'oauth',
|
|
653
|
+
refresh: 'refresh-1',
|
|
654
|
+
access: 'access-1',
|
|
655
|
+
expires: Date.now() + 60_000,
|
|
656
|
+
email: 'a@x.com',
|
|
657
|
+
addedAt: 1,
|
|
658
|
+
lastUsed: 1,
|
|
659
|
+
},
|
|
660
|
+
})
|
|
661
|
+
await addAccount({
|
|
662
|
+
provider: 'opencode-go',
|
|
663
|
+
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
664
|
+
})
|
|
665
|
+
await savePreset({
|
|
666
|
+
name: 'default',
|
|
667
|
+
models: ['anthropic/claude-fake', 'opencode-go/fake-model'],
|
|
668
|
+
})
|
|
669
|
+
await setLiveRoute({
|
|
670
|
+
sessionID: 'ses_1',
|
|
671
|
+
preset: 'default',
|
|
672
|
+
provider: 'opencode-go',
|
|
673
|
+
modelId: 'fake-model',
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
const system = [
|
|
677
|
+
'You are powered by the model named default. The exact model ID is subrouter/default',
|
|
678
|
+
]
|
|
679
|
+
await revealRoutedModel({
|
|
680
|
+
providerID: 'subrouter',
|
|
681
|
+
preset: 'default',
|
|
682
|
+
sessionID: 'ses_1',
|
|
683
|
+
system,
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
expect(system[0]).toContain('You are powered by the model named fake-model.')
|
|
687
|
+
expect(system[0]).toContain('The exact model ID is opencode-go/fake-model')
|
|
688
|
+
expect(system[0]).not.toContain('claude-fake')
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
test('system transform appends the apply_patch constraint for a live GPT route', async () => {
|
|
692
|
+
await addAccount({
|
|
693
|
+
provider: 'openai',
|
|
694
|
+
account: {
|
|
695
|
+
type: 'oauth',
|
|
696
|
+
refresh: 'refresh-o',
|
|
697
|
+
access: 'access-o',
|
|
698
|
+
expires: Date.now() + 60_000,
|
|
699
|
+
email: 'o@x.com',
|
|
700
|
+
addedAt: 1,
|
|
701
|
+
lastUsed: 1,
|
|
702
|
+
},
|
|
703
|
+
})
|
|
704
|
+
await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] })
|
|
705
|
+
await setLiveRoute({
|
|
706
|
+
sessionID: 'ses_gpt',
|
|
707
|
+
preset: 'openai-only',
|
|
708
|
+
provider: 'openai',
|
|
709
|
+
modelId: 'gpt-5.5',
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
const system = [
|
|
713
|
+
'You are powered by the model named openai-only. The exact model ID is subrouter/openai-only',
|
|
714
|
+
]
|
|
715
|
+
await revealRoutedModel({
|
|
716
|
+
providerID: 'subrouter',
|
|
717
|
+
preset: 'openai-only',
|
|
718
|
+
sessionID: 'ses_gpt',
|
|
719
|
+
system,
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
expect(system[0]).toContain('You are powered by the model named gpt-5.5.')
|
|
723
|
+
expect(system).toContain(applyPatchConstraint('gpt-5.5'))
|
|
724
|
+
})
|
|
725
|
+
|
|
266
726
|
test('system transform leaves other providers unchanged', async () => {
|
|
267
727
|
const original =
|
|
268
728
|
'You are powered by the model named claude-opus-4-6. The exact model ID is anthropic/claude-opus-4-6'
|