@subrouter/opencode 0.2.0 → 0.4.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.
@@ -1,23 +1,38 @@
1
+ import childProcess from 'node:child_process'
1
2
  import { createServer, type Server } from 'node:http'
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { mkdtemp, rm } from 'node:fs/promises'
3
4
  import { tmpdir } from 'node:os'
4
5
  import path from 'node:path'
6
+ import util from 'node:util'
5
7
  import { afterEach, beforeEach, expect, test } from 'vitest'
6
- import type { PluginInput } from '@opencode-ai/plugin'
7
- import { loadAccounts, PROVIDER_IDS } from '@subrouter/cli'
8
+ import type { Config, PluginInput } from '@opencode-ai/plugin'
9
+ import { createOpencodeClient, type Event } from '@opencode-ai/sdk'
10
+ import {
11
+ addAccount,
12
+ adapters,
13
+ loadAccounts,
14
+ PROVIDER_DISPLAY_NAME,
15
+ PROVIDER_IDS,
16
+ savePreset,
17
+ } from '@subrouter/cli'
8
18
  import { subrouterAuthPlugin, subrouterPlugin } from './index.ts'
19
+ import { revealRoutedModel, rewritePoweredByModelLine } from './provider.ts'
9
20
 
21
+ const execFile = util.promisify(childProcess.execFile)
10
22
  let home: string
11
23
  const openServers: Server[] = []
24
+ const pluginInput = {} as PluginInput
12
25
 
13
26
  beforeEach(async () => {
14
27
  home = await mkdtemp(path.join(tmpdir(), 'subrouter-plugin-'))
15
28
  process.env.SUBROUTER_HOME = home
29
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev()
16
30
  })
17
31
 
18
32
  afterEach(async () => {
19
33
  delete process.env.SUBROUTER_HOME
20
34
  delete process.env.SUBROUTER_OPENAI_ISSUER_URL
35
+ delete process.env.SUBROUTER_MODELS_DEV_URL
21
36
  for (const server of openServers.splice(0)) {
22
37
  await new Promise<void>((resolve) => {
23
38
  server.close(() => {
@@ -28,6 +43,51 @@ afterEach(async () => {
28
43
  await rm(home, { recursive: true, force: true })
29
44
  })
30
45
 
46
+ async function startFakeModelsDev(
47
+ providers: Record<
48
+ string,
49
+ {
50
+ models: Record<
51
+ string,
52
+ {
53
+ id: string
54
+ attachment?: boolean
55
+ reasoning?: boolean
56
+ temperature?: boolean
57
+ tool_call?: boolean
58
+ modalities?: { input?: string[]; output?: string[] }
59
+ limit?: { context?: number; output?: number; input?: number }
60
+ }
61
+ >
62
+ }
63
+ > = {},
64
+ ) {
65
+ const payload = {
66
+ anthropic: { models: {} },
67
+ openai: { models: {} },
68
+ xai: { models: {} },
69
+ 'opencode-go': { models: {} },
70
+ 'github-copilot': { models: {} },
71
+ poe: { models: {} },
72
+ 'minimax-coding-plan': { models: {} },
73
+ 'kimi-for-coding': { models: {} },
74
+ 'zai-coding-plan': { models: {} },
75
+ 'alibaba-coding-plan': { models: {} },
76
+ ...providers,
77
+ }
78
+ const server = createServer((_req, res) => {
79
+ res.writeHead(200, { 'Content-Type': 'application/json' })
80
+ res.end(JSON.stringify(payload))
81
+ })
82
+ await new Promise<void>((resolve) => {
83
+ server.listen(0, '127.0.0.1', resolve)
84
+ })
85
+ const address = server.address()
86
+ if (typeof address === 'string' || !address) throw new Error('failed to bind fake models.dev')
87
+ openServers.push(server)
88
+ return `http://127.0.0.1:${address.port}`
89
+ }
90
+
31
91
  /** Minimal stand-in for the OpenAI Codex device endpoints. */
32
92
  async function startFakeOpenAIIssuer() {
33
93
  const server = createServer((req, res) => {
@@ -65,29 +125,379 @@ async function startFakeOpenAIIssuer() {
65
125
  }
66
126
 
67
127
  function authMethod() {
68
- return subrouterAuthPlugin({} as PluginInput).then((hooks) => {
128
+ return subrouterAuthPlugin(pluginInput).then((hooks) => {
69
129
  const method = hooks.auth?.methods[0]
70
130
  if (!method || method.type !== 'oauth') throw new Error('expected an oauth method')
71
131
  return { provider: hooks.auth!.provider, method }
72
132
  })
73
133
  }
74
134
 
135
+ test('plugin load and config do not write stdout or stderr', async () => {
136
+ const script = "import('./src/index.ts').then(async ({ subrouterPlugin }) => { const hooks = await subrouterPlugin({}); await hooks.config?.({}) })"
137
+ const result = await execFile(process.execPath, ['--no-warnings', '--import', 'tsx', '--eval', script], {
138
+ cwd: process.cwd(),
139
+ env: process.env,
140
+ })
141
+ expect(result).toEqual({ stdout: '', stderr: '' })
142
+ })
143
+
144
+ test('provider log callback forwards only to client.app.log', async () => {
145
+ const received = Promise.withResolvers<object>()
146
+ const server = createServer((req, res) => {
147
+ const chunks: Buffer[] = []
148
+ req.on('data', (chunk: Buffer) => {
149
+ chunks.push(chunk)
150
+ })
151
+ req.on('end', () => {
152
+ received.resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as object)
153
+ res.writeHead(200, { 'Content-Type': 'application/json' })
154
+ res.end('{}')
155
+ })
156
+ })
157
+ await new Promise<void>((resolve) => {
158
+ server.listen(0, '127.0.0.1', resolve)
159
+ })
160
+ openServers.push(server)
161
+ const address = server.address()
162
+ if (typeof address === 'string' || !address) throw new Error('failed to bind log server')
163
+ const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` })
164
+ const hooks = await subrouterPlugin({ ...pluginInput, client })
165
+ const config: Config = {}
166
+ await hooks.config?.(config)
167
+ const log = config.provider?.subrouter?.options?.log
168
+ expect(log).toBeTypeOf('function')
169
+ if (typeof log !== 'function') throw new Error('expected provider log callback')
170
+ await log({
171
+ level: 'warn',
172
+ message: 'failover openai/gpt-5.5',
173
+ extra: { provider: 'openai', modelId: 'gpt-5.5' },
174
+ })
175
+ expect(await received.promise).toEqual({
176
+ service: 'subrouter',
177
+ level: 'warn',
178
+ message: 'failover openai/gpt-5.5',
179
+ extra: { provider: 'openai', modelId: 'gpt-5.5' },
180
+ })
181
+ })
182
+
183
+ test('cooldown fallback creates only a persisted ignored notice after idle', async () => {
184
+ const requests: Array<{ path: string; body: any }> = []
185
+ const server = createServer((req, res) => {
186
+ const chunks: Buffer[] = []
187
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
188
+ req.on('end', () => {
189
+ requests.push({
190
+ path: req.url ?? '',
191
+ body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
192
+ })
193
+ res.writeHead(200, { 'Content-Type': 'application/json' })
194
+ res.end('{}')
195
+ })
196
+ })
197
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
198
+ openServers.push(server)
199
+ const address = server.address()
200
+ if (typeof address === 'string' || !address) throw new Error('failed to bind notification server')
201
+ const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${address.port}` })
202
+ const hooks = await subrouterPlugin({ ...pluginInput, client, directory: '/tmp/project' })
203
+ const config: Config = {}
204
+ await hooks.config?.(config)
205
+ const onCooldownFallback = config.provider?.subrouter?.options?.onCooldownFallback
206
+ expect(onCooldownFallback).toBeTypeOf('function')
207
+ if (typeof onCooldownFallback !== 'function') throw new Error('expected cooldown callback')
208
+
209
+ await onCooldownFallback({
210
+ sessionID: 'session-1',
211
+ agent: 'build',
212
+ variant: 'high',
213
+ preset: 'work',
214
+ preferred: { provider: 'xai', modelId: 'grok-4.6', retryAfterMs: 252_000 },
215
+ 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 })
229
+
230
+ expect(requests).toEqual([
231
+ {
232
+ path: '/session/session-1/message?directory=%2Ftmp%2Fproject',
233
+ 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
+ ],
245
+ },
246
+ },
247
+ ])
248
+ })
249
+
75
250
  test('config hook registers the subrouter provider with preset models', async () => {
76
- await writeFile(
77
- path.join(home, 'presets.json'),
78
- JSON.stringify({ version: 1, presets: { work: ['anthropic/claude-opus-4-6'] } }),
79
- )
251
+ await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] })
80
252
 
81
- const hooks = await subrouterPlugin({} as PluginInput)
253
+ const hooks = await subrouterPlugin(pluginInput)
82
254
  const config: Record<string, any> = {}
83
255
  await hooks.config?.(config as any)
84
256
 
85
257
  const provider = config.provider?.subrouter
86
258
  expect(provider).toBeTruthy()
259
+ expect(provider.name).toBe(PROVIDER_DISPLAY_NAME)
87
260
  expect(provider.npm.startsWith('file://')).toBe(true)
88
261
  expect(provider.npm.endsWith('provider.ts') || provider.npm.endsWith('provider.js')).toBe(true)
89
262
  expect(Object.keys(provider.models).sort()).toEqual(['default', 'work'])
263
+ expect(provider.models.default.name).toBe('default')
264
+ expect(provider.models.work.name).toBe('work')
90
265
  expect(provider.models.default.cost).toEqual({ input: 0, output: 0, cache_read: 0, cache_write: 0 })
266
+ expect(provider.models.default.limit).toEqual({ context: 200_000, output: 64_000 })
267
+ expect(provider.models.work.limit).toEqual({ context: 200_000, output: 64_000 })
268
+ })
269
+
270
+ test('preset model names stay stable when the routed candidate changes', async () => {
271
+ await addAccount({
272
+ provider: 'anthropic',
273
+ account: {
274
+ type: 'oauth',
275
+ refresh: 'refresh-1',
276
+ access: 'access-1',
277
+ expires: Date.now() + 60_000,
278
+ email: 'a@x.com',
279
+ addedAt: 1,
280
+ lastUsed: 1,
281
+ },
282
+ })
283
+ await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] })
284
+
285
+ const hooks = await subrouterPlugin(pluginInput)
286
+ const config: Config = {}
287
+ await hooks.config?.(config)
288
+
289
+ expect(config.provider?.subrouter?.name).toBe(PROVIDER_DISPLAY_NAME)
290
+ expect(config.provider?.subrouter?.models?.work?.name).toBe('work')
291
+ })
292
+
293
+ test('preset model limits follow the first live candidate', async () => {
294
+ const modelId = adapters.anthropic.defaultModels[0]
295
+ if (!modelId) throw new Error('anthropic adapter has no default model')
296
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
297
+ anthropic: {
298
+ models: {
299
+ [modelId]: {
300
+ id: modelId,
301
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
302
+ limit: { context: 1_000_000, input: 900_000, output: 128_000 },
303
+ },
304
+ },
305
+ },
306
+ })
307
+ await addAccount({
308
+ provider: 'anthropic',
309
+ account: {
310
+ type: 'oauth',
311
+ refresh: 'refresh-1',
312
+ access: 'access-1',
313
+ expires: Date.now() + 60_000,
314
+ email: 'a@x.com',
315
+ addedAt: 1,
316
+ lastUsed: 1,
317
+ },
318
+ })
319
+ await savePreset({ name: 'work', models: [`anthropic/${modelId}`] })
320
+
321
+ const hooks = await subrouterPlugin(pluginInput)
322
+ const config: Config = {}
323
+ await hooks.config?.(config)
324
+
325
+ expect(config.provider?.subrouter?.models?.work?.limit).toEqual({
326
+ context: 1_000_000,
327
+ input: 900_000,
328
+ output: 128_000,
329
+ })
330
+ expect(config.provider?.subrouter?.models?.default?.limit).toEqual({
331
+ context: 1_000_000,
332
+ input: 900_000,
333
+ output: 128_000,
334
+ })
335
+ })
336
+
337
+ test('preset model input modalities are the union of usable candidates', async () => {
338
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
339
+ openai: {
340
+ models: {
341
+ 'gpt-pdf': {
342
+ id: 'gpt-pdf',
343
+ attachment: true,
344
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
345
+ },
346
+ },
347
+ },
348
+ 'kimi-for-coding': {
349
+ models: {
350
+ 'kimi-image': {
351
+ id: 'kimi-image',
352
+ attachment: true,
353
+ modalities: { input: ['text', 'image'], output: ['text'] },
354
+ },
355
+ },
356
+ },
357
+ })
358
+ await addAccount({
359
+ provider: 'openai',
360
+ account: {
361
+ type: 'oauth',
362
+ access: 'access-1',
363
+ refresh: 'refresh-1',
364
+ expires: Date.now() + 60_000,
365
+ addedAt: 1,
366
+ lastUsed: 1,
367
+ },
368
+ })
369
+ await addAccount({
370
+ provider: 'kimi',
371
+ account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
372
+ })
373
+ await savePreset({ name: 'work', models: ['openai/gpt-pdf', 'kimi/kimi-image'] })
374
+
375
+ const hooks = await subrouterPlugin(pluginInput)
376
+ const config: Config = {}
377
+ await hooks.config?.(config)
378
+
379
+ expect(config.provider?.subrouter?.models?.work).toMatchObject({
380
+ attachment: true,
381
+ modalities: {
382
+ input: ['text', 'image', 'pdf'],
383
+ output: ['text'],
384
+ },
385
+ })
386
+ })
387
+
388
+ test('xAI presets do not advertise inline PDF support that its SDK cannot encode', async () => {
389
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
390
+ xai: {
391
+ models: {
392
+ 'grok-pdf': {
393
+ id: 'grok-pdf',
394
+ attachment: true,
395
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
396
+ },
397
+ },
398
+ },
399
+ })
400
+ await addAccount({
401
+ provider: 'xai',
402
+ account: {
403
+ type: 'oauth',
404
+ access: 'access-1',
405
+ refresh: 'refresh-1',
406
+ expires: Date.now() + 60_000,
407
+ addedAt: 1,
408
+ lastUsed: 1,
409
+ },
410
+ })
411
+ await savePreset({ name: 'work', models: ['xai/grok-pdf'] })
412
+
413
+ const hooks = await subrouterPlugin(pluginInput)
414
+ const config: Config = {}
415
+ await hooks.config?.(config)
416
+
417
+ expect(config.provider?.subrouter?.models?.work).toMatchObject({
418
+ attachment: true,
419
+ modalities: {
420
+ input: ['text', 'image'],
421
+ output: ['text'],
422
+ },
423
+ })
424
+ })
425
+
426
+ test('Anthropic-compatible coding plans do not advertise video input', async () => {
427
+ process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
428
+ 'kimi-for-coding': {
429
+ models: {
430
+ 'kimi-video': {
431
+ id: 'kimi-video',
432
+ attachment: true,
433
+ modalities: { input: ['text', 'image', 'video'], output: ['text'] },
434
+ },
435
+ },
436
+ },
437
+ })
438
+ await addAccount({
439
+ provider: 'kimi',
440
+ account: { type: 'api', key: 'kimi-key', addedAt: 1, lastUsed: 1 },
441
+ })
442
+ await savePreset({ name: 'work', models: ['kimi/kimi-video'] })
443
+
444
+ const hooks = await subrouterPlugin(pluginInput)
445
+ const config: Config = {}
446
+ await hooks.config?.(config)
447
+
448
+ expect(config.provider?.subrouter?.models?.work).toMatchObject({
449
+ attachment: true,
450
+ modalities: {
451
+ input: ['text', 'image'],
452
+ output: ['text'],
453
+ },
454
+ })
455
+ })
456
+
457
+ test('rewrites the OpenCode powered-by line to the routed candidate', () => {
458
+ const system = [
459
+ 'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
460
+ ]
461
+ rewritePoweredByModelLine({
462
+ system,
463
+ candidate: { provider: 'anthropic', modelId: 'claude-opus-4-6' },
464
+ })
465
+ expect(system[0]).toContain('You are powered by the model named claude-opus-4-6.')
466
+ expect(system[0]).toContain('The exact model ID is anthropic/claude-opus-4-6')
467
+ expect(system[0]).not.toContain('subrouter/build')
468
+ })
469
+
470
+ test('system transform rewrites the powered-by line to the live candidate', async () => {
471
+ await addAccount({
472
+ provider: 'anthropic',
473
+ account: {
474
+ type: 'oauth',
475
+ refresh: 'refresh-1',
476
+ access: 'access-1',
477
+ expires: Date.now() + 60_000,
478
+ email: 'a@x.com',
479
+ addedAt: 1,
480
+ lastUsed: 1,
481
+ },
482
+ })
483
+
484
+ const system = [
485
+ 'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
486
+ ]
487
+ await revealRoutedModel({ providerID: 'subrouter', preset: 'default', system })
488
+
489
+ const modelId = adapters.anthropic.defaultModels[0]
490
+ expect(system[0]).toContain(`You are powered by the model named ${modelId}.`)
491
+ expect(system[0]).toContain(`The exact model ID is anthropic/${modelId}`)
492
+ expect(system[0]).not.toContain('subrouter/build')
493
+ })
494
+
495
+ test('system transform leaves other providers unchanged', async () => {
496
+ const original =
497
+ 'You are powered by the model named claude-opus-4-6. The exact model ID is anthropic/claude-opus-4-6'
498
+ const system = [original]
499
+ await revealRoutedModel({ providerID: 'anthropic', preset: 'claude-opus-4-6', system })
500
+ expect(system[0]).toBe(original)
91
501
  })
92
502
 
93
503
  test('auth hook asks which subscription to add before authorizing', async () => {
@@ -132,26 +542,26 @@ test('authorize dispatches to the chosen adapter and the callback pools the acco
132
542
  ])
133
543
  })
134
544
 
135
- test('opencode zen asks for a pasted key and stores it as an api account', async () => {
545
+ test('opencode go asks for a pasted key and stores it as an api account', async () => {
136
546
  const { method } = await authMethod()
137
547
 
138
- const result = await method.authorize({ provider: 'opencode' })
548
+ const result = await method.authorize({ provider: 'opencode-go' })
139
549
  expect(result.method).toBe('code')
140
550
 
141
551
  if (result.method !== 'code') throw new Error('expected a pasted-key flow')
142
- expect(await result.callback('zen-key-1')).toMatchObject({ type: 'success', key: 'zen-key-1' })
552
+ expect(await result.callback('go-key-1')).toMatchObject({ type: 'success', key: 'go-key-1' })
143
553
 
144
554
  const accounts = await loadAccounts()
145
- expect(accounts.providers.opencode?.accounts).toMatchObject([{ type: 'api', key: 'zen-key-1' }])
555
+ expect(accounts.providers['opencode-go']?.accounts).toMatchObject([{ type: 'api', key: 'go-key-1' }])
146
556
  })
147
557
 
148
558
  test('a failed login reports failure instead of pooling a broken account', async () => {
149
559
  const { method } = await authMethod()
150
560
 
151
- const result = await method.authorize({ provider: 'opencode' })
561
+ const result = await method.authorize({ provider: 'opencode-go' })
152
562
  if (result.method !== 'code') throw new Error('expected a pasted-key flow')
153
563
 
154
564
  expect(await result.callback(' ')).toEqual({ type: 'failed' })
155
565
  const accounts = await loadAccounts()
156
- expect(accounts.providers.opencode).toBeUndefined()
566
+ expect(accounts.providers['opencode-go']).toBeUndefined()
157
567
  })
package/src/provider.ts CHANGED
@@ -2,20 +2,69 @@
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
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.
5
6
  */
6
7
 
7
8
  import {
9
+ OPENCODE_AGENT_HEADER,
10
+ OPENCODE_VARIANT_HEADER,
8
11
  OPENAI_WEBSOCKET_SESSION_HEADER,
9
12
  OPENAI_WEBSOCKET_TITLE_HEADER,
13
+ PROVIDER_ID,
14
+ resolveActiveCandidate,
10
15
  } from '@subrouter/cli'
11
16
 
12
17
  export { createSubrouter } from '@subrouter/cli'
13
18
 
19
+ const POWERED_BY_MODEL = /You are powered by the model named [^\n]+/
20
+
21
+ export function rewritePoweredByModelLine({
22
+ system,
23
+ candidate,
24
+ }: {
25
+ system: string[]
26
+ candidate: { provider: string; modelId: string }
27
+ }) {
28
+ const line = `You are powered by the model named ${candidate.modelId}. The exact model ID is ${candidate.provider}/${candidate.modelId}`
29
+ for (let i = 0; i < system.length; i++) {
30
+ system[i] = system[i]!.replace(POWERED_BY_MODEL, line)
31
+ }
32
+ }
33
+
34
+ export async function revealRoutedModel({
35
+ providerID,
36
+ preset,
37
+ system,
38
+ }: {
39
+ providerID: string
40
+ preset: string
41
+ system: string[]
42
+ }) {
43
+ if (providerID !== PROVIDER_ID) return
44
+ const candidate = await resolveActiveCandidate(preset)
45
+ if (!candidate) return
46
+ rewritePoweredByModelLine({ system, candidate })
47
+ }
48
+
14
49
  export function addSubrouterHeaders(
15
- input: { sessionID: string; agent: string; model: { providerID: string } },
50
+ input: {
51
+ sessionID: string
52
+ agent: string
53
+ model: { providerID: string }
54
+ message: {
55
+ agent: string
56
+ model: { providerID: string; modelID: string; variant?: string }
57
+ }
58
+ },
16
59
  output: { headers: Record<string, string> },
17
60
  ) {
18
- if (input.model.providerID !== 'subrouter') return
61
+ if (input.model.providerID !== PROVIDER_ID) return
19
62
  output.headers[OPENAI_WEBSOCKET_SESSION_HEADER] = input.sessionID
63
+ if (input.agent === input.message.agent) {
64
+ output.headers[OPENCODE_AGENT_HEADER] = input.message.agent
65
+ if (input.message.model.variant) {
66
+ output.headers[OPENCODE_VARIANT_HEADER] = input.message.model.variant
67
+ }
68
+ }
20
69
  if (input.agent === 'title') output.headers[OPENAI_WEBSOCKET_TITLE_HEADER] = 'true'
21
70
  }