@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.
@@ -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 persists one ignored notice 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,27 +213,17 @@ 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({
216
+ const notice = {
210
217
  sessionID: 'session-1',
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
  {
@@ -238,7 +236,7 @@ test('cooldown fallback creates only a persisted ignored notice after idle', asy
238
236
  parts: [
239
237
  {
240
238
  type: 'text',
241
- text: 'Subrouter: xai/grok-4.6 was rate limited. This message started with openai/gpt-5.6-sol.',
239
+ text: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.',
242
240
  ignored: true,
243
241
  },
244
242
  ],
@@ -327,10 +325,84 @@ test('preset model limits follow the first live candidate', async () => {
327
325
  input: 900_000,
328
326
  output: 128_000,
329
327
  })
330
- expect(config.provider?.subrouter?.models?.default?.limit).toEqual({
331
- context: 1_000_000,
332
- input: 900_000,
333
- output: 128_000,
328
+ })
329
+
330
+ test('preset reasoning follows the first live candidate', async () => {
331
+ const modelId = adapters.openai.defaultModels[0]
332
+ if (!modelId) throw new Error('openai adapter has no default model')
333
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
334
+ openai: {
335
+ models: {
336
+ [modelId]: {
337
+ id: modelId,
338
+ reasoning: true,
339
+ modalities: { input: ['text'], output: ['text'] },
340
+ limit: { context: 200_000, output: 64_000 },
341
+ },
342
+ },
343
+ },
344
+ })
345
+ await addAccount({
346
+ provider: 'openai',
347
+ account: {
348
+ type: 'oauth',
349
+ refresh: 'refresh-1',
350
+ access: 'access-1',
351
+ expires: Date.now() + 60_000,
352
+ email: 'a@x.com',
353
+ addedAt: 1,
354
+ lastUsed: 1,
355
+ },
356
+ })
357
+ await savePreset({ name: 'work', models: [`openai/${modelId}`] })
358
+
359
+ const hooks = await subrouterPlugin(pluginInput)
360
+ const config: Config = {}
361
+ await hooks.config?.(config)
362
+
363
+ expect(config.provider?.subrouter?.models?.work?.reasoning).toBe(true)
364
+ })
365
+
366
+ test('preset variants follow the first live candidate', async () => {
367
+ const modelId = adapters.openai.defaultModels[0]
368
+ if (!modelId) throw new Error('openai adapter has no default model')
369
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
370
+ openai: {
371
+ models: {
372
+ [modelId]: {
373
+ id: modelId,
374
+ reasoning: true,
375
+ modalities: { input: ['text'], output: ['text'] },
376
+ reasoning_options: [{ type: 'effort', values: ['none', 'low', 'medium', 'high'] }],
377
+ },
378
+ },
379
+ },
380
+ })
381
+ await addAccount({
382
+ provider: 'openai',
383
+ account: {
384
+ type: 'oauth',
385
+ refresh: 'refresh-1',
386
+ access: 'access-1',
387
+ expires: Date.now() + 60_000,
388
+ email: 'a@x.com',
389
+ addedAt: 1,
390
+ lastUsed: 1,
391
+ },
392
+ })
393
+ await savePreset({ name: 'work', models: [`openai/${modelId}#high`] })
394
+
395
+ const hooks = await subrouterPlugin(pluginInput)
396
+ const config: Config = {}
397
+ await hooks.config?.(config)
398
+
399
+ expect(config.provider?.subrouter?.models?.work).toMatchObject({
400
+ variants: {
401
+ none: { reasoningEffort: 'none', reasoningSummary: 'auto' },
402
+ low: { reasoningEffort: 'low', reasoningSummary: 'auto' },
403
+ medium: { reasoningEffort: 'medium', reasoningSummary: 'auto' },
404
+ high: { reasoningEffort: 'high', reasoningSummary: 'auto' },
405
+ },
334
406
  })
335
407
  })
336
408
 
@@ -454,6 +526,87 @@ test('Anthropic-compatible coding plans do not advertise video input', async ()
454
526
  })
455
527
  })
456
528
 
529
+ test('OpenCode apply_patch matcher follows gpt- and skips gpt-4 and gpt-oss', () => {
530
+ expect(shouldUseApplyPatch('gpt-5.5')).toBe(true)
531
+ expect(shouldUseApplyPatch('gpt-5.4')).toBe(true)
532
+ expect(shouldUseApplyPatch('gpt-5.3-codex')).toBe(true)
533
+ expect(shouldUseApplyPatch('gpt-4.1')).toBe(false)
534
+ expect(shouldUseApplyPatch('gpt-oss-120b')).toBe(false)
535
+ expect(shouldUseApplyPatch('claude-opus-4-6')).toBe(false)
536
+ })
537
+
538
+ test('apply_patch api ids stay unique across presets that share a model', () => {
539
+ const taken = new Set<string>()
540
+ const first = applyPatchApiId({ preset: 'codex-a', modelId: 'gpt-5.5', taken })
541
+ taken.add(first)
542
+ const second = applyPatchApiId({ preset: 'codex-b', modelId: 'gpt-5.5', taken })
543
+ expect(first).toBe('gpt-5.5')
544
+ expect(second).toBe('gpt-5.5:codex-b')
545
+ expect(shouldUseApplyPatch(second)).toBe(true)
546
+ })
547
+
548
+ test('appendApplyPatchConstraint adds the OpenCode GPT line once', () => {
549
+ const system = ['You are powered by the model named gpt-5.5.']
550
+ appendApplyPatchConstraint({ system, modelId: 'gpt-5.5' })
551
+ appendApplyPatchConstraint({ system, modelId: 'gpt-5.5' })
552
+ expect(system).toEqual(['You are powered by the model named gpt-5.5.', applyPatchConstraint('gpt-5.5')])
553
+ })
554
+
555
+ test('appendApplyPatchConstraint skips non-GPT models', () => {
556
+ const system = ['You are powered by the model named claude-opus-4-6.']
557
+ appendApplyPatchConstraint({ system, modelId: 'claude-opus-4-6' })
558
+ expect(system).toEqual(['You are powered by the model named claude-opus-4-6.'])
559
+ })
560
+
561
+ test('openai live candidate spoofs a gpt api id so OpenCode prefers apply_patch', async () => {
562
+ await addAccount({
563
+ provider: 'anthropic',
564
+ account: {
565
+ type: 'oauth',
566
+ refresh: 'refresh-1',
567
+ access: 'access-1',
568
+ expires: Date.now() + 60_000,
569
+ email: 'a@x.com',
570
+ addedAt: 1,
571
+ lastUsed: 1,
572
+ },
573
+ })
574
+ await addAccount({
575
+ provider: 'openai',
576
+ account: {
577
+ type: 'oauth',
578
+ refresh: 'refresh-o',
579
+ access: 'access-o',
580
+ expires: Date.now() + 60_000,
581
+ email: 'o@x.com',
582
+ addedAt: 1,
583
+ lastUsed: 1,
584
+ },
585
+ })
586
+ await savePreset({ name: 'default', models: ['anthropic/claude-opus-4-6'] })
587
+ await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] })
588
+ await savePreset({ name: 'codex-b', models: ['openai/gpt-5.5'] })
589
+
590
+ const hooks = await subrouterPlugin(pluginInput)
591
+ const config: Config = {}
592
+ await hooks.config?.(config)
593
+
594
+ expect(config.provider?.subrouter?.models?.work).toBeUndefined()
595
+ expect(config.provider?.subrouter?.models?.['openai-only']).toMatchObject({
596
+ name: 'openai-only',
597
+ id: 'gpt-5.5',
598
+ })
599
+ expect(config.provider?.subrouter?.models?.['codex-b']).toMatchObject({
600
+ name: 'codex-b',
601
+ id: 'gpt-5.5:codex-b',
602
+ })
603
+ expect(config.provider?.subrouter?.models?.default?.id).toBeUndefined()
604
+ expect(config.provider?.subrouter?.options?.presetByApiId).toEqual({
605
+ 'gpt-5.5': 'openai-only',
606
+ 'gpt-5.5:codex-b': 'codex-b',
607
+ })
608
+ })
609
+
457
610
  test('rewrites the OpenCode powered-by line to the routed candidate', () => {
458
611
  const system = [
459
612
  '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 +645,84 @@ test('system transform rewrites the powered-by line to the live candidate', asyn
492
645
  expect(system[0]).not.toContain('subrouter/build')
493
646
  })
494
647
 
648
+ test('system transform uses the in-flight session route, not the first free preset model', async () => {
649
+ await addAccount({
650
+ provider: 'anthropic',
651
+ account: {
652
+ type: 'oauth',
653
+ refresh: 'refresh-1',
654
+ access: 'access-1',
655
+ expires: Date.now() + 60_000,
656
+ email: 'a@x.com',
657
+ addedAt: 1,
658
+ lastUsed: 1,
659
+ },
660
+ })
661
+ await addAccount({
662
+ provider: 'opencode-go',
663
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
664
+ })
665
+ await savePreset({
666
+ name: 'default',
667
+ models: ['anthropic/claude-fake', 'opencode-go/fake-model'],
668
+ })
669
+ await setLiveRoute({
670
+ sessionID: 'ses_1',
671
+ preset: 'default',
672
+ provider: 'opencode-go',
673
+ modelId: 'fake-model',
674
+ })
675
+
676
+ const system = [
677
+ 'You are powered by the model named default. The exact model ID is subrouter/default',
678
+ ]
679
+ await revealRoutedModel({
680
+ providerID: 'subrouter',
681
+ preset: 'default',
682
+ sessionID: 'ses_1',
683
+ system,
684
+ })
685
+
686
+ expect(system[0]).toContain('You are powered by the model named fake-model.')
687
+ expect(system[0]).toContain('The exact model ID is opencode-go/fake-model')
688
+ expect(system[0]).not.toContain('claude-fake')
689
+ })
690
+
691
+ test('system transform appends the apply_patch constraint for a live GPT route', async () => {
692
+ await addAccount({
693
+ provider: 'openai',
694
+ account: {
695
+ type: 'oauth',
696
+ refresh: 'refresh-o',
697
+ access: 'access-o',
698
+ expires: Date.now() + 60_000,
699
+ email: 'o@x.com',
700
+ addedAt: 1,
701
+ lastUsed: 1,
702
+ },
703
+ })
704
+ await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] })
705
+ await setLiveRoute({
706
+ sessionID: 'ses_gpt',
707
+ preset: 'openai-only',
708
+ provider: 'openai',
709
+ modelId: 'gpt-5.5',
710
+ })
711
+
712
+ const system = [
713
+ 'You are powered by the model named openai-only. The exact model ID is subrouter/openai-only',
714
+ ]
715
+ await revealRoutedModel({
716
+ providerID: 'subrouter',
717
+ preset: 'openai-only',
718
+ sessionID: 'ses_gpt',
719
+ system,
720
+ })
721
+
722
+ expect(system[0]).toContain('You are powered by the model named gpt-5.5.')
723
+ expect(system).toContain(applyPatchConstraint('gpt-5.5'))
724
+ })
725
+
495
726
  test('system transform leaves other providers unchanged', async () => {
496
727
  const original =
497
728
  '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
  }