@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.
@@ -6,7 +6,7 @@ import path from 'node:path'
6
6
  import util from 'node:util'
7
7
  import { afterEach, beforeEach, expect, test } from 'vitest'
8
8
  import type { Config, PluginInput } from '@opencode-ai/plugin'
9
- import { createOpencodeClient, type Event } from '@opencode-ai/sdk'
9
+ import { createOpencodeClient } from '@opencode-ai/sdk'
10
10
  import {
11
11
  addAccount,
12
12
  adapters,
@@ -14,9 +14,17 @@ import {
14
14
  PROVIDER_DISPLAY_NAME,
15
15
  PROVIDER_IDS,
16
16
  savePreset,
17
+ setLiveRoute,
17
18
  } from '@subrouter/cli'
18
19
  import { subrouterAuthPlugin, subrouterPlugin } from './index.ts'
19
- import { revealRoutedModel, rewritePoweredByModelLine } from './provider.ts'
20
+ import {
21
+ appendApplyPatchConstraint,
22
+ applyPatchApiId,
23
+ applyPatchConstraint,
24
+ revealRoutedModel,
25
+ rewritePoweredByModelLine,
26
+ shouldUseApplyPatch,
27
+ } from './provider.ts'
20
28
 
21
29
  const execFile = util.promisify(childProcess.execFile)
22
30
  let home: string
@@ -30,7 +38,6 @@ beforeEach(async () => {
30
38
  })
31
39
 
32
40
  afterEach(async () => {
33
- delete process.env.SUBROUTER_HOME
34
41
  delete process.env.SUBROUTER_OPENAI_ISSUER_URL
35
42
  delete process.env.SUBROUTER_MODELS_DEV_URL
36
43
  for (const server of openServers.splice(0)) {
@@ -56,12 +63,13 @@ async function startFakeModelsDev(
56
63
  temperature?: boolean
57
64
  tool_call?: boolean
58
65
  modalities?: { input?: string[]; output?: string[] }
59
- limit?: { context?: number; output?: number; input?: number }
60
- }
61
- >
62
- }
63
- > = {},
64
- ) {
66
+ limit?: { context?: number; output?: number; input?: number }
67
+ reasoning_options?: Array<{ type: string; values?: Array<string | null> }>
68
+ }
69
+ >
70
+ }
71
+ > = {},
72
+ ) {
65
73
  const payload = {
66
74
  anthropic: { models: {} },
67
75
  openai: { models: {} },
@@ -180,8 +188,8 @@ test('provider log callback forwards only to client.app.log', async () => {
180
188
  })
181
189
  })
182
190
 
183
- test('cooldown fallback creates only a persisted ignored notice after idle', async () => {
184
- const requests: Array<{ path: string; body: any }> = []
191
+ test('cooldown fallback shows one session-scoped toast during the active run', async () => {
192
+ const requests: Array<{ path: string; body: Record<string, unknown> }> = []
185
193
  const server = createServer((req, res) => {
186
194
  const chunks: Buffer[] = []
187
195
  req.on('data', (chunk: Buffer) => chunks.push(chunk))
@@ -205,43 +213,25 @@ test('cooldown fallback creates only a persisted ignored notice after idle', asy
205
213
  const onCooldownFallback = config.provider?.subrouter?.options?.onCooldownFallback
206
214
  expect(onCooldownFallback).toBeTypeOf('function')
207
215
  if (typeof onCooldownFallback !== 'function') throw new Error('expected cooldown callback')
208
-
209
- await onCooldownFallback({
210
- sessionID: 'session-1',
216
+ const notice = {
217
+ sessionID: 'ses_session1',
211
218
  agent: 'build',
212
219
  variant: 'high',
213
220
  preset: 'work',
214
- preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 252_000 },
221
+ preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 250_000 },
215
222
  active: { provider: 'openai', modelId: 'gpt-5.6-sol' },
216
- })
217
- await onCooldownFallback({
218
- sessionID: 'session-1',
219
- agent: 'compaction',
220
- preset: 'work',
221
- preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 251_000 },
222
- active: { provider: 'anthropic', modelId: 'claude-sonnet-5' },
223
- })
224
- const event: Event = {
225
- type: 'session.idle',
226
- properties: { sessionID: 'session-1' },
227
- }
228
- await hooks.event?.({ event })
223
+ } as const
224
+
225
+ await onCooldownFallback(notice)
226
+ await onCooldownFallback(notice)
229
227
 
230
228
  expect(requests).toEqual([
231
229
  {
232
- path: '/session/session-1/message?directory=%2Ftmp%2Fproject',
230
+ path: '/tui/show-toast?directory=%2Ftmp%2Fproject',
233
231
  body: {
234
- noReply: true,
235
- agent: 'build',
236
- model: { providerID: 'subrouter', modelID: 'work' },
237
- variant: 'high',
238
- parts: [
239
- {
240
- type: 'text',
241
- text: 'Subrouter: xai/grok-4.6 was rate limited. This message started with openai/gpt-5.6-sol.',
242
- ignored: true,
243
- },
244
- ],
232
+ message:
233
+ 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited. ses_session1',
234
+ variant: 'info',
245
235
  },
246
236
  },
247
237
  ])
@@ -327,10 +317,84 @@ test('preset model limits follow the first live candidate', async () => {
327
317
  input: 900_000,
328
318
  output: 128_000,
329
319
  })
330
- expect(config.provider?.subrouter?.models?.default?.limit).toEqual({
331
- context: 1_000_000,
332
- input: 900_000,
333
- output: 128_000,
320
+ })
321
+
322
+ test('preset reasoning follows the first live candidate', async () => {
323
+ const modelId = adapters.openai.defaultModels[0]
324
+ if (!modelId) throw new Error('openai adapter has no default model')
325
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
326
+ openai: {
327
+ models: {
328
+ [modelId]: {
329
+ id: modelId,
330
+ reasoning: true,
331
+ modalities: { input: ['text'], output: ['text'] },
332
+ limit: { context: 200_000, output: 64_000 },
333
+ },
334
+ },
335
+ },
336
+ })
337
+ await addAccount({
338
+ provider: 'openai',
339
+ account: {
340
+ type: 'oauth',
341
+ refresh: 'refresh-1',
342
+ access: 'access-1',
343
+ expires: Date.now() + 60_000,
344
+ email: 'a@x.com',
345
+ addedAt: 1,
346
+ lastUsed: 1,
347
+ },
348
+ })
349
+ await savePreset({ name: 'work', models: [`openai/${modelId}`] })
350
+
351
+ const hooks = await subrouterPlugin(pluginInput)
352
+ const config: Config = {}
353
+ await hooks.config?.(config)
354
+
355
+ expect(config.provider?.subrouter?.models?.work?.reasoning).toBe(true)
356
+ })
357
+
358
+ test('preset variants follow the first live candidate', async () => {
359
+ const modelId = adapters.openai.defaultModels[0]
360
+ if (!modelId) throw new Error('openai adapter has no default model')
361
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
362
+ openai: {
363
+ models: {
364
+ [modelId]: {
365
+ id: modelId,
366
+ reasoning: true,
367
+ modalities: { input: ['text'], output: ['text'] },
368
+ reasoning_options: [{ type: 'effort', values: ['none', 'low', 'medium', 'high'] }],
369
+ },
370
+ },
371
+ },
372
+ })
373
+ await addAccount({
374
+ provider: 'openai',
375
+ account: {
376
+ type: 'oauth',
377
+ refresh: 'refresh-1',
378
+ access: 'access-1',
379
+ expires: Date.now() + 60_000,
380
+ email: 'a@x.com',
381
+ addedAt: 1,
382
+ lastUsed: 1,
383
+ },
384
+ })
385
+ await savePreset({ name: 'work', models: [`openai/${modelId}#high`] })
386
+
387
+ const hooks = await subrouterPlugin(pluginInput)
388
+ const config: Config = {}
389
+ await hooks.config?.(config)
390
+
391
+ expect(config.provider?.subrouter?.models?.work).toMatchObject({
392
+ variants: {
393
+ none: { reasoningEffort: 'none', reasoningSummary: 'auto' },
394
+ low: { reasoningEffort: 'low', reasoningSummary: 'auto' },
395
+ medium: { reasoningEffort: 'medium', reasoningSummary: 'auto' },
396
+ high: { reasoningEffort: 'high', reasoningSummary: 'auto' },
397
+ },
334
398
  })
335
399
  })
336
400
 
@@ -454,6 +518,87 @@ test('Anthropic-compatible coding plans do not advertise video input', async ()
454
518
  })
455
519
  })
456
520
 
521
+ test('OpenCode apply_patch matcher follows gpt- and skips gpt-4 and gpt-oss', () => {
522
+ expect(shouldUseApplyPatch('gpt-5.5')).toBe(true)
523
+ expect(shouldUseApplyPatch('gpt-5.4')).toBe(true)
524
+ expect(shouldUseApplyPatch('gpt-5.3-codex')).toBe(true)
525
+ expect(shouldUseApplyPatch('gpt-4.1')).toBe(false)
526
+ expect(shouldUseApplyPatch('gpt-oss-120b')).toBe(false)
527
+ expect(shouldUseApplyPatch('claude-opus-4-6')).toBe(false)
528
+ })
529
+
530
+ test('apply_patch api ids stay unique across presets that share a model', () => {
531
+ const taken = new Set<string>()
532
+ const first = applyPatchApiId({ preset: 'codex-a', modelId: 'gpt-5.5', taken })
533
+ taken.add(first)
534
+ const second = applyPatchApiId({ preset: 'codex-b', modelId: 'gpt-5.5', taken })
535
+ expect(first).toBe('gpt-5.5')
536
+ expect(second).toBe('gpt-5.5:codex-b')
537
+ expect(shouldUseApplyPatch(second)).toBe(true)
538
+ })
539
+
540
+ test('appendApplyPatchConstraint adds the OpenCode GPT line once', () => {
541
+ const system = ['You are powered by the model named gpt-5.5.']
542
+ appendApplyPatchConstraint({ system, modelId: 'gpt-5.5' })
543
+ appendApplyPatchConstraint({ system, modelId: 'gpt-5.5' })
544
+ expect(system).toEqual(['You are powered by the model named gpt-5.5.', applyPatchConstraint('gpt-5.5')])
545
+ })
546
+
547
+ test('appendApplyPatchConstraint skips non-GPT models', () => {
548
+ const system = ['You are powered by the model named claude-opus-4-6.']
549
+ appendApplyPatchConstraint({ system, modelId: 'claude-opus-4-6' })
550
+ expect(system).toEqual(['You are powered by the model named claude-opus-4-6.'])
551
+ })
552
+
553
+ test('openai live candidate spoofs a gpt api id so OpenCode prefers apply_patch', async () => {
554
+ await addAccount({
555
+ provider: 'anthropic',
556
+ account: {
557
+ type: 'oauth',
558
+ refresh: 'refresh-1',
559
+ access: 'access-1',
560
+ expires: Date.now() + 60_000,
561
+ email: 'a@x.com',
562
+ addedAt: 1,
563
+ lastUsed: 1,
564
+ },
565
+ })
566
+ await addAccount({
567
+ provider: 'openai',
568
+ account: {
569
+ type: 'oauth',
570
+ refresh: 'refresh-o',
571
+ access: 'access-o',
572
+ expires: Date.now() + 60_000,
573
+ email: 'o@x.com',
574
+ addedAt: 1,
575
+ lastUsed: 1,
576
+ },
577
+ })
578
+ await savePreset({ name: 'default', models: ['anthropic/claude-opus-4-6'] })
579
+ await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] })
580
+ await savePreset({ name: 'codex-b', models: ['openai/gpt-5.5'] })
581
+
582
+ const hooks = await subrouterPlugin(pluginInput)
583
+ const config: Config = {}
584
+ await hooks.config?.(config)
585
+
586
+ expect(config.provider?.subrouter?.models?.work).toBeUndefined()
587
+ expect(config.provider?.subrouter?.models?.['openai-only']).toMatchObject({
588
+ name: 'openai-only',
589
+ id: 'gpt-5.5',
590
+ })
591
+ expect(config.provider?.subrouter?.models?.['codex-b']).toMatchObject({
592
+ name: 'codex-b',
593
+ id: 'gpt-5.5:codex-b',
594
+ })
595
+ expect(config.provider?.subrouter?.models?.default?.id).toBeUndefined()
596
+ expect(config.provider?.subrouter?.options?.presetByApiId).toEqual({
597
+ 'gpt-5.5': 'openai-only',
598
+ 'gpt-5.5:codex-b': 'codex-b',
599
+ })
600
+ })
601
+
457
602
  test('rewrites the OpenCode powered-by line to the routed candidate', () => {
458
603
  const system = [
459
604
  'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
@@ -492,6 +637,84 @@ test('system transform rewrites the powered-by line to the live candidate', asyn
492
637
  expect(system[0]).not.toContain('subrouter/build')
493
638
  })
494
639
 
640
+ test('system transform uses the in-flight session route, not the first free preset model', async () => {
641
+ await addAccount({
642
+ provider: 'anthropic',
643
+ account: {
644
+ type: 'oauth',
645
+ refresh: 'refresh-1',
646
+ access: 'access-1',
647
+ expires: Date.now() + 60_000,
648
+ email: 'a@x.com',
649
+ addedAt: 1,
650
+ lastUsed: 1,
651
+ },
652
+ })
653
+ await addAccount({
654
+ provider: 'opencode-go',
655
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
656
+ })
657
+ await savePreset({
658
+ name: 'default',
659
+ models: ['anthropic/claude-fake', 'opencode-go/fake-model'],
660
+ })
661
+ await setLiveRoute({
662
+ sessionID: 'ses_1',
663
+ preset: 'default',
664
+ provider: 'opencode-go',
665
+ modelId: 'fake-model',
666
+ })
667
+
668
+ const system = [
669
+ 'You are powered by the model named default. The exact model ID is subrouter/default',
670
+ ]
671
+ await revealRoutedModel({
672
+ providerID: 'subrouter',
673
+ preset: 'default',
674
+ sessionID: 'ses_1',
675
+ system,
676
+ })
677
+
678
+ expect(system[0]).toContain('You are powered by the model named fake-model.')
679
+ expect(system[0]).toContain('The exact model ID is opencode-go/fake-model')
680
+ expect(system[0]).not.toContain('claude-fake')
681
+ })
682
+
683
+ test('system transform appends the apply_patch constraint for a live GPT route', async () => {
684
+ await addAccount({
685
+ provider: 'openai',
686
+ account: {
687
+ type: 'oauth',
688
+ refresh: 'refresh-o',
689
+ access: 'access-o',
690
+ expires: Date.now() + 60_000,
691
+ email: 'o@x.com',
692
+ addedAt: 1,
693
+ lastUsed: 1,
694
+ },
695
+ })
696
+ await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] })
697
+ await setLiveRoute({
698
+ sessionID: 'ses_gpt',
699
+ preset: 'openai-only',
700
+ provider: 'openai',
701
+ modelId: 'gpt-5.5',
702
+ })
703
+
704
+ const system = [
705
+ 'You are powered by the model named openai-only. The exact model ID is subrouter/openai-only',
706
+ ]
707
+ await revealRoutedModel({
708
+ providerID: 'subrouter',
709
+ preset: 'openai-only',
710
+ sessionID: 'ses_gpt',
711
+ system,
712
+ })
713
+
714
+ expect(system[0]).toContain('You are powered by the model named gpt-5.5.')
715
+ expect(system).toContain(applyPatchConstraint('gpt-5.5'))
716
+ })
717
+
495
718
  test('system transform leaves other providers unchanged', async () => {
496
719
  const original =
497
720
  'You are powered by the model named claude-opus-4-6. The exact model ID is anthropic/claude-opus-4-6'
package/src/provider.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Provider entry loaded by opencode via `provider.subrouter.npm` (file:// URL).
3
3
  * OpenCode imports this module, calls the first export starting with `create`,
4
- * then `sdk.languageModel(modelId)` where modelId is a subrouter preset name.
5
- * Also rewrites OpenCode's powered-by identity to the live routed model.
4
+ * then `sdk.languageModel(modelId)`. For GPT presets that id is a spoofed
5
+ * `gpt-*` api id so OpenCode prefers apply_patch; createSubrouter maps it
6
+ * back to the preset. Also rewrites OpenCode's powered-by identity to the
7
+ * live routed model and appends the GPT apply_patch constraint when needed.
6
8
  */
7
9
 
8
10
  import {
@@ -11,13 +13,56 @@ import {
11
13
  OPENAI_WEBSOCKET_SESSION_HEADER,
12
14
  OPENAI_WEBSOCKET_TITLE_HEADER,
13
15
  PROVIDER_ID,
14
- resolveActiveCandidate,
16
+ resolveLiveModel,
17
+ ROUTE_AFFINITY_HEADER,
15
18
  } from '@subrouter/cli'
16
19
 
17
20
  export { createSubrouter } from '@subrouter/cli'
18
21
 
19
22
  const POWERED_BY_MODEL = /You are powered by the model named [^\n]+/
20
23
 
24
+ // OpenCode v1 registry.ts and v2 patch.ts: GPT (not gpt-4 / gpt-oss) uses apply_patch.
25
+ export function shouldUseApplyPatch(modelId: string) {
26
+ return modelId.includes('gpt-') && !modelId.includes('oss') && !modelId.includes('gpt-4')
27
+ }
28
+
29
+ export function applyPatchApiId({
30
+ preset,
31
+ modelId,
32
+ taken,
33
+ }: {
34
+ preset: string
35
+ modelId: string
36
+ taken: Set<string>
37
+ }) {
38
+ if (!taken.has(modelId)) return modelId
39
+ return `${modelId}:${preset}`
40
+ }
41
+
42
+ const APPLY_PATCH_GPT =
43
+ 'Always use apply_patch for manual code edits. Do not use cat or any other commands when creating or editing files. Formatting commands or bulk edits don\'t need to be done with apply_patch.'
44
+
45
+ const APPLY_PATCH_CODEX =
46
+ 'Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).'
47
+
48
+ export function applyPatchConstraint(modelId: string) {
49
+ if (modelId.includes('codex')) return APPLY_PATCH_CODEX
50
+ return APPLY_PATCH_GPT
51
+ }
52
+
53
+ export function appendApplyPatchConstraint({
54
+ system,
55
+ modelId,
56
+ }: {
57
+ system: string[]
58
+ modelId: string
59
+ }) {
60
+ if (!shouldUseApplyPatch(modelId)) return
61
+ const constraint = applyPatchConstraint(modelId)
62
+ if (system.some((line) => line.includes('use apply_patch'))) return
63
+ system.push(constraint)
64
+ }
65
+
21
66
  export function rewritePoweredByModelLine({
22
67
  system,
23
68
  candidate,
@@ -34,37 +79,48 @@ export function rewritePoweredByModelLine({
34
79
  export async function revealRoutedModel({
35
80
  providerID,
36
81
  preset,
82
+ sessionID,
37
83
  system,
38
84
  }: {
39
85
  providerID: string
40
86
  preset: string
87
+ sessionID?: string
41
88
  system: string[]
42
89
  }) {
43
90
  if (providerID !== PROVIDER_ID) return
44
- const candidate = await resolveActiveCandidate(preset)
91
+ const candidate = await resolveLiveModel({ preset, sessionID })
45
92
  if (!candidate) return
46
93
  rewritePoweredByModelLine({ system, candidate })
94
+ appendApplyPatchConstraint({ system, modelId: candidate.modelId })
47
95
  }
48
96
 
49
- export function addSubrouterHeaders(
97
+ export function addSubrouterHeaders({
98
+ input,
99
+ output,
100
+ affinityKey = input.message.id,
101
+ }: {
50
102
  input: {
51
103
  sessionID: string
52
104
  agent: string
53
105
  model: { providerID: string }
54
106
  message: {
107
+ id: string
55
108
  agent: string
56
109
  model: { providerID: string; modelID: string; variant?: string }
57
110
  }
58
- },
59
- output: { headers: Record<string, string> },
60
- ) {
61
- if (input.model.providerID !== PROVIDER_ID) return
111
+ }
112
+ output: { headers: Record<string, string> }
113
+ affinityKey?: string
114
+ }) {
115
+ if (input.model.providerID !== PROVIDER_ID) return null
62
116
  output.headers[OPENAI_WEBSOCKET_SESSION_HEADER] = input.sessionID
63
117
  if (input.agent === input.message.agent) {
118
+ output.headers[ROUTE_AFFINITY_HEADER] = affinityKey
64
119
  output.headers[OPENCODE_AGENT_HEADER] = input.message.agent
65
120
  if (input.message.model.variant) {
66
121
  output.headers[OPENCODE_VARIANT_HEADER] = input.message.model.variant
67
122
  }
68
123
  }
69
124
  if (input.agent === 'title') output.headers[OPENAI_WEBSOCKET_TITLE_HEADER] = 'true'
125
+ return output.headers[ROUTE_AFFINITY_HEADER] ?? null
70
126
  }