@subrouter/opencode 0.3.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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +86 -26
- package/dist/opencode-e2e.test.js +133 -5
- package/dist/plugin.test.js +226 -8
- package/dist/provider.d.ts +8 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +7 -1
- package/package.json +2 -2
- package/src/index.ts +89 -29
- package/src/opencode-e2e.test.ts +159 -6
- package/src/plugin.test.ts +238 -9
- package/src/provider.ts +17 -1
package/src/opencode-e2e.test.ts
CHANGED
|
@@ -19,6 +19,8 @@ import path from 'node:path'
|
|
|
19
19
|
import { pathToFileURL } from 'node:url'
|
|
20
20
|
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
|
21
21
|
import {
|
|
22
|
+
OPENCODE_AGENT_HEADER,
|
|
23
|
+
OPENCODE_VARIANT_HEADER,
|
|
22
24
|
OPENAI_WEBSOCKET_SESSION_HEADER,
|
|
23
25
|
OPENAI_WEBSOCKET_TITLE_HEADER,
|
|
24
26
|
addAccount,
|
|
@@ -65,21 +67,21 @@ function summarizeSessionEvents(events: Event[]) {
|
|
|
65
67
|
|
|
66
68
|
type MockServer = {
|
|
67
69
|
url: string
|
|
68
|
-
requests: string
|
|
70
|
+
requests: Array<{ path: string; body: string }>
|
|
69
71
|
close: () => Promise<void>
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
async function startMockServer(
|
|
73
75
|
handler: (args: { path: string; body: string }, res: import('node:http').ServerResponse) => void,
|
|
74
76
|
): Promise<MockServer> {
|
|
75
|
-
const requests:
|
|
77
|
+
const requests: MockServer['requests'] = []
|
|
76
78
|
const server: Server = createServer((req, res) => {
|
|
77
79
|
let body = ''
|
|
78
80
|
req.on('data', (chunk) => {
|
|
79
81
|
body += String(chunk)
|
|
80
82
|
})
|
|
81
83
|
req.on('end', () => {
|
|
82
|
-
requests.push(req.url ?? '')
|
|
84
|
+
requests.push({ path: req.url ?? '', body })
|
|
83
85
|
handler({ path: req.url ?? '', body }, res)
|
|
84
86
|
})
|
|
85
87
|
})
|
|
@@ -110,6 +112,7 @@ function sseChunk(data: object) {
|
|
|
110
112
|
let home: string
|
|
111
113
|
let projectDir: string
|
|
112
114
|
let anthropicMock: MockServer
|
|
115
|
+
let modelsDevMock: MockServer
|
|
113
116
|
let zenMock: MockServer
|
|
114
117
|
let server: { url: string; close: () => void }
|
|
115
118
|
const savedEnv: Record<string, string | undefined> = {}
|
|
@@ -170,6 +173,31 @@ beforeAll(async () => {
|
|
|
170
173
|
res.end()
|
|
171
174
|
})
|
|
172
175
|
|
|
176
|
+
modelsDevMock = await startMockServer((_request, res) => {
|
|
177
|
+
const emptyProvider = { models: {} }
|
|
178
|
+
const pdfModel = (id: string) => ({
|
|
179
|
+
id,
|
|
180
|
+
attachment: true,
|
|
181
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
182
|
+
limit: { context: 200_000, output: 64_000 },
|
|
183
|
+
})
|
|
184
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
185
|
+
res.end(
|
|
186
|
+
JSON.stringify({
|
|
187
|
+
anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
|
|
188
|
+
openai: emptyProvider,
|
|
189
|
+
xai: emptyProvider,
|
|
190
|
+
'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
|
|
191
|
+
'github-copilot': emptyProvider,
|
|
192
|
+
poe: emptyProvider,
|
|
193
|
+
'minimax-coding-plan': emptyProvider,
|
|
194
|
+
'kimi-for-coding': emptyProvider,
|
|
195
|
+
'zai-coding-plan': emptyProvider,
|
|
196
|
+
'alibaba-coding-plan': emptyProvider,
|
|
197
|
+
}),
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
173
201
|
// Subrouter state: one rate-limited anthropic account + one zen key
|
|
174
202
|
const subrouterHome = path.join(home, 'subrouter')
|
|
175
203
|
await mkdir(subrouterHome, { recursive: true })
|
|
@@ -177,6 +205,7 @@ beforeAll(async () => {
|
|
|
177
205
|
for (const [key, value] of Object.entries({
|
|
178
206
|
SUBROUTER_HOME: subrouterHome,
|
|
179
207
|
SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
|
|
208
|
+
SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
|
|
180
209
|
SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
|
|
181
210
|
// Isolate opencode from the user's real global config and auth
|
|
182
211
|
XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
|
|
@@ -208,11 +237,15 @@ beforeAll(async () => {
|
|
|
208
237
|
const providerEntry = pathToFileURL(
|
|
209
238
|
path.join(import.meta.dirname, '..', 'dist', 'provider.js'),
|
|
210
239
|
).href
|
|
240
|
+
const pluginEntry = pathToFileURL(
|
|
241
|
+
path.join(import.meta.dirname, '..', 'dist', 'index.js'),
|
|
242
|
+
).href
|
|
211
243
|
|
|
212
244
|
server = await createOpencodeServer({
|
|
213
245
|
port: 0,
|
|
214
246
|
timeout: 60_000,
|
|
215
247
|
config: {
|
|
248
|
+
plugin: [pluginEntry],
|
|
216
249
|
provider: {
|
|
217
250
|
subrouter: {
|
|
218
251
|
name: 'Subrouter',
|
|
@@ -221,6 +254,8 @@ beforeAll(async () => {
|
|
|
221
254
|
default: {
|
|
222
255
|
name: 'subrouter default',
|
|
223
256
|
tool_call: true,
|
|
257
|
+
attachment: true,
|
|
258
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
224
259
|
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
225
260
|
limit: { context: 200_000, output: 64_000 },
|
|
226
261
|
},
|
|
@@ -234,6 +269,7 @@ beforeAll(async () => {
|
|
|
234
269
|
afterAll(async () => {
|
|
235
270
|
server?.close()
|
|
236
271
|
await anthropicMock?.close()
|
|
272
|
+
await modelsDevMock?.close()
|
|
237
273
|
await zenMock?.close()
|
|
238
274
|
for (const [key, value] of Object.entries(savedEnv)) {
|
|
239
275
|
if (value === undefined) delete process.env[key]
|
|
@@ -250,20 +286,33 @@ describe('opencode + subrouter provider', () => {
|
|
|
250
286
|
sessionID: 'session-1',
|
|
251
287
|
agent: 'build',
|
|
252
288
|
model: { providerID: 'subrouter' },
|
|
289
|
+
message: {
|
|
290
|
+
agent: 'build',
|
|
291
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
292
|
+
},
|
|
253
293
|
},
|
|
254
294
|
output,
|
|
255
295
|
)
|
|
256
|
-
expect(output.headers).toEqual({
|
|
296
|
+
expect(output.headers).toEqual({
|
|
297
|
+
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
|
|
298
|
+
[OPENCODE_AGENT_HEADER]: 'build',
|
|
299
|
+
[OPENCODE_VARIANT_HEADER]: 'high',
|
|
300
|
+
})
|
|
257
301
|
|
|
302
|
+
const titleOutput = { headers: {} }
|
|
258
303
|
addSubrouterHeaders(
|
|
259
304
|
{
|
|
260
305
|
sessionID: 'session-2',
|
|
261
306
|
agent: 'title',
|
|
262
307
|
model: { providerID: 'subrouter' },
|
|
308
|
+
message: {
|
|
309
|
+
agent: 'build',
|
|
310
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
311
|
+
},
|
|
263
312
|
},
|
|
264
|
-
|
|
313
|
+
titleOutput,
|
|
265
314
|
)
|
|
266
|
-
expect(
|
|
315
|
+
expect(titleOutput.headers).toEqual({
|
|
267
316
|
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
|
|
268
317
|
[OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
|
|
269
318
|
})
|
|
@@ -299,6 +348,110 @@ describe('opencode + subrouter provider', () => {
|
|
|
299
348
|
expect(zenMock.requests.length).toBeGreaterThan(0)
|
|
300
349
|
}, 120_000)
|
|
301
350
|
|
|
351
|
+
test('a pre-existing cooldown leaves a visible ignored route notice without another model turn', async () => {
|
|
352
|
+
const client = createOpencodeClient({ baseUrl: server.url })
|
|
353
|
+
const session = await client.session.create({
|
|
354
|
+
query: { directory: projectDir },
|
|
355
|
+
body: { title: 'subrouter route notice' },
|
|
356
|
+
})
|
|
357
|
+
expect(session.data).toBeTruthy()
|
|
358
|
+
const requestsBefore = zenMock.requests.length
|
|
359
|
+
|
|
360
|
+
const result = await client.session.prompt({
|
|
361
|
+
path: { id: session.data!.id },
|
|
362
|
+
query: { directory: projectDir },
|
|
363
|
+
body: {
|
|
364
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
365
|
+
parts: [{ type: 'text', text: 'say hi again' }],
|
|
366
|
+
},
|
|
367
|
+
})
|
|
368
|
+
expect(
|
|
369
|
+
(result.data?.parts ?? [])
|
|
370
|
+
.filter((part) => part.type === 'text')
|
|
371
|
+
.map((part) => part.text)
|
|
372
|
+
.join('\n'),
|
|
373
|
+
).toContain('hello from fallback')
|
|
374
|
+
|
|
375
|
+
await expect
|
|
376
|
+
.poll(async () => {
|
|
377
|
+
const messages = await client.session.messages({
|
|
378
|
+
path: { id: session.data!.id },
|
|
379
|
+
query: { directory: projectDir },
|
|
380
|
+
})
|
|
381
|
+
return (messages.data ?? []).some(({ parts }) =>
|
|
382
|
+
parts.some(
|
|
383
|
+
(part) =>
|
|
384
|
+
part.type === 'text' &&
|
|
385
|
+
part.ignored === true &&
|
|
386
|
+
part.text.includes('was rate limited. This message started with opencode-go/'),
|
|
387
|
+
),
|
|
388
|
+
)
|
|
389
|
+
})
|
|
390
|
+
.toBe(true)
|
|
391
|
+
|
|
392
|
+
const messages = await client.session.messages({
|
|
393
|
+
path: { id: session.data!.id },
|
|
394
|
+
query: { directory: projectDir },
|
|
395
|
+
})
|
|
396
|
+
const notice = (messages.data ?? []).find(({ parts }) =>
|
|
397
|
+
parts.some((part) => part.type === 'text' && part.ignored === true),
|
|
398
|
+
)
|
|
399
|
+
expect(notice?.info).toMatchObject({
|
|
400
|
+
role: 'user',
|
|
401
|
+
agent: 'build',
|
|
402
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
403
|
+
})
|
|
404
|
+
expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1)
|
|
405
|
+
expect(zenMock.requests).toHaveLength(requestsBefore + 1)
|
|
406
|
+
}, 120_000)
|
|
407
|
+
|
|
408
|
+
test('PDF file parts reach a compatible fallback through opencode', async () => {
|
|
409
|
+
const client = createOpencodeClient({ baseUrl: server.url })
|
|
410
|
+
const session = await client.session.create({
|
|
411
|
+
query: { directory: projectDir },
|
|
412
|
+
body: { title: 'subrouter PDF e2e' },
|
|
413
|
+
})
|
|
414
|
+
expect(session.data).toBeTruthy()
|
|
415
|
+
|
|
416
|
+
const result = await client.session.prompt({
|
|
417
|
+
path: { id: session.data!.id },
|
|
418
|
+
query: { directory: projectDir },
|
|
419
|
+
body: {
|
|
420
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
421
|
+
parts: [
|
|
422
|
+
{
|
|
423
|
+
type: 'file',
|
|
424
|
+
filename: 'document.pdf',
|
|
425
|
+
mime: 'application/pdf',
|
|
426
|
+
url: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
|
|
427
|
+
},
|
|
428
|
+
{ type: 'text', text: 'read the PDF' },
|
|
429
|
+
],
|
|
430
|
+
},
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
const texts = (result.data?.parts ?? [])
|
|
434
|
+
.filter((part) => part.type === 'text')
|
|
435
|
+
.map((part) => part.text)
|
|
436
|
+
.join('\n')
|
|
437
|
+
expect(texts).toContain('hello from fallback')
|
|
438
|
+
const request = zenMock.requests.at(-1)
|
|
439
|
+
expect(request).toBeTruthy()
|
|
440
|
+
const body = JSON.parse(request!.body) as {
|
|
441
|
+
messages: Array<{ content: Array<{ type: string; file?: { filename?: string } }> }>
|
|
442
|
+
}
|
|
443
|
+
expect(body.messages.at(-1)?.content).toEqual([
|
|
444
|
+
{
|
|
445
|
+
type: 'file',
|
|
446
|
+
file: {
|
|
447
|
+
filename: 'document.pdf',
|
|
448
|
+
file_data: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
{ type: 'text', text: 'read the PDF' },
|
|
452
|
+
])
|
|
453
|
+
}, 120_000)
|
|
454
|
+
|
|
302
455
|
test('all cooling-down accounts retry through opencode instead of dying', async () => {
|
|
303
456
|
const untilMs = Date.now() + 2_000
|
|
304
457
|
await markCooldown({
|
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, type Event } from '@opencode-ai/sdk'
|
|
7
10
|
import {
|
|
8
11
|
addAccount,
|
|
9
12
|
adapters,
|
|
@@ -11,11 +14,11 @@ import {
|
|
|
11
14
|
PROVIDER_DISPLAY_NAME,
|
|
12
15
|
PROVIDER_IDS,
|
|
13
16
|
savePreset,
|
|
14
|
-
setSubrouterLog,
|
|
15
17
|
} from '@subrouter/cli'
|
|
16
18
|
import { subrouterAuthPlugin, subrouterPlugin } from './index.ts'
|
|
17
19
|
import { revealRoutedModel, rewritePoweredByModelLine } from './provider.ts'
|
|
18
20
|
|
|
21
|
+
const execFile = util.promisify(childProcess.execFile)
|
|
19
22
|
let home: string
|
|
20
23
|
const openServers: Server[] = []
|
|
21
24
|
const pluginInput = {} as PluginInput
|
|
@@ -27,7 +30,6 @@ beforeEach(async () => {
|
|
|
27
30
|
})
|
|
28
31
|
|
|
29
32
|
afterEach(async () => {
|
|
30
|
-
setSubrouterLog(undefined)
|
|
31
33
|
delete process.env.SUBROUTER_HOME
|
|
32
34
|
delete process.env.SUBROUTER_OPENAI_ISSUER_URL
|
|
33
35
|
delete process.env.SUBROUTER_MODELS_DEV_URL
|
|
@@ -49,7 +51,11 @@ async function startFakeModelsDev(
|
|
|
49
51
|
string,
|
|
50
52
|
{
|
|
51
53
|
id: string
|
|
52
|
-
|
|
54
|
+
attachment?: boolean
|
|
55
|
+
reasoning?: boolean
|
|
56
|
+
temperature?: boolean
|
|
57
|
+
tool_call?: boolean
|
|
58
|
+
modalities?: { input?: string[]; output?: string[] }
|
|
53
59
|
limit?: { context?: number; output?: number; input?: number }
|
|
54
60
|
}
|
|
55
61
|
>
|
|
@@ -126,6 +132,121 @@ function authMethod() {
|
|
|
126
132
|
})
|
|
127
133
|
}
|
|
128
134
|
|
|
135
|
+
test('plugin load and config do not write stdout or stderr', async () => {
|
|
136
|
+
const script = "import('./src/index.ts').then(async ({ subrouterPlugin }) => { const hooks = await subrouterPlugin({}); await hooks.config?.({}) })"
|
|
137
|
+
const result = await execFile(process.execPath, ['--no-warnings', '--import', 'tsx', '--eval', script], {
|
|
138
|
+
cwd: process.cwd(),
|
|
139
|
+
env: process.env,
|
|
140
|
+
})
|
|
141
|
+
expect(result).toEqual({ stdout: '', stderr: '' })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('provider log callback forwards only to client.app.log', async () => {
|
|
145
|
+
const received = Promise.withResolvers<object>()
|
|
146
|
+
const server = createServer((req, res) => {
|
|
147
|
+
const chunks: Buffer[] = []
|
|
148
|
+
req.on('data', (chunk: Buffer) => {
|
|
149
|
+
chunks.push(chunk)
|
|
150
|
+
})
|
|
151
|
+
req.on('end', () => {
|
|
152
|
+
received.resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as object)
|
|
153
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
154
|
+
res.end('{}')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
await new Promise<void>((resolve) => {
|
|
158
|
+
server.listen(0, '127.0.0.1', resolve)
|
|
159
|
+
})
|
|
160
|
+
openServers.push(server)
|
|
161
|
+
const address = server.address()
|
|
162
|
+
if (typeof address === 'string' || !address) throw new Error('failed to bind log server')
|
|
163
|
+
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` })
|
|
164
|
+
const hooks = await subrouterPlugin({ ...pluginInput, client })
|
|
165
|
+
const config: Config = {}
|
|
166
|
+
await hooks.config?.(config)
|
|
167
|
+
const log = config.provider?.subrouter?.options?.log
|
|
168
|
+
expect(log).toBeTypeOf('function')
|
|
169
|
+
if (typeof log !== 'function') throw new Error('expected provider log callback')
|
|
170
|
+
await log({
|
|
171
|
+
level: 'warn',
|
|
172
|
+
message: 'failover openai/gpt-5.5',
|
|
173
|
+
extra: { provider: 'openai', modelId: 'gpt-5.5' },
|
|
174
|
+
})
|
|
175
|
+
expect(await received.promise).toEqual({
|
|
176
|
+
service: 'subrouter',
|
|
177
|
+
level: 'warn',
|
|
178
|
+
message: 'failover openai/gpt-5.5',
|
|
179
|
+
extra: { provider: 'openai', modelId: 'gpt-5.5' },
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('cooldown fallback creates only a persisted ignored notice after idle', async () => {
|
|
184
|
+
const requests: Array<{ path: string; body: any }> = []
|
|
185
|
+
const server = createServer((req, res) => {
|
|
186
|
+
const chunks: Buffer[] = []
|
|
187
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
188
|
+
req.on('end', () => {
|
|
189
|
+
requests.push({
|
|
190
|
+
path: req.url ?? '',
|
|
191
|
+
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
|
192
|
+
})
|
|
193
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
194
|
+
res.end('{}')
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
198
|
+
openServers.push(server)
|
|
199
|
+
const address = server.address()
|
|
200
|
+
if (typeof address === 'string' || !address) throw new Error('failed to bind notification server')
|
|
201
|
+
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` })
|
|
202
|
+
const hooks = await subrouterPlugin({ ...pluginInput, client, directory: '/tmp/project' })
|
|
203
|
+
const config: Config = {}
|
|
204
|
+
await hooks.config?.(config)
|
|
205
|
+
const onCooldownFallback = config.provider?.subrouter?.options?.onCooldownFallback
|
|
206
|
+
expect(onCooldownFallback).toBeTypeOf('function')
|
|
207
|
+
if (typeof onCooldownFallback !== 'function') throw new Error('expected cooldown callback')
|
|
208
|
+
|
|
209
|
+
await onCooldownFallback({
|
|
210
|
+
sessionID: 'session-1',
|
|
211
|
+
agent: 'build',
|
|
212
|
+
variant: 'high',
|
|
213
|
+
preset: 'work',
|
|
214
|
+
preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 252_000 },
|
|
215
|
+
active: { provider: 'openai', modelId: 'gpt-5.6-sol' },
|
|
216
|
+
})
|
|
217
|
+
await onCooldownFallback({
|
|
218
|
+
sessionID: 'session-1',
|
|
219
|
+
agent: 'compaction',
|
|
220
|
+
preset: 'work',
|
|
221
|
+
preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 251_000 },
|
|
222
|
+
active: { provider: 'anthropic', modelId: 'claude-sonnet-5' },
|
|
223
|
+
})
|
|
224
|
+
const event: Event = {
|
|
225
|
+
type: 'session.idle',
|
|
226
|
+
properties: { sessionID: 'session-1' },
|
|
227
|
+
}
|
|
228
|
+
await hooks.event?.({ event })
|
|
229
|
+
|
|
230
|
+
expect(requests).toEqual([
|
|
231
|
+
{
|
|
232
|
+
path: '/session/session-1/message?directory=%2Ftmp%2Fproject',
|
|
233
|
+
body: {
|
|
234
|
+
noReply: true,
|
|
235
|
+
agent: 'build',
|
|
236
|
+
model: { providerID: 'subrouter', modelID: 'work' },
|
|
237
|
+
variant: 'high',
|
|
238
|
+
parts: [
|
|
239
|
+
{
|
|
240
|
+
type: 'text',
|
|
241
|
+
text: 'Subrouter: xai/grok-4.6 was rate limited. This message started with openai/gpt-5.6-sol.',
|
|
242
|
+
ignored: true,
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
])
|
|
248
|
+
})
|
|
249
|
+
|
|
129
250
|
test('config hook registers the subrouter provider with preset models', async () => {
|
|
130
251
|
await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] })
|
|
131
252
|
|
|
@@ -146,7 +267,7 @@ test('config hook registers the subrouter provider with preset models', async ()
|
|
|
146
267
|
expect(provider.models.work.limit).toEqual({ context: 200_000, output: 64_000 })
|
|
147
268
|
})
|
|
148
269
|
|
|
149
|
-
test('preset model names
|
|
270
|
+
test('preset model names stay stable when the routed candidate changes', async () => {
|
|
150
271
|
await addAccount({
|
|
151
272
|
provider: 'anthropic',
|
|
152
273
|
account: {
|
|
@@ -166,7 +287,7 @@ test('preset model names show the first live candidate', async () => {
|
|
|
166
287
|
await hooks.config?.(config)
|
|
167
288
|
|
|
168
289
|
expect(config.provider?.subrouter?.name).toBe(PROVIDER_DISPLAY_NAME)
|
|
169
|
-
expect(config.provider?.subrouter?.models?.work?.name).toBe('work
|
|
290
|
+
expect(config.provider?.subrouter?.models?.work?.name).toBe('work')
|
|
170
291
|
})
|
|
171
292
|
|
|
172
293
|
test('preset model limits follow the first live candidate', async () => {
|
|
@@ -177,8 +298,8 @@ test('preset model limits follow the first live candidate', async () => {
|
|
|
177
298
|
models: {
|
|
178
299
|
[modelId]: {
|
|
179
300
|
id: modelId,
|
|
180
|
-
modalities: { output: ['text'] },
|
|
181
|
-
limit: { context: 1_000_000, output: 128_000 },
|
|
301
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
302
|
+
limit: { context: 1_000_000, input: 900_000, output: 128_000 },
|
|
182
303
|
},
|
|
183
304
|
},
|
|
184
305
|
},
|
|
@@ -203,20 +324,59 @@ test('preset model limits follow the first live candidate', async () => {
|
|
|
203
324
|
|
|
204
325
|
expect(config.provider?.subrouter?.models?.work?.limit).toEqual({
|
|
205
326
|
context: 1_000_000,
|
|
327
|
+
input: 900_000,
|
|
206
328
|
output: 128_000,
|
|
207
329
|
})
|
|
208
330
|
expect(config.provider?.subrouter?.models?.default?.limit).toEqual({
|
|
209
331
|
context: 1_000_000,
|
|
332
|
+
input: 900_000,
|
|
210
333
|
output: 128_000,
|
|
211
334
|
})
|
|
212
335
|
})
|
|
213
336
|
|
|
214
|
-
test('preset
|
|
337
|
+
test('preset model input modalities are the union of usable candidates', async () => {
|
|
338
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
339
|
+
openai: {
|
|
340
|
+
models: {
|
|
341
|
+
'gpt-pdf': {
|
|
342
|
+
id: 'gpt-pdf',
|
|
343
|
+
attachment: true,
|
|
344
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
'kimi-for-coding': {
|
|
349
|
+
models: {
|
|
350
|
+
'kimi-image': {
|
|
351
|
+
id: 'kimi-image',
|
|
352
|
+
attachment: true,
|
|
353
|
+
modalities: { input: ['text', 'image'], output: ['text'] },
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
await addAccount({
|
|
359
|
+
provider: 'openai',
|
|
360
|
+
account: {
|
|
361
|
+
type: 'oauth',
|
|
362
|
+
access: 'access-1',
|
|
363
|
+
refresh: 'refresh-1',
|
|
364
|
+
expires: Date.now() + 60_000,
|
|
365
|
+
addedAt: 1,
|
|
366
|
+
lastUsed: 1,
|
|
367
|
+
},
|
|
368
|
+
})
|
|
369
|
+
await addAccount({
|
|
370
|
+
provider: 'kimi',
|
|
371
|
+
account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
|
|
372
|
+
})
|
|
373
|
+
await savePreset({ name: 'work', models: ['openai/gpt-pdf', 'kimi/kimi-image'] })
|
|
374
|
+
|
|
215
375
|
const hooks = await subrouterPlugin(pluginInput)
|
|
216
376
|
const config: Config = {}
|
|
217
377
|
await hooks.config?.(config)
|
|
218
378
|
|
|
219
|
-
expect(config.provider?.subrouter?.models?.
|
|
379
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
220
380
|
attachment: true,
|
|
221
381
|
modalities: {
|
|
222
382
|
input: ['text', 'image', 'pdf'],
|
|
@@ -225,6 +385,75 @@ test('preset models permit image and PDF attachments', async () => {
|
|
|
225
385
|
})
|
|
226
386
|
})
|
|
227
387
|
|
|
388
|
+
test('xAI presets do not advertise inline PDF support that its SDK cannot encode', async () => {
|
|
389
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
390
|
+
xai: {
|
|
391
|
+
models: {
|
|
392
|
+
'grok-pdf': {
|
|
393
|
+
id: 'grok-pdf',
|
|
394
|
+
attachment: true,
|
|
395
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
})
|
|
400
|
+
await addAccount({
|
|
401
|
+
provider: 'xai',
|
|
402
|
+
account: {
|
|
403
|
+
type: 'oauth',
|
|
404
|
+
access: 'access-1',
|
|
405
|
+
refresh: 'refresh-1',
|
|
406
|
+
expires: Date.now() + 60_000,
|
|
407
|
+
addedAt: 1,
|
|
408
|
+
lastUsed: 1,
|
|
409
|
+
},
|
|
410
|
+
})
|
|
411
|
+
await savePreset({ name: 'work', models: ['xai/grok-pdf'] })
|
|
412
|
+
|
|
413
|
+
const hooks = await subrouterPlugin(pluginInput)
|
|
414
|
+
const config: Config = {}
|
|
415
|
+
await hooks.config?.(config)
|
|
416
|
+
|
|
417
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
418
|
+
attachment: true,
|
|
419
|
+
modalities: {
|
|
420
|
+
input: ['text', 'image'],
|
|
421
|
+
output: ['text'],
|
|
422
|
+
},
|
|
423
|
+
})
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
test('Anthropic-compatible coding plans do not advertise video input', async () => {
|
|
427
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
428
|
+
'kimi-for-coding': {
|
|
429
|
+
models: {
|
|
430
|
+
'kimi-video': {
|
|
431
|
+
id: 'kimi-video',
|
|
432
|
+
attachment: true,
|
|
433
|
+
modalities: { input: ['text', 'image', 'video'], output: ['text'] },
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
})
|
|
438
|
+
await addAccount({
|
|
439
|
+
provider: 'kimi',
|
|
440
|
+
account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
|
|
441
|
+
})
|
|
442
|
+
await savePreset({ name: 'work', models: ['kimi/kimi-video'] })
|
|
443
|
+
|
|
444
|
+
const hooks = await subrouterPlugin(pluginInput)
|
|
445
|
+
const config: Config = {}
|
|
446
|
+
await hooks.config?.(config)
|
|
447
|
+
|
|
448
|
+
expect(config.provider?.subrouter?.models?.work).toMatchObject({
|
|
449
|
+
attachment: true,
|
|
450
|
+
modalities: {
|
|
451
|
+
input: ['text', 'image'],
|
|
452
|
+
output: ['text'],
|
|
453
|
+
},
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
228
457
|
test('rewrites the OpenCode powered-by line to the routed candidate', () => {
|
|
229
458
|
const system = [
|
|
230
459
|
'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
|
package/src/provider.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
OPENCODE_AGENT_HEADER,
|
|
10
|
+
OPENCODE_VARIANT_HEADER,
|
|
9
11
|
OPENAI_WEBSOCKET_SESSION_HEADER,
|
|
10
12
|
OPENAI_WEBSOCKET_TITLE_HEADER,
|
|
11
13
|
PROVIDER_ID,
|
|
@@ -45,10 +47,24 @@ export async function revealRoutedModel({
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export function addSubrouterHeaders(
|
|
48
|
-
input: {
|
|
50
|
+
input: {
|
|
51
|
+
sessionID: string
|
|
52
|
+
agent: string
|
|
53
|
+
model: { providerID: string }
|
|
54
|
+
message: {
|
|
55
|
+
agent: string
|
|
56
|
+
model: { providerID: string; modelID: string; variant?: string }
|
|
57
|
+
}
|
|
58
|
+
},
|
|
49
59
|
output: { headers: Record<string, string> },
|
|
50
60
|
) {
|
|
51
61
|
if (input.model.providerID !== PROVIDER_ID) return
|
|
52
62
|
output.headers[OPENAI_WEBSOCKET_SESSION_HEADER] = input.sessionID
|
|
63
|
+
if (input.agent === input.message.agent) {
|
|
64
|
+
output.headers[OPENCODE_AGENT_HEADER] = input.message.agent
|
|
65
|
+
if (input.message.model.variant) {
|
|
66
|
+
output.headers[OPENCODE_VARIANT_HEADER] = input.message.model.variant
|
|
67
|
+
}
|
|
68
|
+
}
|
|
53
69
|
if (input.agent === 'title') output.headers[OPENAI_WEBSOCKET_TITLE_HEADER] = 'true'
|
|
54
70
|
}
|