@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/opencode-e2e.test.ts
CHANGED
|
@@ -13,16 +13,21 @@
|
|
|
13
13
|
import { createOpencodeClient, type Event } from '@opencode-ai/sdk'
|
|
14
14
|
import { createOpencodeServer } from '@opencode-ai/sdk/server'
|
|
15
15
|
import { createServer, type Server } from 'node:http'
|
|
16
|
-
import { mkdtemp, rm, mkdir } from 'node:fs/promises'
|
|
16
|
+
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
17
17
|
import { tmpdir } from 'node:os'
|
|
18
18
|
import path from 'node:path'
|
|
19
19
|
import { pathToFileURL } from 'node:url'
|
|
20
|
-
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
|
20
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'
|
|
21
21
|
import {
|
|
22
|
+
OPENCODE_AGENT_HEADER,
|
|
23
|
+
OPENCODE_VARIANT_HEADER,
|
|
22
24
|
OPENAI_WEBSOCKET_SESSION_HEADER,
|
|
25
|
+
ROUTE_AFFINITY_HEADER,
|
|
23
26
|
OPENAI_WEBSOCKET_TITLE_HEADER,
|
|
24
27
|
addAccount,
|
|
28
|
+
clearCooldowns,
|
|
25
29
|
markCooldown,
|
|
30
|
+
savePreset,
|
|
26
31
|
} from '@subrouter/cli'
|
|
27
32
|
import { addSubrouterHeaders } from './provider.ts'
|
|
28
33
|
|
|
@@ -65,21 +70,26 @@ function summarizeSessionEvents(events: Event[]) {
|
|
|
65
70
|
|
|
66
71
|
type MockServer = {
|
|
67
72
|
url: string
|
|
68
|
-
requests: string
|
|
73
|
+
requests: Array<{ path: string; body: string }>
|
|
69
74
|
close: () => Promise<void>
|
|
70
75
|
}
|
|
71
76
|
|
|
77
|
+
type MockHandler = (
|
|
78
|
+
args: { path: string; body: string },
|
|
79
|
+
res: import('node:http').ServerResponse,
|
|
80
|
+
) => void
|
|
81
|
+
|
|
72
82
|
async function startMockServer(
|
|
73
|
-
handler:
|
|
83
|
+
handler: MockHandler,
|
|
74
84
|
): Promise<MockServer> {
|
|
75
|
-
const requests:
|
|
85
|
+
const requests: MockServer['requests'] = []
|
|
76
86
|
const server: Server = createServer((req, res) => {
|
|
77
87
|
let body = ''
|
|
78
88
|
req.on('data', (chunk) => {
|
|
79
89
|
body += String(chunk)
|
|
80
90
|
})
|
|
81
91
|
req.on('end', () => {
|
|
82
|
-
requests.push(req.url ?? '')
|
|
92
|
+
requests.push({ path: req.url ?? '', body })
|
|
83
93
|
handler({ path: req.url ?? '', body }, res)
|
|
84
94
|
})
|
|
85
95
|
})
|
|
@@ -110,7 +120,11 @@ function sseChunk(data: object) {
|
|
|
110
120
|
let home: string
|
|
111
121
|
let projectDir: string
|
|
112
122
|
let anthropicMock: MockServer
|
|
123
|
+
let modelsDevMock: MockServer
|
|
113
124
|
let zenMock: MockServer
|
|
125
|
+
let openaiMock: MockServer
|
|
126
|
+
let zenRespond: MockHandler
|
|
127
|
+
let defaultZenRespond: MockHandler
|
|
114
128
|
let server: { url: string; close: () => void }
|
|
115
129
|
const savedEnv: Record<string, string | undefined> = {}
|
|
116
130
|
|
|
@@ -126,7 +140,7 @@ beforeAll(async () => {
|
|
|
126
140
|
})
|
|
127
141
|
|
|
128
142
|
// Fake opencode-go: streams a canned completion
|
|
129
|
-
|
|
143
|
+
defaultZenRespond = ({ body }, res) => {
|
|
130
144
|
const streaming = body.includes('"stream":true')
|
|
131
145
|
if (!streaming) {
|
|
132
146
|
res.writeHead(200, { 'content-type': 'application/json' })
|
|
@@ -168,6 +182,79 @@ beforeAll(async () => {
|
|
|
168
182
|
)
|
|
169
183
|
res.write('data: [DONE]\n\n')
|
|
170
184
|
res.end()
|
|
185
|
+
}
|
|
186
|
+
zenRespond = defaultZenRespond
|
|
187
|
+
zenMock = await startMockServer((request, response) => zenRespond(request, response))
|
|
188
|
+
|
|
189
|
+
openaiMock = await startMockServer((_request, res) => {
|
|
190
|
+
const text = 'hello from openai'
|
|
191
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
192
|
+
for (const event of [
|
|
193
|
+
{
|
|
194
|
+
type: 'response.created',
|
|
195
|
+
response: { id: 'resp-1', created_at: 1, model: 'gpt-5.5', service_tier: null },
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
type: 'response.output_item.added',
|
|
199
|
+
output_index: 0,
|
|
200
|
+
item: { type: 'message', id: 'msg-1', role: 'assistant', status: 'in_progress', content: [] },
|
|
201
|
+
},
|
|
202
|
+
{ type: 'response.content_part.added', part: { type: 'output_text', text: '' } },
|
|
203
|
+
{ type: 'response.output_text.delta', item_id: 'msg-1', delta: text },
|
|
204
|
+
{
|
|
205
|
+
type: 'response.output_item.done',
|
|
206
|
+
output_index: 0,
|
|
207
|
+
item: {
|
|
208
|
+
type: 'message',
|
|
209
|
+
id: 'msg-1',
|
|
210
|
+
role: 'assistant',
|
|
211
|
+
status: 'completed',
|
|
212
|
+
content: [{ type: 'output_text', text }],
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
type: 'response.completed',
|
|
217
|
+
response: {
|
|
218
|
+
id: 'resp-1',
|
|
219
|
+
status: 'completed',
|
|
220
|
+
usage: {
|
|
221
|
+
input_tokens: 5,
|
|
222
|
+
output_tokens: 3,
|
|
223
|
+
total_tokens: 8,
|
|
224
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
]) {
|
|
229
|
+
res.write(sseChunk(event))
|
|
230
|
+
}
|
|
231
|
+
res.write('data: [DONE]\n\n')
|
|
232
|
+
res.end()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
modelsDevMock = await startMockServer((_request, res) => {
|
|
236
|
+
const emptyProvider = { models: {} }
|
|
237
|
+
const pdfModel = (id: string) => ({
|
|
238
|
+
id,
|
|
239
|
+
attachment: true,
|
|
240
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
241
|
+
limit: { context: 200_000, output: 64_000 },
|
|
242
|
+
})
|
|
243
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
244
|
+
res.end(
|
|
245
|
+
JSON.stringify({
|
|
246
|
+
anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
|
|
247
|
+
openai: { models: { 'gpt-5.5': pdfModel('gpt-5.5') } },
|
|
248
|
+
xai: emptyProvider,
|
|
249
|
+
'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
|
|
250
|
+
'github-copilot': emptyProvider,
|
|
251
|
+
poe: emptyProvider,
|
|
252
|
+
'minimax-coding-plan': emptyProvider,
|
|
253
|
+
'kimi-for-coding': emptyProvider,
|
|
254
|
+
'zai-coding-plan': emptyProvider,
|
|
255
|
+
'alibaba-coding-plan': emptyProvider,
|
|
256
|
+
}),
|
|
257
|
+
)
|
|
171
258
|
})
|
|
172
259
|
|
|
173
260
|
// Subrouter state: one rate-limited anthropic account + one zen key
|
|
@@ -177,7 +264,9 @@ beforeAll(async () => {
|
|
|
177
264
|
for (const [key, value] of Object.entries({
|
|
178
265
|
SUBROUTER_HOME: subrouterHome,
|
|
179
266
|
SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
|
|
267
|
+
SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
|
|
180
268
|
SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
|
|
269
|
+
SUBROUTER_OPENAI_BASE_URL: openaiMock.url,
|
|
181
270
|
// Isolate opencode from the user's real global config and auth
|
|
182
271
|
XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
|
|
183
272
|
XDG_DATA_HOME: path.join(home, 'xdg-data'),
|
|
@@ -204,15 +293,36 @@ beforeAll(async () => {
|
|
|
204
293
|
provider: 'opencode-go',
|
|
205
294
|
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
206
295
|
})
|
|
296
|
+
await addAccount({
|
|
297
|
+
provider: 'openai',
|
|
298
|
+
account: {
|
|
299
|
+
type: 'oauth',
|
|
300
|
+
refresh: 'openai-refresh',
|
|
301
|
+
access: 'openai-access',
|
|
302
|
+
expires: Date.now() + 1_000_000_000,
|
|
303
|
+
email: 'o@x.com',
|
|
304
|
+
addedAt: 1,
|
|
305
|
+
lastUsed: 1,
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
await savePreset({
|
|
309
|
+
name: 'default',
|
|
310
|
+
models: ['anthropic/claude-opus-4-6', 'opencode-go/grok-4.6'],
|
|
311
|
+
})
|
|
312
|
+
await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] })
|
|
207
313
|
|
|
208
314
|
const providerEntry = pathToFileURL(
|
|
209
315
|
path.join(import.meta.dirname, '..', 'dist', 'provider.js'),
|
|
210
316
|
).href
|
|
317
|
+
const pluginEntry = pathToFileURL(
|
|
318
|
+
path.join(import.meta.dirname, '..', 'dist', 'index.js'),
|
|
319
|
+
).href
|
|
211
320
|
|
|
212
321
|
server = await createOpencodeServer({
|
|
213
322
|
port: 0,
|
|
214
323
|
timeout: 60_000,
|
|
215
324
|
config: {
|
|
325
|
+
plugin: [pluginEntry],
|
|
216
326
|
provider: {
|
|
217
327
|
subrouter: {
|
|
218
328
|
name: 'Subrouter',
|
|
@@ -221,6 +331,8 @@ beforeAll(async () => {
|
|
|
221
331
|
default: {
|
|
222
332
|
name: 'subrouter default',
|
|
223
333
|
tool_call: true,
|
|
334
|
+
attachment: true,
|
|
335
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
224
336
|
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
225
337
|
limit: { context: 200_000, output: 64_000 },
|
|
226
338
|
},
|
|
@@ -231,10 +343,16 @@ beforeAll(async () => {
|
|
|
231
343
|
})
|
|
232
344
|
}, 120_000)
|
|
233
345
|
|
|
346
|
+
afterEach(() => {
|
|
347
|
+
zenRespond = defaultZenRespond
|
|
348
|
+
})
|
|
349
|
+
|
|
234
350
|
afterAll(async () => {
|
|
235
351
|
server?.close()
|
|
236
352
|
await anthropicMock?.close()
|
|
353
|
+
await modelsDevMock?.close()
|
|
237
354
|
await zenMock?.close()
|
|
355
|
+
await openaiMock?.close()
|
|
238
356
|
for (const [key, value] of Object.entries(savedEnv)) {
|
|
239
357
|
if (value === undefined) delete process.env[key]
|
|
240
358
|
else process.env[key] = value
|
|
@@ -245,25 +363,41 @@ afterAll(async () => {
|
|
|
245
363
|
describe('opencode + subrouter provider', () => {
|
|
246
364
|
test('adds session affinity headers for subrouter models', async () => {
|
|
247
365
|
const output = { headers: {} }
|
|
248
|
-
addSubrouterHeaders(
|
|
249
|
-
{
|
|
366
|
+
addSubrouterHeaders({
|
|
367
|
+
input: {
|
|
250
368
|
sessionID: 'session-1',
|
|
251
369
|
agent: 'build',
|
|
252
370
|
model: { providerID: 'subrouter' },
|
|
371
|
+
message: {
|
|
372
|
+
id: 'message-1',
|
|
373
|
+
agent: 'build',
|
|
374
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
375
|
+
},
|
|
253
376
|
},
|
|
254
377
|
output,
|
|
255
|
-
)
|
|
256
|
-
expect(output.headers).toEqual({
|
|
378
|
+
})
|
|
379
|
+
expect(output.headers).toEqual({
|
|
380
|
+
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
|
|
381
|
+
[ROUTE_AFFINITY_HEADER]: 'message-1',
|
|
382
|
+
[OPENCODE_AGENT_HEADER]: 'build',
|
|
383
|
+
[OPENCODE_VARIANT_HEADER]: 'high',
|
|
384
|
+
})
|
|
257
385
|
|
|
258
|
-
|
|
259
|
-
|
|
386
|
+
const titleOutput = { headers: {} }
|
|
387
|
+
addSubrouterHeaders({
|
|
388
|
+
input: {
|
|
260
389
|
sessionID: 'session-2',
|
|
261
390
|
agent: 'title',
|
|
262
391
|
model: { providerID: 'subrouter' },
|
|
392
|
+
message: {
|
|
393
|
+
id: 'message-1',
|
|
394
|
+
agent: 'build',
|
|
395
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
396
|
+
},
|
|
263
397
|
},
|
|
264
|
-
output,
|
|
265
|
-
)
|
|
266
|
-
expect(
|
|
398
|
+
output: titleOutput,
|
|
399
|
+
})
|
|
400
|
+
expect(titleOutput.headers).toEqual({
|
|
267
401
|
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
|
|
268
402
|
[OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
|
|
269
403
|
})
|
|
@@ -299,6 +433,98 @@ describe('opencode + subrouter provider', () => {
|
|
|
299
433
|
expect(zenMock.requests.length).toBeGreaterThan(0)
|
|
300
434
|
}, 120_000)
|
|
301
435
|
|
|
436
|
+
test('a pre-existing cooldown appends one ignored notice without another model turn', async () => {
|
|
437
|
+
const client = createOpencodeClient({ baseUrl: server.url })
|
|
438
|
+
const session = await client.session.create({
|
|
439
|
+
query: { directory: projectDir },
|
|
440
|
+
body: { title: 'subrouter route notice' },
|
|
441
|
+
})
|
|
442
|
+
expect(session.data).toBeTruthy()
|
|
443
|
+
const requestsBefore = zenMock.requests.length
|
|
444
|
+
|
|
445
|
+
const result = await client.session.prompt({
|
|
446
|
+
path: { id: session.data!.id },
|
|
447
|
+
query: { directory: projectDir },
|
|
448
|
+
body: {
|
|
449
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
450
|
+
parts: [{ type: 'text', text: 'say hi again' }],
|
|
451
|
+
},
|
|
452
|
+
})
|
|
453
|
+
expect(
|
|
454
|
+
(result.data?.parts ?? [])
|
|
455
|
+
.filter((part) => part.type === 'text')
|
|
456
|
+
.map((part) => part.text)
|
|
457
|
+
.join('\n'),
|
|
458
|
+
).toContain('hello from fallback')
|
|
459
|
+
|
|
460
|
+
await expect
|
|
461
|
+
.poll(async () => {
|
|
462
|
+
const messages = await client.session.messages({
|
|
463
|
+
path: { id: session.data!.id },
|
|
464
|
+
query: { directory: projectDir },
|
|
465
|
+
})
|
|
466
|
+
return (messages.data ?? []).filter(({ parts }) =>
|
|
467
|
+
parts.some((part) => part.type === 'text' && part.ignored === true),
|
|
468
|
+
).length
|
|
469
|
+
})
|
|
470
|
+
.toBe(1)
|
|
471
|
+
|
|
472
|
+
const messages = await client.session.messages({
|
|
473
|
+
path: { id: session.data!.id },
|
|
474
|
+
query: { directory: projectDir },
|
|
475
|
+
})
|
|
476
|
+
expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(2)
|
|
477
|
+
expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1)
|
|
478
|
+
expect(zenMock.requests).toHaveLength(requestsBefore + 1)
|
|
479
|
+
}, 120_000)
|
|
480
|
+
|
|
481
|
+
test('PDF file parts reach a compatible fallback through opencode', async () => {
|
|
482
|
+
const client = createOpencodeClient({ baseUrl: server.url })
|
|
483
|
+
const session = await client.session.create({
|
|
484
|
+
query: { directory: projectDir },
|
|
485
|
+
body: { title: 'subrouter PDF e2e' },
|
|
486
|
+
})
|
|
487
|
+
expect(session.data).toBeTruthy()
|
|
488
|
+
|
|
489
|
+
const result = await client.session.prompt({
|
|
490
|
+
path: { id: session.data!.id },
|
|
491
|
+
query: { directory: projectDir },
|
|
492
|
+
body: {
|
|
493
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
494
|
+
parts: [
|
|
495
|
+
{
|
|
496
|
+
type: 'file',
|
|
497
|
+
filename: 'document.pdf',
|
|
498
|
+
mime: 'application/pdf',
|
|
499
|
+
url: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
|
|
500
|
+
},
|
|
501
|
+
{ type: 'text', text: 'read the PDF' },
|
|
502
|
+
],
|
|
503
|
+
},
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
const texts = (result.data?.parts ?? [])
|
|
507
|
+
.filter((part) => part.type === 'text')
|
|
508
|
+
.map((part) => part.text)
|
|
509
|
+
.join('\n')
|
|
510
|
+
expect(texts).toContain('hello from fallback')
|
|
511
|
+
const request = zenMock.requests.at(-1)
|
|
512
|
+
expect(request).toBeTruthy()
|
|
513
|
+
const body = JSON.parse(request!.body) as {
|
|
514
|
+
messages: Array<{ content: Array<{ type: string; file?: { filename?: string } }> }>
|
|
515
|
+
}
|
|
516
|
+
expect(body.messages.at(-1)?.content).toEqual([
|
|
517
|
+
{
|
|
518
|
+
type: 'file',
|
|
519
|
+
file: {
|
|
520
|
+
filename: 'document.pdf',
|
|
521
|
+
file_data: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
{ type: 'text', text: 'read the PDF' },
|
|
525
|
+
])
|
|
526
|
+
}, 120_000)
|
|
527
|
+
|
|
302
528
|
test('all cooling-down accounts retry through opencode instead of dying', async () => {
|
|
303
529
|
const untilMs = Date.now() + 2_000
|
|
304
530
|
await markCooldown({
|
|
@@ -367,4 +593,159 @@ describe('opencode + subrouter provider', () => {
|
|
|
367
593
|
]
|
|
368
594
|
`)
|
|
369
595
|
}, 120_000)
|
|
596
|
+
|
|
597
|
+
test('keeps the fallback candidate through tool follow-ups until the session is idle', async () => {
|
|
598
|
+
await clearCooldowns()
|
|
599
|
+
await markCooldown({
|
|
600
|
+
provider: 'anthropic',
|
|
601
|
+
account: {
|
|
602
|
+
type: 'oauth',
|
|
603
|
+
refresh: 'fake-refresh',
|
|
604
|
+
access: 'fake-access',
|
|
605
|
+
email: 'a@x.com',
|
|
606
|
+
addedAt: 1,
|
|
607
|
+
lastUsed: 1,
|
|
608
|
+
},
|
|
609
|
+
untilMs: Date.now() + 60_000,
|
|
610
|
+
})
|
|
611
|
+
const readable = path.join(projectDir, 'message.txt')
|
|
612
|
+
await writeFile(readable, 'tool result')
|
|
613
|
+
const fallbackBodies: string[] = []
|
|
614
|
+
let fallbackCalls = 0
|
|
615
|
+
let cooldownCleared = Promise.resolve()
|
|
616
|
+
zenRespond = ({ body }, res) => {
|
|
617
|
+
fallbackBodies.push(body)
|
|
618
|
+
fallbackCalls++
|
|
619
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
620
|
+
if (fallbackCalls === 1) {
|
|
621
|
+
cooldownCleared = clearCooldowns()
|
|
622
|
+
res.write(
|
|
623
|
+
sseChunk({
|
|
624
|
+
id: 'tool-1',
|
|
625
|
+
object: 'chat.completion.chunk',
|
|
626
|
+
created: 1,
|
|
627
|
+
model: 'fake-model',
|
|
628
|
+
choices: [
|
|
629
|
+
{
|
|
630
|
+
index: 0,
|
|
631
|
+
delta: {
|
|
632
|
+
role: 'assistant',
|
|
633
|
+
tool_calls: [
|
|
634
|
+
{
|
|
635
|
+
index: 0,
|
|
636
|
+
id: 'call-read',
|
|
637
|
+
type: 'function',
|
|
638
|
+
function: { name: 'read', arguments: JSON.stringify({ filePath: readable }) },
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
},
|
|
642
|
+
finish_reason: null,
|
|
643
|
+
},
|
|
644
|
+
],
|
|
645
|
+
}),
|
|
646
|
+
)
|
|
647
|
+
res.write(
|
|
648
|
+
sseChunk({
|
|
649
|
+
id: 'tool-1',
|
|
650
|
+
object: 'chat.completion.chunk',
|
|
651
|
+
created: 1,
|
|
652
|
+
model: 'fake-model',
|
|
653
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
|
654
|
+
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
|
|
655
|
+
}),
|
|
656
|
+
)
|
|
657
|
+
res.end('data: [DONE]\n\n')
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
res.write(
|
|
661
|
+
sseChunk({
|
|
662
|
+
id: 'text-1',
|
|
663
|
+
object: 'chat.completion.chunk',
|
|
664
|
+
created: 1,
|
|
665
|
+
model: 'fake-model',
|
|
666
|
+
choices: [{ index: 0, delta: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }],
|
|
667
|
+
}),
|
|
668
|
+
)
|
|
669
|
+
res.write(
|
|
670
|
+
sseChunk({
|
|
671
|
+
id: 'text-1',
|
|
672
|
+
object: 'chat.completion.chunk',
|
|
673
|
+
created: 1,
|
|
674
|
+
model: 'fake-model',
|
|
675
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
|
676
|
+
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
|
|
677
|
+
}),
|
|
678
|
+
)
|
|
679
|
+
res.end('data: [DONE]\n\n')
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const client = createOpencodeClient({ baseUrl: server.url })
|
|
683
|
+
const session = await client.session.create({
|
|
684
|
+
query: { directory: projectDir },
|
|
685
|
+
body: { title: 'subrouter tool affinity' },
|
|
686
|
+
})
|
|
687
|
+
const anthropicBefore = anthropicMock.requests.length
|
|
688
|
+
await client.session.prompt({
|
|
689
|
+
path: { id: session.data!.id },
|
|
690
|
+
query: { directory: projectDir },
|
|
691
|
+
body: {
|
|
692
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
693
|
+
parts: [{ type: 'text', text: 'read the file' }],
|
|
694
|
+
},
|
|
695
|
+
})
|
|
696
|
+
expect(anthropicMock.requests).toHaveLength(anthropicBefore)
|
|
697
|
+
expect(
|
|
698
|
+
fallbackBodies
|
|
699
|
+
.slice(0, 2)
|
|
700
|
+
.map((body) => body.includes('You are powered by the model named grok-4.6')),
|
|
701
|
+
).toEqual([true, true])
|
|
702
|
+
|
|
703
|
+
await cooldownCleared
|
|
704
|
+
await clearCooldowns()
|
|
705
|
+
await client.session.prompt({
|
|
706
|
+
path: { id: session.data!.id },
|
|
707
|
+
query: { directory: projectDir },
|
|
708
|
+
body: {
|
|
709
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
710
|
+
parts: [{ type: 'text', text: 'say done' }],
|
|
711
|
+
},
|
|
712
|
+
})
|
|
713
|
+
expect(anthropicMock.requests).toHaveLength(anthropicBefore + 1)
|
|
714
|
+
expect(fallbackCalls).toBeGreaterThanOrEqual(3)
|
|
715
|
+
}, 120_000)
|
|
716
|
+
|
|
717
|
+
test('openai live model advertises apply_patch and not edit or write', async () => {
|
|
718
|
+
const client = createOpencodeClient({ baseUrl: server.url })
|
|
719
|
+
const session = await client.session.create({
|
|
720
|
+
query: { directory: projectDir },
|
|
721
|
+
body: { title: 'subrouter apply_patch' },
|
|
722
|
+
})
|
|
723
|
+
expect(session.data).toBeTruthy()
|
|
724
|
+
|
|
725
|
+
const result = await client.session.prompt({
|
|
726
|
+
path: { id: session.data!.id },
|
|
727
|
+
query: { directory: projectDir },
|
|
728
|
+
body: {
|
|
729
|
+
model: { providerID: 'subrouter', modelID: 'openai-only' },
|
|
730
|
+
parts: [{ type: 'text', text: 'say hi' }],
|
|
731
|
+
},
|
|
732
|
+
})
|
|
733
|
+
const texts = (result.data?.parts ?? [])
|
|
734
|
+
.filter((part) => part.type === 'text')
|
|
735
|
+
.map((part) => part.text)
|
|
736
|
+
.join('\n')
|
|
737
|
+
expect(texts).toContain('hello from openai')
|
|
738
|
+
expect(openaiMock.requests.length).toBeGreaterThan(0)
|
|
739
|
+
|
|
740
|
+
const raw = openaiMock.requests.at(-1)!.body
|
|
741
|
+
const body = JSON.parse(raw) as {
|
|
742
|
+
tools?: Array<{ name?: string; type?: string; function?: { name?: string } }>
|
|
743
|
+
}
|
|
744
|
+
const names = (body.tools ?? []).map((tool) => tool.name ?? tool.function?.name)
|
|
745
|
+
expect(names).toContain('apply_patch')
|
|
746
|
+
expect(names).not.toContain('edit')
|
|
747
|
+
expect(names).not.toContain('write')
|
|
748
|
+
expect(raw).toContain('apply_patch')
|
|
749
|
+
expect(raw.includes('"name":"edit"') || raw.includes('"name": "edit"')).toBe(false)
|
|
750
|
+
}, 120_000)
|
|
370
751
|
})
|