@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/src/index.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
@@ -30,16 +32,24 @@ import {
30
32
  modelsDevInputModalities,
31
33
  modelsDevLimit,
32
34
  modelsDevModel,
35
+ variantProviderOptions,
33
36
  PROVIDER_DISPLAY_NAME,
34
37
  PROVIDER_ID,
35
38
  PROVIDER_IDS,
39
+ clearLiveRoute,
36
40
  resolveCandidates,
37
41
  resolvePresetModels,
42
+ RouteAffinity,
38
43
  type CooldownFallbackNotice,
39
44
  type StoredAccount,
40
45
  type SubrouterLog,
41
46
  } from '@subrouter/cli'
42
- import { addSubrouterHeaders, revealRoutedModel } from './provider.ts'
47
+ import {
48
+ addSubrouterHeaders,
49
+ applyPatchApiId,
50
+ revealRoutedModel,
51
+ shouldUseApplyPatch,
52
+ } from './provider.ts'
43
53
 
44
54
  function providerEntryUrl() {
45
55
  const isDev = import.meta.url.endsWith('.ts')
@@ -63,21 +73,37 @@ function opencodeLog(client: PluginInput['client'] | undefined): SubrouterLog |
63
73
 
64
74
  export const subrouterPlugin: Plugin = async ({ client, directory }) => {
65
75
  const log = opencodeLog(client)
66
- const pendingNotices = new Map<
67
- string,
68
- { agent: string; variant?: string; preset: string; text: string }
69
- >()
70
- const onCooldownFallback = (notice: CooldownFallbackNotice) => {
71
- if (!client || !notice.sessionID || !notice.agent || notice.agent === 'title') return
72
- if (pendingNotices.has(notice.sessionID)) return
76
+ const affinity = new RouteAffinity()
77
+ const activeMessages = new Map<string, string>()
78
+ const deliveredNotices = new Map<string, { text: string; expiresAt: number }>()
79
+ // Surface the fallback as a TUI toast, the same channel OpenCode plugins use for
80
+ // notifications (see kimaki's legacy anthropic/openai/xai auth plugins). A toast is
81
+ // not persisted into the transcript, so it never becomes a fake user/assistant
82
+ // message or enters model context. tui.toast.show is a global event with no session
83
+ // field, so append the OpenCode session id at the end: kimaki routes the toast to
84
+ // the matching Discord thread by that marker and strips it before display. Plain
85
+ // OpenCode TUI just shows the toast for the active session.
86
+ const onCooldownFallback = async (notice: CooldownFallbackNotice) => {
87
+ if (!notice.sessionID || !notice.agent || notice.agent === 'title') return
73
88
  const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`
74
89
  const active = `${notice.active.provider}/${notice.active.modelId}`
75
- pendingNotices.set(notice.sessionID, {
76
- agent: notice.agent,
77
- variant: notice.variant,
78
- preset: notice.preset,
79
- text: `Subrouter: ${preferred} was rate limited. This message started with ${active}.`,
80
- })
90
+ const text = `Subrouter: Using ${active} because ${preferred} is rate limited.`
91
+ const delivered = deliveredNotices.get(notice.sessionID)
92
+ if (delivered?.text === text && delivered.expiresAt > Date.now()) return
93
+ const current = { text, expiresAt: Date.now() + notice.preferred.retryAfterMs }
94
+ deliveredNotices.set(notice.sessionID, current)
95
+ const result = await client.tui
96
+ .showToast({
97
+ query: { directory },
98
+ body: { message: `${text} ${notice.sessionID}`, variant: 'info' },
99
+ throwOnError: true,
100
+ })
101
+ .catch((cause) => new Error('failed to show cooldown fallback toast', { cause }))
102
+ if (!(result instanceof Error)) return
103
+ if (deliveredNotices.get(notice.sessionID) === current) {
104
+ deliveredNotices.delete(notice.sessionID)
105
+ }
106
+ void log?.({ level: 'warn', message: result.message })
81
107
  }
82
108
  return {
83
109
  config: async (config) => {
@@ -86,56 +112,109 @@ export const subrouterPlugin: Plugin = async ({ client, directory }) => {
86
112
  })
87
113
  const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)])
88
114
  const catalog = await loadModelsDevCatalog({ log })
115
+ const takenApiIds = new Set<string>()
116
+ const presetByApiId: Record<string, string> = {}
117
+ const resolved = await Promise.all(
118
+ [...names].map(async (name) => {
119
+ const presetModels = await resolvePresetModels(name)
120
+ const candidates =
121
+ presetModels instanceof Error
122
+ ? []
123
+ : (await resolveCandidates({ presetModels })).candidates
124
+ const candidate = candidates[0]
125
+ const limit = candidate
126
+ ? modelsDevLimit({
127
+ provider: candidate.provider,
128
+ modelId: candidate.modelId,
129
+ catalog,
130
+ })
131
+ : null
132
+ const input = new Set<'text' | 'audio' | 'image' | 'video' | 'pdf'>(['text'])
133
+ let attachment = false
134
+ for (const current of candidates) {
135
+ const model = modelsDevModel({
136
+ provider: current.provider,
137
+ modelId: current.modelId,
138
+ catalog,
139
+ })
140
+ const modalities = modelsDevInputModalities({
141
+ provider: current.provider,
142
+ modelId: current.modelId,
143
+ catalog,
144
+ })
145
+ if (!model || !modalities) continue
146
+ attachment ||= model.attachment && modalities.some((modality) => modality !== 'text')
147
+ for (const modality of modalities) input.add(modality)
148
+ }
149
+ return { name, candidate, attachment, input, limit }
150
+ }),
151
+ )
89
152
  const models = Object.fromEntries(
90
- await Promise.all(
91
- [...names].map(async (name) => {
92
- const presetModels = await resolvePresetModels(name)
93
- const candidates =
94
- presetModels instanceof Error
95
- ? []
96
- : (await resolveCandidates({ presetModels })).candidates
97
- const candidate = candidates[0]
98
- const limit = candidate
99
- ? modelsDevLimit({
100
- provider: candidate.provider,
153
+ resolved.map(({ name, candidate, attachment, input, limit }) => {
154
+ const apiId =
155
+ candidate && shouldUseApplyPatch(candidate.modelId)
156
+ ? applyPatchApiId({
157
+ preset: name,
101
158
  modelId: candidate.modelId,
102
- catalog,
159
+ taken: takenApiIds,
103
160
  })
104
- : null
105
- const input = new Set<'text' | 'audio' | 'image' | 'video' | 'pdf'>(['text'])
106
- let attachment = false
107
- for (const current of candidates) {
108
- const model = modelsDevModel({
109
- provider: current.provider,
110
- modelId: current.modelId,
111
- catalog,
112
- })
113
- const modalities = modelsDevInputModalities({
114
- provider: current.provider,
115
- modelId: current.modelId,
161
+ : undefined
162
+ if (apiId) {
163
+ takenApiIds.add(apiId)
164
+ presetByApiId[apiId] = name
165
+ }
166
+ const catalogModel = candidate
167
+ ? modelsDevModel({
168
+ provider: candidate.provider,
169
+ modelId: candidate.modelId,
116
170
  catalog,
117
171
  })
118
- if (!model || !modalities) continue
119
- attachment ||= model.attachment && modalities.some((modality) => modality !== 'text')
120
- for (const modality of modalities) input.add(modality)
172
+ : null
173
+ const reasoning = catalogModel?.reasoning ?? false
174
+ const variants = candidate
175
+ ? Object.fromEntries(
176
+ (catalogModel?.variants ?? []).map((variant) => [
177
+ variant,
178
+ variantProviderOptions({
179
+ provider: candidate.provider,
180
+ modelId: candidate.modelId,
181
+ variant,
182
+ }),
183
+ ]),
184
+ )
185
+ : {}
186
+ const model: {
187
+ name: string
188
+ id?: string
189
+ tool_call: true
190
+ attachment: boolean
191
+ reasoning: boolean
192
+ variants?: Record<
193
+ string,
194
+ { thinking?: { type: string }; effort?: string; reasoningEffort?: string; reasoningSummary?: string }
195
+ >
196
+ modalities: {
197
+ input: Array<'text' | 'audio' | 'image' | 'video' | 'pdf'>
198
+ output: Array<'text'>
121
199
  }
122
- return [
123
- name,
124
- {
125
- name,
126
- tool_call: true,
127
- attachment,
128
- reasoning: false,
129
- modalities: {
130
- input: [...input],
131
- output: ['text'] satisfies Array<'text'>,
132
- },
133
- cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
134
- limit: limit ?? { context: 200_000, output: 64_000 },
135
- },
136
- ]
137
- }),
138
- ),
200
+ cost: { input: number; output: number; cache_read: number; cache_write: number }
201
+ limit: { context: number; input?: number; output: number }
202
+ } = {
203
+ name,
204
+ tool_call: true,
205
+ attachment,
206
+ reasoning,
207
+ modalities: {
208
+ input: [...input],
209
+ output: ['text'],
210
+ },
211
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
212
+ limit: limit ?? { context: 200_000, output: 64_000 },
213
+ }
214
+ if (apiId) model.id = apiId
215
+ if (Object.keys(variants).length > 0) model.variants = variants
216
+ return [name, model]
217
+ }),
139
218
  )
140
219
  config.provider = {
141
220
  ...config.provider,
@@ -143,36 +222,37 @@ export const subrouterPlugin: Plugin = async ({ client, directory }) => {
143
222
  name: PROVIDER_DISPLAY_NAME,
144
223
  npm: providerEntryUrl(),
145
224
  models,
146
- options: { log, onCooldownFallback },
225
+ options: { affinity, log, onCooldownFallback, presetByApiId },
147
226
  },
148
227
  }
149
228
  },
150
229
  event: async ({ event }) => {
151
- if (event.type !== 'session.idle') return
152
- const pending = pendingNotices.get(event.properties.sessionID)
153
- if (!pending) return
154
- pendingNotices.delete(event.properties.sessionID)
155
- const body = {
156
- noReply: true,
157
- agent: pending.agent,
158
- model: { providerID: PROVIDER_ID, modelID: pending.preset },
159
- variant: pending.variant,
160
- parts: [{ type: 'text' as const, text: pending.text, ignored: true }],
230
+ if (event.type === 'session.deleted') {
231
+ const sessionID = event.properties.info.id
232
+ const messageID = activeMessages.get(sessionID)
233
+ if (messageID) affinity.clear(messageID)
234
+ activeMessages.delete(sessionID)
235
+ deliveredNotices.delete(sessionID)
236
+ await clearLiveRoute(sessionID)
237
+ return
161
238
  }
162
- await client.session
163
- .prompt({
164
- path: { id: event.properties.sessionID },
165
- query: { directory },
166
- body,
167
- })
168
- .catch(() => {})
239
+ if (event.type !== 'session.idle') return
240
+ const sessionID = event.properties.sessionID
241
+ const messageID = activeMessages.get(sessionID)
242
+ if (messageID) affinity.clear(messageID)
243
+ activeMessages.delete(sessionID)
244
+ await clearLiveRoute(sessionID)
245
+ },
246
+ 'chat.headers': async (input, output) => {
247
+ const activeMessage = activeMessages.get(input.sessionID) ?? input.message.id
248
+ const affinityKey = addSubrouterHeaders({ input, output, affinityKey: activeMessage })
249
+ if (affinityKey) activeMessages.set(input.sessionID, affinityKey)
169
250
  },
170
- 'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
171
- // OpenCode identity uses the preset id; rewrite it to the live routed model.
172
251
  'experimental.chat.system.transform': async (input, output) => {
173
252
  await revealRoutedModel({
174
253
  providerID: input.model.providerID,
175
254
  preset: input.model.id,
255
+ sessionID: input.sessionID,
176
256
  system: output.system,
177
257
  })
178
258
  },
@@ -197,7 +277,8 @@ function toOpencodeCredentials(account: StoredAccount) {
197
277
  }
198
278
  }
199
279
 
200
- export const subrouterAuthPlugin: Plugin = async () => {
280
+ export const subrouterAuthPlugin: Plugin = async ({ client }) => {
281
+ const log = opencodeLog(client)
201
282
  return {
202
283
  auth: {
203
284
  provider: PROVIDER_ID,
@@ -247,7 +328,18 @@ export const subrouterAuthPlugin: Plugin = async () => {
247
328
 
248
329
  const finish = async (input?: string) => {
249
330
  const account = await session.complete(input)
250
- if (account instanceof Error) return { type: 'failed' as const }
331
+ if (account instanceof Error) {
332
+ // opencode's AuthOAuthResult has no failure-with-message
333
+ // variant, so the real reason cannot travel back through
334
+ // oauth.callback and harnesses show a generic fallback. Log it
335
+ // here so the cause is diagnosable instead of lost.
336
+ void log?.({
337
+ level: 'error',
338
+ message: `login failed: ${account.message}`,
339
+ extra: { provider: providerId },
340
+ })
341
+ return { type: 'failed' as const }
342
+ }
251
343
  await addAccount({ provider: providerId, account })
252
344
  return toOpencodeCredentials(account)
253
345
  }