@subrouter/opencode 0.4.0 → 0.5.1

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,MA0L7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MAsFjC,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,40 @@ 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
+ // Surface the fallback as a TUI toast, the same channel OpenCode plugins use for
50
+ // notifications (see kimaki's legacy anthropic/openai/xai auth plugins). A toast is
51
+ // not persisted into the transcript, so it never becomes a fake user/assistant
52
+ // message or enters model context. tui.toast.show is a global event with no session
53
+ // field, so append the OpenCode session id at the end: kimaki routes the toast to
54
+ // the matching Discord thread by that marker and strips it before display. Plain
55
+ // OpenCode TUI just shows the toast for the active session.
56
+ const onCooldownFallback = async (notice) => {
57
+ if (!notice.sessionID || !notice.agent || notice.agent === 'title')
49
58
  return;
50
59
  const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`;
51
60
  const active = `${notice.active.provider}/${notice.active.modelId}`;
52
- pendingNotices.set(notice.sessionID, {
53
- agent: notice.agent,
54
- variant: notice.variant,
55
- preset: notice.preset,
56
- text: `Subrouter: ${preferred} was rate limited. This message started with ${active}.`,
57
- });
61
+ const text = `Subrouter: Using ${active} because ${preferred} is rate limited.`;
62
+ const delivered = deliveredNotices.get(notice.sessionID);
63
+ if (delivered?.text === text && delivered.expiresAt > Date.now())
64
+ return;
65
+ const current = { text, expiresAt: Date.now() + notice.preferred.retryAfterMs };
66
+ deliveredNotices.set(notice.sessionID, current);
67
+ const result = await client.tui
68
+ .showToast({
69
+ query: { directory },
70
+ body: { message: `${text} ${notice.sessionID}`, variant: 'info' },
71
+ throwOnError: true,
72
+ })
73
+ .catch((cause) => new Error('failed to show cooldown fallback toast', { cause }));
74
+ if (!(result instanceof Error))
75
+ return;
76
+ if (deliveredNotices.get(notice.sessionID) === current) {
77
+ deliveredNotices.delete(notice.sessionID);
78
+ }
79
+ void log?.({ level: 'warn', message: result.message });
58
80
  };
59
81
  return {
60
82
  config: async (config) => {
@@ -63,7 +85,9 @@ export const subrouterPlugin = async ({ client, directory }) => {
63
85
  });
64
86
  const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)]);
65
87
  const catalog = await loadModelsDevCatalog({ log });
66
- const models = Object.fromEntries(await Promise.all([...names].map(async (name) => {
88
+ const takenApiIds = new Set();
89
+ const presetByApiId = {};
90
+ const resolved = await Promise.all([...names].map(async (name) => {
67
91
  const presetModels = await resolvePresetModels(name);
68
92
  const candidates = presetModels instanceof Error
69
93
  ? []
@@ -95,60 +119,97 @@ export const subrouterPlugin = async ({ client, directory }) => {
95
119
  for (const modality of modalities)
96
120
  input.add(modality);
97
121
  }
98
- return [
122
+ return { name, candidate, attachment, input, limit };
123
+ }));
124
+ const models = Object.fromEntries(resolved.map(({ name, candidate, attachment, input, limit }) => {
125
+ const apiId = candidate && shouldUseApplyPatch(candidate.modelId)
126
+ ? applyPatchApiId({
127
+ preset: name,
128
+ modelId: candidate.modelId,
129
+ taken: takenApiIds,
130
+ })
131
+ : undefined;
132
+ if (apiId) {
133
+ takenApiIds.add(apiId);
134
+ presetByApiId[apiId] = name;
135
+ }
136
+ const catalogModel = candidate
137
+ ? modelsDevModel({
138
+ provider: candidate.provider,
139
+ modelId: candidate.modelId,
140
+ catalog,
141
+ })
142
+ : null;
143
+ const reasoning = catalogModel?.reasoning ?? false;
144
+ const variants = candidate
145
+ ? Object.fromEntries((catalogModel?.variants ?? []).map((variant) => [
146
+ variant,
147
+ variantProviderOptions({
148
+ provider: candidate.provider,
149
+ modelId: candidate.modelId,
150
+ variant,
151
+ }),
152
+ ]))
153
+ : {};
154
+ const model = {
99
155
  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 },
156
+ tool_call: true,
157
+ attachment,
158
+ reasoning,
159
+ modalities: {
160
+ input: [...input],
161
+ output: ['text'],
111
162
  },
112
- ];
113
- })));
163
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
164
+ limit: limit ?? { context: 200_000, output: 64_000 },
165
+ };
166
+ if (apiId)
167
+ model.id = apiId;
168
+ if (Object.keys(variants).length > 0)
169
+ model.variants = variants;
170
+ return [name, model];
171
+ }));
114
172
  config.provider = {
115
173
  ...config.provider,
116
174
  [PROVIDER_ID]: {
117
175
  name: PROVIDER_DISPLAY_NAME,
118
176
  npm: providerEntryUrl(),
119
177
  models,
120
- options: { log, onCooldownFallback },
178
+ options: { affinity, log, onCooldownFallback, presetByApiId },
121
179
  },
122
180
  };
123
181
  },
124
182
  event: async ({ event }) => {
125
- if (event.type !== 'session.idle')
183
+ if (event.type === 'session.deleted') {
184
+ const sessionID = event.properties.info.id;
185
+ const messageID = activeMessages.get(sessionID);
186
+ if (messageID)
187
+ affinity.clear(messageID);
188
+ activeMessages.delete(sessionID);
189
+ deliveredNotices.delete(sessionID);
190
+ await clearLiveRoute(sessionID);
126
191
  return;
127
- const pending = pendingNotices.get(event.properties.sessionID);
128
- if (!pending)
192
+ }
193
+ if (event.type !== 'session.idle')
129
194
  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(() => { });
195
+ const sessionID = event.properties.sessionID;
196
+ const messageID = activeMessages.get(sessionID);
197
+ if (messageID)
198
+ affinity.clear(messageID);
199
+ activeMessages.delete(sessionID);
200
+ await clearLiveRoute(sessionID);
201
+ },
202
+ 'chat.headers': async (input, output) => {
203
+ const activeMessage = activeMessages.get(input.sessionID) ?? input.message.id;
204
+ const affinityKey = addSubrouterHeaders({ input, output, affinityKey: activeMessage });
205
+ if (affinityKey)
206
+ activeMessages.set(input.sessionID, affinityKey);
145
207
  },
146
- 'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
147
- // OpenCode identity uses the preset id; rewrite it to the live routed model.
148
208
  'experimental.chat.system.transform': async (input, output) => {
149
209
  await revealRoutedModel({
150
210
  providerID: input.model.providerID,
151
211
  preset: input.model.id,
212
+ sessionID: input.sessionID,
152
213
  system: output.system,
153
214
  });
154
215
  },
@@ -171,7 +232,8 @@ function toOpencodeCredentials(account) {
171
232
  accountId: account.accountId,
172
233
  };
173
234
  }
174
- export const subrouterAuthPlugin = async () => {
235
+ export const subrouterAuthPlugin = async ({ client }) => {
236
+ const log = opencodeLog(client);
175
237
  return {
176
238
  auth: {
177
239
  provider: PROVIDER_ID,
@@ -218,8 +280,18 @@ export const subrouterAuthPlugin = async () => {
218
280
  throw session;
219
281
  const finish = async (input) => {
220
282
  const account = await session.complete(input);
221
- if (account instanceof Error)
283
+ if (account instanceof Error) {
284
+ // opencode's AuthOAuthResult has no failure-with-message
285
+ // variant, so the real reason cannot travel back through
286
+ // oauth.callback and harnesses show a generic fallback. Log it
287
+ // here so the cause is diagnosable instead of lost.
288
+ void log?.({
289
+ level: 'error',
290
+ message: `login failed: ${account.message}`,
291
+ extra: { provider: providerId },
292
+ });
222
293
  return { type: 'failed' };
294
+ }
223
295
  await addAccount({ provider: providerId, account });
224
296
  return toOpencodeCredentials(account);
225
297
  };
@@ -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,8 +371,18 @@ 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 shows one session toast without another model turn', async () => {
294
375
  const client = createOpencodeClient({ baseUrl: server.url });
376
+ const toasts = [];
377
+ const subscription = await client.event.subscribe({
378
+ query: { directory: projectDir },
379
+ });
380
+ void (async () => {
381
+ for await (const event of subscription.stream) {
382
+ if (event.type === 'tui.toast.show')
383
+ toasts.push({ message: event.properties.message });
384
+ }
385
+ })();
295
386
  const session = await client.session.create({
296
387
  query: { directory: projectDir },
297
388
  body: { title: 'subrouter route notice' },
@@ -310,27 +401,16 @@ describe('opencode + subrouter provider', () => {
310
401
  .filter((part) => part.type === 'text')
311
402
  .map((part) => part.text)
312
403
  .join('\n')).toContain('hello from fallback');
404
+ // The toast is session-scoped through the trailing session-id marker and never
405
+ // enters the transcript, so no extra user/assistant message and no extra model turn.
313
406
  await expect
314
- .poll(async () => {
315
- const messages = await client.session.messages({
316
- path: { id: session.data.id },
317
- query: { directory: projectDir },
318
- });
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/')));
322
- })
323
- .toBe(true);
407
+ .poll(() => toasts.filter(({ message }) => message.startsWith('Subrouter: Using ') && message.endsWith(session.data.id)).length)
408
+ .toBe(1);
324
409
  const messages = await client.session.messages({
325
410
  path: { id: session.data.id },
326
411
  query: { directory: projectDir },
327
412
  });
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
- });
413
+ expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(1);
334
414
  expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1);
335
415
  expect(zenMock.requests).toHaveLength(requestsBefore + 1);
336
416
  }, 120_000);
@@ -437,4 +517,141 @@ describe('opencode + subrouter provider', () => {
437
517
  ]
438
518
  `);
439
519
  }, 120_000);
520
+ test('keeps the fallback candidate through tool follow-ups until the session is idle', async () => {
521
+ await clearCooldowns();
522
+ await markCooldown({
523
+ provider: 'anthropic',
524
+ account: {
525
+ type: 'oauth',
526
+ refresh: 'fake-refresh',
527
+ access: 'fake-access',
528
+ email: 'a@x.com',
529
+ addedAt: 1,
530
+ lastUsed: 1,
531
+ },
532
+ untilMs: Date.now() + 60_000,
533
+ });
534
+ const readable = path.join(projectDir, 'message.txt');
535
+ await writeFile(readable, 'tool result');
536
+ const fallbackBodies = [];
537
+ let fallbackCalls = 0;
538
+ let cooldownCleared = Promise.resolve();
539
+ zenRespond = ({ body }, res) => {
540
+ fallbackBodies.push(body);
541
+ fallbackCalls++;
542
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
543
+ if (fallbackCalls === 1) {
544
+ cooldownCleared = clearCooldowns();
545
+ res.write(sseChunk({
546
+ id: 'tool-1',
547
+ object: 'chat.completion.chunk',
548
+ created: 1,
549
+ model: 'fake-model',
550
+ choices: [
551
+ {
552
+ index: 0,
553
+ delta: {
554
+ role: 'assistant',
555
+ tool_calls: [
556
+ {
557
+ index: 0,
558
+ id: 'call-read',
559
+ type: 'function',
560
+ function: { name: 'read', arguments: JSON.stringify({ filePath: readable }) },
561
+ },
562
+ ],
563
+ },
564
+ finish_reason: null,
565
+ },
566
+ ],
567
+ }));
568
+ res.write(sseChunk({
569
+ id: 'tool-1',
570
+ object: 'chat.completion.chunk',
571
+ created: 1,
572
+ model: 'fake-model',
573
+ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
574
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
575
+ }));
576
+ res.end('data: [DONE]\n\n');
577
+ return;
578
+ }
579
+ res.write(sseChunk({
580
+ id: 'text-1',
581
+ object: 'chat.completion.chunk',
582
+ created: 1,
583
+ model: 'fake-model',
584
+ choices: [{ index: 0, delta: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }],
585
+ }));
586
+ res.write(sseChunk({
587
+ id: 'text-1',
588
+ object: 'chat.completion.chunk',
589
+ created: 1,
590
+ model: 'fake-model',
591
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
592
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
593
+ }));
594
+ res.end('data: [DONE]\n\n');
595
+ };
596
+ const client = createOpencodeClient({ baseUrl: server.url });
597
+ const session = await client.session.create({
598
+ query: { directory: projectDir },
599
+ body: { title: 'subrouter tool affinity' },
600
+ });
601
+ const anthropicBefore = anthropicMock.requests.length;
602
+ await client.session.prompt({
603
+ path: { id: session.data.id },
604
+ query: { directory: projectDir },
605
+ body: {
606
+ model: { providerID: 'subrouter', modelID: 'default' },
607
+ parts: [{ type: 'text', text: 'read the file' }],
608
+ },
609
+ });
610
+ expect(anthropicMock.requests).toHaveLength(anthropicBefore);
611
+ expect(fallbackBodies
612
+ .slice(0, 2)
613
+ .map((body) => body.includes('You are powered by the model named grok-4.6'))).toEqual([true, true]);
614
+ await cooldownCleared;
615
+ await clearCooldowns();
616
+ await client.session.prompt({
617
+ path: { id: session.data.id },
618
+ query: { directory: projectDir },
619
+ body: {
620
+ model: { providerID: 'subrouter', modelID: 'default' },
621
+ parts: [{ type: 'text', text: 'say done' }],
622
+ },
623
+ });
624
+ expect(anthropicMock.requests).toHaveLength(anthropicBefore + 1);
625
+ expect(fallbackCalls).toBeGreaterThanOrEqual(3);
626
+ }, 120_000);
627
+ test('openai live model advertises apply_patch and not edit or write', async () => {
628
+ const client = createOpencodeClient({ baseUrl: server.url });
629
+ const session = await client.session.create({
630
+ query: { directory: projectDir },
631
+ body: { title: 'subrouter apply_patch' },
632
+ });
633
+ expect(session.data).toBeTruthy();
634
+ const result = await client.session.prompt({
635
+ path: { id: session.data.id },
636
+ query: { directory: projectDir },
637
+ body: {
638
+ model: { providerID: 'subrouter', modelID: 'openai-only' },
639
+ parts: [{ type: 'text', text: 'say hi' }],
640
+ },
641
+ });
642
+ const texts = (result.data?.parts ?? [])
643
+ .filter((part) => part.type === 'text')
644
+ .map((part) => part.text)
645
+ .join('\n');
646
+ expect(texts).toContain('hello from openai');
647
+ expect(openaiMock.requests.length).toBeGreaterThan(0);
648
+ const raw = openaiMock.requests.at(-1).body;
649
+ const body = JSON.parse(raw);
650
+ const names = (body.tools ?? []).map((tool) => tool.name ?? tool.function?.name);
651
+ expect(names).toContain('apply_patch');
652
+ expect(names).not.toContain('edit');
653
+ expect(names).not.toContain('write');
654
+ expect(raw).toContain('apply_patch');
655
+ expect(raw.includes('"name":"edit"') || raw.includes('"name": "edit"')).toBe(false);
656
+ }, 120_000);
440
657
  });