@subrouter/opencode 0.4.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 CHANGED
@@ -7,8 +7,10 @@
7
7
  * model: pick `subrouter/default` (or any preset created with
8
8
  * `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
9
9
  * visible name is `subrouter.org`. Context limits follow the first live
10
- * candidate. Input modalities cover every usable candidate so the
11
- * router can select a compatible subscription for each prompt.
10
+ * candidate. GPT candidates spoof a `gpt-*` api id so OpenCode prefers
11
+ * apply_patch over edit/write. Input modalities cover every usable candidate so the
12
+ * router can select a compatible subscription for each prompt. Tool
13
+ * follow-ups stay on the selected route until the session becomes idle.
12
14
  *
13
15
  * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
14
16
  * (and any harness driving opencode's auth hook, like kimaki's Discord
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAA;AA0C9D,eAAO,MAAM,eAAe,EAAE,MAoH7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MA0EjC,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAA;AAkD9D,eAAO,MAAM,eAAe,EAAE,MA2L7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MA0EjC,CAAA"}
package/dist/index.js CHANGED
@@ -7,8 +7,10 @@
7
7
  * model: pick `subrouter/default` (or any preset created with
8
8
  * `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
9
9
  * visible name is `subrouter.org`. Context limits follow the first live
10
- * candidate. Input modalities cover every usable candidate so the
11
- * router can select a compatible subscription for each prompt.
10
+ * candidate. GPT candidates spoof a `gpt-*` api id so OpenCode prefers
11
+ * apply_patch over edit/write. Input modalities cover every usable candidate so the
12
+ * router can select a compatible subscription for each prompt. Tool
13
+ * follow-ups stay on the selected route until the session becomes idle.
12
14
  *
13
15
  * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
14
16
  * (and any harness driving opencode's auth hook, like kimaki's Discord
@@ -18,8 +20,8 @@
18
20
  * NOTE: only plugin initializer functions may be exported from this module.
19
21
  * OpenCode calls every export as a plugin.
20
22
  */
21
- import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadModelsDevCatalog, loadPresets, modelsDevInputModalities, modelsDevLimit, modelsDevModel, PROVIDER_DISPLAY_NAME, PROVIDER_ID, PROVIDER_IDS, resolveCandidates, resolvePresetModels, } from '@subrouter/cli';
22
- import { addSubrouterHeaders, revealRoutedModel } from "./provider.js";
23
+ import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadModelsDevCatalog, loadPresets, modelsDevInputModalities, modelsDevLimit, modelsDevModel, variantProviderOptions, PROVIDER_DISPLAY_NAME, PROVIDER_ID, PROVIDER_IDS, clearLiveRoute, resolveCandidates, resolvePresetModels, RouteAffinity, } from '@subrouter/cli';
24
+ import { addSubrouterHeaders, applyPatchApiId, revealRoutedModel, shouldUseApplyPatch, } from "./provider.js";
23
25
  function providerEntryUrl() {
24
26
  const isDev = import.meta.url.endsWith('.ts');
25
27
  return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href;
@@ -41,20 +43,41 @@ function opencodeLog(client) {
41
43
  }
42
44
  export const subrouterPlugin = async ({ client, directory }) => {
43
45
  const log = opencodeLog(client);
44
- const pendingNotices = new Map();
45
- const onCooldownFallback = (notice) => {
46
- if (!client || !notice.sessionID || !notice.agent || notice.agent === 'title')
47
- return;
48
- if (pendingNotices.has(notice.sessionID))
46
+ const affinity = new RouteAffinity();
47
+ const activeMessages = new Map();
48
+ const deliveredNotices = new Map();
49
+ const onCooldownFallback = async (notice) => {
50
+ if (!notice.sessionID || !notice.agent || notice.agent === 'title')
49
51
  return;
50
52
  const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`;
51
53
  const active = `${notice.active.provider}/${notice.active.modelId}`;
52
- pendingNotices.set(notice.sessionID, {
54
+ const text = `Subrouter: Using ${active} because ${preferred} is rate limited.`;
55
+ const delivered = deliveredNotices.get(notice.sessionID);
56
+ if (delivered?.text === text && delivered.expiresAt > Date.now())
57
+ return;
58
+ const current = { text, expiresAt: Date.now() + notice.preferred.retryAfterMs };
59
+ deliveredNotices.set(notice.sessionID, current);
60
+ const body = {
61
+ noReply: true,
53
62
  agent: notice.agent,
63
+ model: { providerID: PROVIDER_ID, modelID: notice.preset },
54
64
  variant: notice.variant,
55
- preset: notice.preset,
56
- text: `Subrouter: ${preferred} was rate limited. This message started with ${active}.`,
57
- });
65
+ parts: [{ type: 'text', text, ignored: true }],
66
+ };
67
+ const result = await client.session
68
+ .prompt({
69
+ path: { id: notice.sessionID },
70
+ query: { directory },
71
+ body,
72
+ throwOnError: true,
73
+ })
74
+ .catch((cause) => new Error('failed to persist cooldown fallback notice', { cause }));
75
+ if (!(result instanceof Error))
76
+ return;
77
+ if (deliveredNotices.get(notice.sessionID) === current) {
78
+ deliveredNotices.delete(notice.sessionID);
79
+ }
80
+ void log?.({ level: 'warn', message: result.message });
58
81
  };
59
82
  return {
60
83
  config: async (config) => {
@@ -63,7 +86,9 @@ export const subrouterPlugin = async ({ client, directory }) => {
63
86
  });
64
87
  const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)]);
65
88
  const catalog = await loadModelsDevCatalog({ log });
66
- const models = Object.fromEntries(await Promise.all([...names].map(async (name) => {
89
+ const takenApiIds = new Set();
90
+ const presetByApiId = {};
91
+ const resolved = await Promise.all([...names].map(async (name) => {
67
92
  const presetModels = await resolvePresetModels(name);
68
93
  const candidates = presetModels instanceof Error
69
94
  ? []
@@ -95,60 +120,97 @@ export const subrouterPlugin = async ({ client, directory }) => {
95
120
  for (const modality of modalities)
96
121
  input.add(modality);
97
122
  }
98
- return [
123
+ return { name, candidate, attachment, input, limit };
124
+ }));
125
+ const models = Object.fromEntries(resolved.map(({ name, candidate, attachment, input, limit }) => {
126
+ const apiId = candidate && shouldUseApplyPatch(candidate.modelId)
127
+ ? applyPatchApiId({
128
+ preset: name,
129
+ modelId: candidate.modelId,
130
+ taken: takenApiIds,
131
+ })
132
+ : undefined;
133
+ if (apiId) {
134
+ takenApiIds.add(apiId);
135
+ presetByApiId[apiId] = name;
136
+ }
137
+ const catalogModel = candidate
138
+ ? modelsDevModel({
139
+ provider: candidate.provider,
140
+ modelId: candidate.modelId,
141
+ catalog,
142
+ })
143
+ : null;
144
+ const reasoning = catalogModel?.reasoning ?? false;
145
+ const variants = candidate
146
+ ? Object.fromEntries((catalogModel?.variants ?? []).map((variant) => [
147
+ variant,
148
+ variantProviderOptions({
149
+ provider: candidate.provider,
150
+ modelId: candidate.modelId,
151
+ variant,
152
+ }),
153
+ ]))
154
+ : {};
155
+ const model = {
99
156
  name,
100
- {
101
- name,
102
- tool_call: true,
103
- attachment,
104
- reasoning: false,
105
- modalities: {
106
- input: [...input],
107
- output: ['text'],
108
- },
109
- cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
110
- limit: limit ?? { context: 200_000, output: 64_000 },
157
+ tool_call: true,
158
+ attachment,
159
+ reasoning,
160
+ modalities: {
161
+ input: [...input],
162
+ output: ['text'],
111
163
  },
112
- ];
113
- })));
164
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
165
+ limit: limit ?? { context: 200_000, output: 64_000 },
166
+ };
167
+ if (apiId)
168
+ model.id = apiId;
169
+ if (Object.keys(variants).length > 0)
170
+ model.variants = variants;
171
+ return [name, model];
172
+ }));
114
173
  config.provider = {
115
174
  ...config.provider,
116
175
  [PROVIDER_ID]: {
117
176
  name: PROVIDER_DISPLAY_NAME,
118
177
  npm: providerEntryUrl(),
119
178
  models,
120
- options: { log, onCooldownFallback },
179
+ options: { affinity, log, onCooldownFallback, presetByApiId },
121
180
  },
122
181
  };
123
182
  },
124
183
  event: async ({ event }) => {
125
- if (event.type !== 'session.idle')
184
+ if (event.type === 'session.deleted') {
185
+ const sessionID = event.properties.info.id;
186
+ const messageID = activeMessages.get(sessionID);
187
+ if (messageID)
188
+ affinity.clear(messageID);
189
+ activeMessages.delete(sessionID);
190
+ deliveredNotices.delete(sessionID);
191
+ await clearLiveRoute(sessionID);
126
192
  return;
127
- const pending = pendingNotices.get(event.properties.sessionID);
128
- if (!pending)
193
+ }
194
+ if (event.type !== 'session.idle')
129
195
  return;
130
- pendingNotices.delete(event.properties.sessionID);
131
- const body = {
132
- noReply: true,
133
- agent: pending.agent,
134
- model: { providerID: PROVIDER_ID, modelID: pending.preset },
135
- variant: pending.variant,
136
- parts: [{ type: 'text', text: pending.text, ignored: true }],
137
- };
138
- await client.session
139
- .prompt({
140
- path: { id: event.properties.sessionID },
141
- query: { directory },
142
- body,
143
- })
144
- .catch(() => { });
196
+ const sessionID = event.properties.sessionID;
197
+ const messageID = activeMessages.get(sessionID);
198
+ if (messageID)
199
+ affinity.clear(messageID);
200
+ activeMessages.delete(sessionID);
201
+ await clearLiveRoute(sessionID);
202
+ },
203
+ 'chat.headers': async (input, output) => {
204
+ const activeMessage = activeMessages.get(input.sessionID) ?? input.message.id;
205
+ const affinityKey = addSubrouterHeaders({ input, output, affinityKey: activeMessage });
206
+ if (affinityKey)
207
+ activeMessages.set(input.sessionID, affinityKey);
145
208
  },
146
- 'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
147
- // OpenCode identity uses the preset id; rewrite it to the live routed model.
148
209
  'experimental.chat.system.transform': async (input, output) => {
149
210
  await revealRoutedModel({
150
211
  providerID: input.model.providerID,
151
212
  preset: input.model.id,
213
+ sessionID: input.sessionID,
152
214
  system: output.system,
153
215
  });
154
216
  },
@@ -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 { OPENCODE_AGENT_HEADER, OPENCODE_VARIANT_HEADER, 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 = [];
@@ -90,6 +90,9 @@ let projectDir;
90
90
  let anthropicMock;
91
91
  let modelsDevMock;
92
92
  let zenMock;
93
+ let openaiMock;
94
+ let zenRespond;
95
+ let defaultZenRespond;
93
96
  let server;
94
97
  const savedEnv = {};
95
98
  beforeAll(async () => {
@@ -102,7 +105,7 @@ beforeAll(async () => {
102
105
  res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }));
103
106
  });
104
107
  // Fake opencode-go: streams a canned completion
105
- zenMock = await startMockServer(({ body }, res) => {
108
+ defaultZenRespond = ({ body }, res) => {
106
109
  const streaming = body.includes('"stream":true');
107
110
  if (!streaming) {
108
111
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -138,6 +141,53 @@ beforeAll(async () => {
138
141
  }));
139
142
  res.write('data: [DONE]\n\n');
140
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();
141
191
  });
142
192
  modelsDevMock = await startMockServer((_request, res) => {
143
193
  const emptyProvider = { models: {} };
@@ -150,7 +200,7 @@ beforeAll(async () => {
150
200
  res.writeHead(200, { 'content-type': 'application/json' });
151
201
  res.end(JSON.stringify({
152
202
  anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
153
- openai: emptyProvider,
203
+ openai: { models: { 'gpt-5.5': pdfModel('gpt-5.5') } },
154
204
  xai: emptyProvider,
155
205
  'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
156
206
  'github-copilot': emptyProvider,
@@ -169,6 +219,7 @@ beforeAll(async () => {
169
219
  SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
170
220
  SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
171
221
  SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
222
+ SUBROUTER_OPENAI_BASE_URL: openaiMock.url,
172
223
  // Isolate opencode from the user's real global config and auth
173
224
  XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
174
225
  XDG_DATA_HOME: path.join(home, 'xdg-data'),
@@ -194,6 +245,23 @@ beforeAll(async () => {
194
245
  provider: 'opencode-go',
195
246
  account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
196
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'] });
197
265
  const providerEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'provider.js')).href;
198
266
  const pluginEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'index.js')).href;
199
267
  server = await createOpencodeServer({
@@ -220,11 +288,15 @@ beforeAll(async () => {
220
288
  },
221
289
  });
222
290
  }, 120_000);
291
+ afterEach(() => {
292
+ zenRespond = defaultZenRespond;
293
+ });
223
294
  afterAll(async () => {
224
295
  server?.close();
225
296
  await anthropicMock?.close();
226
297
  await modelsDevMock?.close();
227
298
  await zenMock?.close();
299
+ await openaiMock?.close();
228
300
  for (const [key, value] of Object.entries(savedEnv)) {
229
301
  if (value === undefined)
230
302
  delete process.env[key];
@@ -237,29 +309,38 @@ describe('opencode + subrouter provider', () => {
237
309
  test('adds session affinity headers for subrouter models', async () => {
238
310
  const output = { headers: {} };
239
311
  addSubrouterHeaders({
240
- sessionID: 'session-1',
241
- agent: 'build',
242
- model: { providerID: 'subrouter' },
243
- message: {
312
+ input: {
313
+ sessionID: 'session-1',
244
314
  agent: 'build',
245
- model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
315
+ model: { providerID: 'subrouter' },
316
+ message: {
317
+ id: 'message-1',
318
+ agent: 'build',
319
+ model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
320
+ },
246
321
  },
247
- }, output);
322
+ output,
323
+ });
248
324
  expect(output.headers).toEqual({
249
325
  [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
326
+ [ROUTE_AFFINITY_HEADER]: 'message-1',
250
327
  [OPENCODE_AGENT_HEADER]: 'build',
251
328
  [OPENCODE_VARIANT_HEADER]: 'high',
252
329
  });
253
330
  const titleOutput = { headers: {} };
254
331
  addSubrouterHeaders({
255
- sessionID: 'session-2',
256
- agent: 'title',
257
- model: { providerID: 'subrouter' },
258
- message: {
259
- agent: 'build',
260
- model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
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
+ },
261
341
  },
262
- }, titleOutput);
342
+ output: titleOutput,
343
+ });
263
344
  expect(titleOutput.headers).toEqual({
264
345
  [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
265
346
  [OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
@@ -290,7 +371,7 @@ describe('opencode + subrouter provider', () => {
290
371
  expect(anthropicMock.requests.length).toBeGreaterThan(0);
291
372
  expect(zenMock.requests.length).toBeGreaterThan(0);
292
373
  }, 120_000);
293
- test('a pre-existing cooldown leaves a visible ignored route notice without another model turn', async () => {
374
+ test('a pre-existing cooldown appends one ignored notice without another model turn', async () => {
294
375
  const client = createOpencodeClient({ baseUrl: server.url });
295
376
  const session = await client.session.create({
296
377
  query: { directory: projectDir },
@@ -316,21 +397,14 @@ describe('opencode + subrouter provider', () => {
316
397
  path: { id: session.data.id },
317
398
  query: { directory: projectDir },
318
399
  });
319
- return (messages.data ?? []).some(({ parts }) => parts.some((part) => part.type === 'text' &&
320
- part.ignored === true &&
321
- part.text.includes('was rate limited. This message started with opencode-go/')));
400
+ return (messages.data ?? []).filter(({ parts }) => parts.some((part) => part.type === 'text' && part.ignored === true)).length;
322
401
  })
323
- .toBe(true);
402
+ .toBe(1);
324
403
  const messages = await client.session.messages({
325
404
  path: { id: session.data.id },
326
405
  query: { directory: projectDir },
327
406
  });
328
- const notice = (messages.data ?? []).find(({ parts }) => parts.some((part) => part.type === 'text' && part.ignored === true));
329
- expect(notice?.info).toMatchObject({
330
- role: 'user',
331
- agent: 'build',
332
- model: { providerID: 'subrouter', modelID: 'default' },
333
- });
407
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(2);
334
408
  expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1);
335
409
  expect(zenMock.requests).toHaveLength(requestsBefore + 1);
336
410
  }, 120_000);
@@ -437,4 +511,141 @@ describe('opencode + subrouter provider', () => {
437
511
  ]
438
512
  `);
439
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);
440
651
  });