@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
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
13
13
|
import { createOpencodeServer } from '@opencode-ai/sdk/server';
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
|
-
import { mkdtemp, rm, mkdir } from 'node:fs/promises';
|
|
15
|
+
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
|
16
16
|
import { tmpdir } from 'node:os';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { pathToFileURL } from 'node:url';
|
|
19
|
-
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
|
20
|
-
import { OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, addAccount, markCooldown, } from '@subrouter/cli';
|
|
19
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
|
|
20
|
+
import { OPENCODE_AGENT_HEADER, OPENCODE_VARIANT_HEADER, OPENAI_WEBSOCKET_SESSION_HEADER, ROUTE_AFFINITY_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, addAccount, clearCooldowns, markCooldown, savePreset, } from '@subrouter/cli';
|
|
21
21
|
import { addSubrouterHeaders } from "./provider.js";
|
|
22
22
|
function summarizeSessionEvents(events) {
|
|
23
23
|
const summary = [];
|
|
@@ -58,7 +58,7 @@ async function startMockServer(handler) {
|
|
|
58
58
|
body += String(chunk);
|
|
59
59
|
});
|
|
60
60
|
req.on('end', () => {
|
|
61
|
-
requests.push(req.url ?? '');
|
|
61
|
+
requests.push({ path: req.url ?? '', body });
|
|
62
62
|
handler({ path: req.url ?? '', body }, res);
|
|
63
63
|
});
|
|
64
64
|
});
|
|
@@ -88,7 +88,11 @@ function sseChunk(data) {
|
|
|
88
88
|
let home;
|
|
89
89
|
let projectDir;
|
|
90
90
|
let anthropicMock;
|
|
91
|
+
let modelsDevMock;
|
|
91
92
|
let zenMock;
|
|
93
|
+
let openaiMock;
|
|
94
|
+
let zenRespond;
|
|
95
|
+
let defaultZenRespond;
|
|
92
96
|
let server;
|
|
93
97
|
const savedEnv = {};
|
|
94
98
|
beforeAll(async () => {
|
|
@@ -101,7 +105,7 @@ beforeAll(async () => {
|
|
|
101
105
|
res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }));
|
|
102
106
|
});
|
|
103
107
|
// Fake opencode-go: streams a canned completion
|
|
104
|
-
|
|
108
|
+
defaultZenRespond = ({ body }, res) => {
|
|
105
109
|
const streaming = body.includes('"stream":true');
|
|
106
110
|
if (!streaming) {
|
|
107
111
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -137,6 +141,75 @@ beforeAll(async () => {
|
|
|
137
141
|
}));
|
|
138
142
|
res.write('data: [DONE]\n\n');
|
|
139
143
|
res.end();
|
|
144
|
+
};
|
|
145
|
+
zenRespond = defaultZenRespond;
|
|
146
|
+
zenMock = await startMockServer((request, response) => zenRespond(request, response));
|
|
147
|
+
openaiMock = await startMockServer((_request, res) => {
|
|
148
|
+
const text = 'hello from openai';
|
|
149
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
150
|
+
for (const event of [
|
|
151
|
+
{
|
|
152
|
+
type: 'response.created',
|
|
153
|
+
response: { id: 'resp-1', created_at: 1, model: 'gpt-5.5', service_tier: null },
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
type: 'response.output_item.added',
|
|
157
|
+
output_index: 0,
|
|
158
|
+
item: { type: 'message', id: 'msg-1', role: 'assistant', status: 'in_progress', content: [] },
|
|
159
|
+
},
|
|
160
|
+
{ type: 'response.content_part.added', part: { type: 'output_text', text: '' } },
|
|
161
|
+
{ type: 'response.output_text.delta', item_id: 'msg-1', delta: text },
|
|
162
|
+
{
|
|
163
|
+
type: 'response.output_item.done',
|
|
164
|
+
output_index: 0,
|
|
165
|
+
item: {
|
|
166
|
+
type: 'message',
|
|
167
|
+
id: 'msg-1',
|
|
168
|
+
role: 'assistant',
|
|
169
|
+
status: 'completed',
|
|
170
|
+
content: [{ type: 'output_text', text }],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
type: 'response.completed',
|
|
175
|
+
response: {
|
|
176
|
+
id: 'resp-1',
|
|
177
|
+
status: 'completed',
|
|
178
|
+
usage: {
|
|
179
|
+
input_tokens: 5,
|
|
180
|
+
output_tokens: 3,
|
|
181
|
+
total_tokens: 8,
|
|
182
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
]) {
|
|
187
|
+
res.write(sseChunk(event));
|
|
188
|
+
}
|
|
189
|
+
res.write('data: [DONE]\n\n');
|
|
190
|
+
res.end();
|
|
191
|
+
});
|
|
192
|
+
modelsDevMock = await startMockServer((_request, res) => {
|
|
193
|
+
const emptyProvider = { models: {} };
|
|
194
|
+
const pdfModel = (id) => ({
|
|
195
|
+
id,
|
|
196
|
+
attachment: true,
|
|
197
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
198
|
+
limit: { context: 200_000, output: 64_000 },
|
|
199
|
+
});
|
|
200
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
201
|
+
res.end(JSON.stringify({
|
|
202
|
+
anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
|
|
203
|
+
openai: { models: { 'gpt-5.5': pdfModel('gpt-5.5') } },
|
|
204
|
+
xai: emptyProvider,
|
|
205
|
+
'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
|
|
206
|
+
'github-copilot': emptyProvider,
|
|
207
|
+
poe: emptyProvider,
|
|
208
|
+
'minimax-coding-plan': emptyProvider,
|
|
209
|
+
'kimi-for-coding': emptyProvider,
|
|
210
|
+
'zai-coding-plan': emptyProvider,
|
|
211
|
+
'alibaba-coding-plan': emptyProvider,
|
|
212
|
+
}));
|
|
140
213
|
});
|
|
141
214
|
// Subrouter state: one rate-limited anthropic account + one zen key
|
|
142
215
|
const subrouterHome = path.join(home, 'subrouter');
|
|
@@ -144,7 +217,9 @@ beforeAll(async () => {
|
|
|
144
217
|
for (const [key, value] of Object.entries({
|
|
145
218
|
SUBROUTER_HOME: subrouterHome,
|
|
146
219
|
SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
|
|
220
|
+
SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
|
|
147
221
|
SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
|
|
222
|
+
SUBROUTER_OPENAI_BASE_URL: openaiMock.url,
|
|
148
223
|
// Isolate opencode from the user's real global config and auth
|
|
149
224
|
XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
|
|
150
225
|
XDG_DATA_HOME: path.join(home, 'xdg-data'),
|
|
@@ -170,11 +245,30 @@ beforeAll(async () => {
|
|
|
170
245
|
provider: 'opencode-go',
|
|
171
246
|
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
172
247
|
});
|
|
248
|
+
await addAccount({
|
|
249
|
+
provider: 'openai',
|
|
250
|
+
account: {
|
|
251
|
+
type: 'oauth',
|
|
252
|
+
refresh: 'openai-refresh',
|
|
253
|
+
access: 'openai-access',
|
|
254
|
+
expires: Date.now() + 1_000_000_000,
|
|
255
|
+
email: 'o@x.com',
|
|
256
|
+
addedAt: 1,
|
|
257
|
+
lastUsed: 1,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
await savePreset({
|
|
261
|
+
name: 'default',
|
|
262
|
+
models: ['anthropic/claude-opus-4-6', 'opencode-go/grok-4.6'],
|
|
263
|
+
});
|
|
264
|
+
await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] });
|
|
173
265
|
const providerEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'provider.js')).href;
|
|
266
|
+
const pluginEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'index.js')).href;
|
|
174
267
|
server = await createOpencodeServer({
|
|
175
268
|
port: 0,
|
|
176
269
|
timeout: 60_000,
|
|
177
270
|
config: {
|
|
271
|
+
plugin: [pluginEntry],
|
|
178
272
|
provider: {
|
|
179
273
|
subrouter: {
|
|
180
274
|
name: 'Subrouter',
|
|
@@ -183,6 +277,8 @@ beforeAll(async () => {
|
|
|
183
277
|
default: {
|
|
184
278
|
name: 'subrouter default',
|
|
185
279
|
tool_call: true,
|
|
280
|
+
attachment: true,
|
|
281
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
186
282
|
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
187
283
|
limit: { context: 200_000, output: 64_000 },
|
|
188
284
|
},
|
|
@@ -192,10 +288,15 @@ beforeAll(async () => {
|
|
|
192
288
|
},
|
|
193
289
|
});
|
|
194
290
|
}, 120_000);
|
|
291
|
+
afterEach(() => {
|
|
292
|
+
zenRespond = defaultZenRespond;
|
|
293
|
+
});
|
|
195
294
|
afterAll(async () => {
|
|
196
295
|
server?.close();
|
|
197
296
|
await anthropicMock?.close();
|
|
297
|
+
await modelsDevMock?.close();
|
|
198
298
|
await zenMock?.close();
|
|
299
|
+
await openaiMock?.close();
|
|
199
300
|
for (const [key, value] of Object.entries(savedEnv)) {
|
|
200
301
|
if (value === undefined)
|
|
201
302
|
delete process.env[key];
|
|
@@ -208,17 +309,39 @@ describe('opencode + subrouter provider', () => {
|
|
|
208
309
|
test('adds session affinity headers for subrouter models', async () => {
|
|
209
310
|
const output = { headers: {} };
|
|
210
311
|
addSubrouterHeaders({
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
312
|
+
input: {
|
|
313
|
+
sessionID: 'session-1',
|
|
314
|
+
agent: 'build',
|
|
315
|
+
model: { providerID: 'subrouter' },
|
|
316
|
+
message: {
|
|
317
|
+
id: 'message-1',
|
|
318
|
+
agent: 'build',
|
|
319
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
output,
|
|
323
|
+
});
|
|
221
324
|
expect(output.headers).toEqual({
|
|
325
|
+
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
|
|
326
|
+
[ROUTE_AFFINITY_HEADER]: 'message-1',
|
|
327
|
+
[OPENCODE_AGENT_HEADER]: 'build',
|
|
328
|
+
[OPENCODE_VARIANT_HEADER]: 'high',
|
|
329
|
+
});
|
|
330
|
+
const titleOutput = { headers: {} };
|
|
331
|
+
addSubrouterHeaders({
|
|
332
|
+
input: {
|
|
333
|
+
sessionID: 'session-2',
|
|
334
|
+
agent: 'title',
|
|
335
|
+
model: { providerID: 'subrouter' },
|
|
336
|
+
message: {
|
|
337
|
+
id: 'message-1',
|
|
338
|
+
agent: 'build',
|
|
339
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
output: titleOutput,
|
|
343
|
+
});
|
|
344
|
+
expect(titleOutput.headers).toEqual({
|
|
222
345
|
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
|
|
223
346
|
[OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
|
|
224
347
|
});
|
|
@@ -248,6 +371,85 @@ describe('opencode + subrouter provider', () => {
|
|
|
248
371
|
expect(anthropicMock.requests.length).toBeGreaterThan(0);
|
|
249
372
|
expect(zenMock.requests.length).toBeGreaterThan(0);
|
|
250
373
|
}, 120_000);
|
|
374
|
+
test('a pre-existing cooldown appends one ignored notice without another model turn', async () => {
|
|
375
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
376
|
+
const session = await client.session.create({
|
|
377
|
+
query: { directory: projectDir },
|
|
378
|
+
body: { title: 'subrouter route notice' },
|
|
379
|
+
});
|
|
380
|
+
expect(session.data).toBeTruthy();
|
|
381
|
+
const requestsBefore = zenMock.requests.length;
|
|
382
|
+
const result = await client.session.prompt({
|
|
383
|
+
path: { id: session.data.id },
|
|
384
|
+
query: { directory: projectDir },
|
|
385
|
+
body: {
|
|
386
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
387
|
+
parts: [{ type: 'text', text: 'say hi again' }],
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
expect((result.data?.parts ?? [])
|
|
391
|
+
.filter((part) => part.type === 'text')
|
|
392
|
+
.map((part) => part.text)
|
|
393
|
+
.join('\n')).toContain('hello from fallback');
|
|
394
|
+
await expect
|
|
395
|
+
.poll(async () => {
|
|
396
|
+
const messages = await client.session.messages({
|
|
397
|
+
path: { id: session.data.id },
|
|
398
|
+
query: { directory: projectDir },
|
|
399
|
+
});
|
|
400
|
+
return (messages.data ?? []).filter(({ parts }) => parts.some((part) => part.type === 'text' && part.ignored === true)).length;
|
|
401
|
+
})
|
|
402
|
+
.toBe(1);
|
|
403
|
+
const messages = await client.session.messages({
|
|
404
|
+
path: { id: session.data.id },
|
|
405
|
+
query: { directory: projectDir },
|
|
406
|
+
});
|
|
407
|
+
expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(2);
|
|
408
|
+
expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1);
|
|
409
|
+
expect(zenMock.requests).toHaveLength(requestsBefore + 1);
|
|
410
|
+
}, 120_000);
|
|
411
|
+
test('PDF file parts reach a compatible fallback through opencode', async () => {
|
|
412
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
413
|
+
const session = await client.session.create({
|
|
414
|
+
query: { directory: projectDir },
|
|
415
|
+
body: { title: 'subrouter PDF e2e' },
|
|
416
|
+
});
|
|
417
|
+
expect(session.data).toBeTruthy();
|
|
418
|
+
const result = await client.session.prompt({
|
|
419
|
+
path: { id: session.data.id },
|
|
420
|
+
query: { directory: projectDir },
|
|
421
|
+
body: {
|
|
422
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
423
|
+
parts: [
|
|
424
|
+
{
|
|
425
|
+
type: 'file',
|
|
426
|
+
filename: 'document.pdf',
|
|
427
|
+
mime: 'application/pdf',
|
|
428
|
+
url: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
|
|
429
|
+
},
|
|
430
|
+
{ type: 'text', text: 'read the PDF' },
|
|
431
|
+
],
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
const texts = (result.data?.parts ?? [])
|
|
435
|
+
.filter((part) => part.type === 'text')
|
|
436
|
+
.map((part) => part.text)
|
|
437
|
+
.join('\n');
|
|
438
|
+
expect(texts).toContain('hello from fallback');
|
|
439
|
+
const request = zenMock.requests.at(-1);
|
|
440
|
+
expect(request).toBeTruthy();
|
|
441
|
+
const body = JSON.parse(request.body);
|
|
442
|
+
expect(body.messages.at(-1)?.content).toEqual([
|
|
443
|
+
{
|
|
444
|
+
type: 'file',
|
|
445
|
+
file: {
|
|
446
|
+
filename: 'document.pdf',
|
|
447
|
+
file_data: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
{ type: 'text', text: 'read the PDF' },
|
|
451
|
+
]);
|
|
452
|
+
}, 120_000);
|
|
251
453
|
test('all cooling-down accounts retry through opencode instead of dying', async () => {
|
|
252
454
|
const untilMs = Date.now() + 2_000;
|
|
253
455
|
await markCooldown({
|
|
@@ -309,4 +511,141 @@ describe('opencode + subrouter provider', () => {
|
|
|
309
511
|
]
|
|
310
512
|
`);
|
|
311
513
|
}, 120_000);
|
|
514
|
+
test('keeps the fallback candidate through tool follow-ups until the session is idle', async () => {
|
|
515
|
+
await clearCooldowns();
|
|
516
|
+
await markCooldown({
|
|
517
|
+
provider: 'anthropic',
|
|
518
|
+
account: {
|
|
519
|
+
type: 'oauth',
|
|
520
|
+
refresh: 'fake-refresh',
|
|
521
|
+
access: 'fake-access',
|
|
522
|
+
email: 'a@x.com',
|
|
523
|
+
addedAt: 1,
|
|
524
|
+
lastUsed: 1,
|
|
525
|
+
},
|
|
526
|
+
untilMs: Date.now() + 60_000,
|
|
527
|
+
});
|
|
528
|
+
const readable = path.join(projectDir, 'message.txt');
|
|
529
|
+
await writeFile(readable, 'tool result');
|
|
530
|
+
const fallbackBodies = [];
|
|
531
|
+
let fallbackCalls = 0;
|
|
532
|
+
let cooldownCleared = Promise.resolve();
|
|
533
|
+
zenRespond = ({ body }, res) => {
|
|
534
|
+
fallbackBodies.push(body);
|
|
535
|
+
fallbackCalls++;
|
|
536
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
537
|
+
if (fallbackCalls === 1) {
|
|
538
|
+
cooldownCleared = clearCooldowns();
|
|
539
|
+
res.write(sseChunk({
|
|
540
|
+
id: 'tool-1',
|
|
541
|
+
object: 'chat.completion.chunk',
|
|
542
|
+
created: 1,
|
|
543
|
+
model: 'fake-model',
|
|
544
|
+
choices: [
|
|
545
|
+
{
|
|
546
|
+
index: 0,
|
|
547
|
+
delta: {
|
|
548
|
+
role: 'assistant',
|
|
549
|
+
tool_calls: [
|
|
550
|
+
{
|
|
551
|
+
index: 0,
|
|
552
|
+
id: 'call-read',
|
|
553
|
+
type: 'function',
|
|
554
|
+
function: { name: 'read', arguments: JSON.stringify({ filePath: readable }) },
|
|
555
|
+
},
|
|
556
|
+
],
|
|
557
|
+
},
|
|
558
|
+
finish_reason: null,
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
}));
|
|
562
|
+
res.write(sseChunk({
|
|
563
|
+
id: 'tool-1',
|
|
564
|
+
object: 'chat.completion.chunk',
|
|
565
|
+
created: 1,
|
|
566
|
+
model: 'fake-model',
|
|
567
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
|
568
|
+
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
|
|
569
|
+
}));
|
|
570
|
+
res.end('data: [DONE]\n\n');
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
res.write(sseChunk({
|
|
574
|
+
id: 'text-1',
|
|
575
|
+
object: 'chat.completion.chunk',
|
|
576
|
+
created: 1,
|
|
577
|
+
model: 'fake-model',
|
|
578
|
+
choices: [{ index: 0, delta: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }],
|
|
579
|
+
}));
|
|
580
|
+
res.write(sseChunk({
|
|
581
|
+
id: 'text-1',
|
|
582
|
+
object: 'chat.completion.chunk',
|
|
583
|
+
created: 1,
|
|
584
|
+
model: 'fake-model',
|
|
585
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
|
586
|
+
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
|
|
587
|
+
}));
|
|
588
|
+
res.end('data: [DONE]\n\n');
|
|
589
|
+
};
|
|
590
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
591
|
+
const session = await client.session.create({
|
|
592
|
+
query: { directory: projectDir },
|
|
593
|
+
body: { title: 'subrouter tool affinity' },
|
|
594
|
+
});
|
|
595
|
+
const anthropicBefore = anthropicMock.requests.length;
|
|
596
|
+
await client.session.prompt({
|
|
597
|
+
path: { id: session.data.id },
|
|
598
|
+
query: { directory: projectDir },
|
|
599
|
+
body: {
|
|
600
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
601
|
+
parts: [{ type: 'text', text: 'read the file' }],
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
expect(anthropicMock.requests).toHaveLength(anthropicBefore);
|
|
605
|
+
expect(fallbackBodies
|
|
606
|
+
.slice(0, 2)
|
|
607
|
+
.map((body) => body.includes('You are powered by the model named grok-4.6'))).toEqual([true, true]);
|
|
608
|
+
await cooldownCleared;
|
|
609
|
+
await clearCooldowns();
|
|
610
|
+
await client.session.prompt({
|
|
611
|
+
path: { id: session.data.id },
|
|
612
|
+
query: { directory: projectDir },
|
|
613
|
+
body: {
|
|
614
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
615
|
+
parts: [{ type: 'text', text: 'say done' }],
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
expect(anthropicMock.requests).toHaveLength(anthropicBefore + 1);
|
|
619
|
+
expect(fallbackCalls).toBeGreaterThanOrEqual(3);
|
|
620
|
+
}, 120_000);
|
|
621
|
+
test('openai live model advertises apply_patch and not edit or write', async () => {
|
|
622
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
623
|
+
const session = await client.session.create({
|
|
624
|
+
query: { directory: projectDir },
|
|
625
|
+
body: { title: 'subrouter apply_patch' },
|
|
626
|
+
});
|
|
627
|
+
expect(session.data).toBeTruthy();
|
|
628
|
+
const result = await client.session.prompt({
|
|
629
|
+
path: { id: session.data.id },
|
|
630
|
+
query: { directory: projectDir },
|
|
631
|
+
body: {
|
|
632
|
+
model: { providerID: 'subrouter', modelID: 'openai-only' },
|
|
633
|
+
parts: [{ type: 'text', text: 'say hi' }],
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
const texts = (result.data?.parts ?? [])
|
|
637
|
+
.filter((part) => part.type === 'text')
|
|
638
|
+
.map((part) => part.text)
|
|
639
|
+
.join('\n');
|
|
640
|
+
expect(texts).toContain('hello from openai');
|
|
641
|
+
expect(openaiMock.requests.length).toBeGreaterThan(0);
|
|
642
|
+
const raw = openaiMock.requests.at(-1).body;
|
|
643
|
+
const body = JSON.parse(raw);
|
|
644
|
+
const names = (body.tools ?? []).map((tool) => tool.name ?? tool.function?.name);
|
|
645
|
+
expect(names).toContain('apply_patch');
|
|
646
|
+
expect(names).not.toContain('edit');
|
|
647
|
+
expect(names).not.toContain('write');
|
|
648
|
+
expect(raw).toContain('apply_patch');
|
|
649
|
+
expect(raw.includes('"name":"edit"') || raw.includes('"name": "edit"')).toBe(false);
|
|
650
|
+
}, 120_000);
|
|
312
651
|
});
|