@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.
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,38 @@ 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
+ const onCooldownFallback = async (notice: CooldownFallbackNotice) => {
80
+ if (!notice.sessionID || !notice.agent || notice.agent === 'title') return
73
81
  const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`
74
82
  const active = `${notice.active.provider}/${notice.active.modelId}`
75
- pendingNotices.set(notice.sessionID, {
83
+ const text = `Subrouter: Using ${active} because ${preferred} is rate limited.`
84
+ const delivered = deliveredNotices.get(notice.sessionID)
85
+ if (delivered?.text === text && delivered.expiresAt > Date.now()) return
86
+ const current = { text, expiresAt: Date.now() + notice.preferred.retryAfterMs }
87
+ deliveredNotices.set(notice.sessionID, current)
88
+ const body = {
89
+ noReply: true,
76
90
  agent: notice.agent,
91
+ model: { providerID: PROVIDER_ID, modelID: notice.preset },
77
92
  variant: notice.variant,
78
- preset: notice.preset,
79
- text: `Subrouter: ${preferred} was rate limited. This message started with ${active}.`,
80
- })
93
+ parts: [{ type: 'text' as const, text, ignored: true }],
94
+ }
95
+ const result = await client.session
96
+ .prompt({
97
+ path: { id: notice.sessionID },
98
+ query: { directory },
99
+ body,
100
+ throwOnError: true,
101
+ })
102
+ .catch((cause) => new Error('failed to persist cooldown fallback notice', { cause }))
103
+ if (!(result instanceof Error)) return
104
+ if (deliveredNotices.get(notice.sessionID) === current) {
105
+ deliveredNotices.delete(notice.sessionID)
106
+ }
107
+ void log?.({ level: 'warn', message: result.message })
81
108
  }
82
109
  return {
83
110
  config: async (config) => {
@@ -86,56 +113,109 @@ export const subrouterPlugin: Plugin = async ({ client, directory }) => {
86
113
  })
87
114
  const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)])
88
115
  const catalog = await loadModelsDevCatalog({ log })
116
+ const takenApiIds = new Set<string>()
117
+ const presetByApiId: Record<string, string> = {}
118
+ const resolved = await Promise.all(
119
+ [...names].map(async (name) => {
120
+ const presetModels = await resolvePresetModels(name)
121
+ const candidates =
122
+ presetModels instanceof Error
123
+ ? []
124
+ : (await resolveCandidates({ presetModels })).candidates
125
+ const candidate = candidates[0]
126
+ const limit = candidate
127
+ ? modelsDevLimit({
128
+ provider: candidate.provider,
129
+ modelId: candidate.modelId,
130
+ catalog,
131
+ })
132
+ : null
133
+ const input = new Set<'text' | 'audio' | 'image' | 'video' | 'pdf'>(['text'])
134
+ let attachment = false
135
+ for (const current of candidates) {
136
+ const model = modelsDevModel({
137
+ provider: current.provider,
138
+ modelId: current.modelId,
139
+ catalog,
140
+ })
141
+ const modalities = modelsDevInputModalities({
142
+ provider: current.provider,
143
+ modelId: current.modelId,
144
+ catalog,
145
+ })
146
+ if (!model || !modalities) continue
147
+ attachment ||= model.attachment && modalities.some((modality) => modality !== 'text')
148
+ for (const modality of modalities) input.add(modality)
149
+ }
150
+ return { name, candidate, attachment, input, limit }
151
+ }),
152
+ )
89
153
  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,
154
+ resolved.map(({ name, candidate, attachment, input, limit }) => {
155
+ const apiId =
156
+ candidate && shouldUseApplyPatch(candidate.modelId)
157
+ ? applyPatchApiId({
158
+ preset: name,
101
159
  modelId: candidate.modelId,
102
- catalog,
160
+ taken: takenApiIds,
103
161
  })
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,
162
+ : undefined
163
+ if (apiId) {
164
+ takenApiIds.add(apiId)
165
+ presetByApiId[apiId] = name
166
+ }
167
+ const catalogModel = candidate
168
+ ? modelsDevModel({
169
+ provider: candidate.provider,
170
+ modelId: candidate.modelId,
111
171
  catalog,
112
172
  })
113
- const modalities = modelsDevInputModalities({
114
- provider: current.provider,
115
- modelId: current.modelId,
116
- catalog,
117
- })
118
- if (!model || !modalities) continue
119
- attachment ||= model.attachment && modalities.some((modality) => modality !== 'text')
120
- for (const modality of modalities) input.add(modality)
173
+ : null
174
+ const reasoning = catalogModel?.reasoning ?? false
175
+ const variants = candidate
176
+ ? Object.fromEntries(
177
+ (catalogModel?.variants ?? []).map((variant) => [
178
+ variant,
179
+ variantProviderOptions({
180
+ provider: candidate.provider,
181
+ modelId: candidate.modelId,
182
+ variant,
183
+ }),
184
+ ]),
185
+ )
186
+ : {}
187
+ const model: {
188
+ name: string
189
+ id?: string
190
+ tool_call: true
191
+ attachment: boolean
192
+ reasoning: boolean
193
+ variants?: Record<
194
+ string,
195
+ { thinking?: { type: string }; effort?: string; reasoningEffort?: string; reasoningSummary?: string }
196
+ >
197
+ modalities: {
198
+ input: Array<'text' | 'audio' | 'image' | 'video' | 'pdf'>
199
+ output: Array<'text'>
121
200
  }
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
- ),
201
+ cost: { input: number; output: number; cache_read: number; cache_write: number }
202
+ limit: { context: number; input?: number; output: number }
203
+ } = {
204
+ name,
205
+ tool_call: true,
206
+ attachment,
207
+ reasoning,
208
+ modalities: {
209
+ input: [...input],
210
+ output: ['text'],
211
+ },
212
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
213
+ limit: limit ?? { context: 200_000, output: 64_000 },
214
+ }
215
+ if (apiId) model.id = apiId
216
+ if (Object.keys(variants).length > 0) model.variants = variants
217
+ return [name, model]
218
+ }),
139
219
  )
140
220
  config.provider = {
141
221
  ...config.provider,
@@ -143,36 +223,37 @@ export const subrouterPlugin: Plugin = async ({ client, directory }) => {
143
223
  name: PROVIDER_DISPLAY_NAME,
144
224
  npm: providerEntryUrl(),
145
225
  models,
146
- options: { log, onCooldownFallback },
226
+ options: { affinity, log, onCooldownFallback, presetByApiId },
147
227
  },
148
228
  }
149
229
  },
150
230
  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 }],
231
+ if (event.type === 'session.deleted') {
232
+ const sessionID = event.properties.info.id
233
+ const messageID = activeMessages.get(sessionID)
234
+ if (messageID) affinity.clear(messageID)
235
+ activeMessages.delete(sessionID)
236
+ deliveredNotices.delete(sessionID)
237
+ await clearLiveRoute(sessionID)
238
+ return
161
239
  }
162
- await client.session
163
- .prompt({
164
- path: { id: event.properties.sessionID },
165
- query: { directory },
166
- body,
167
- })
168
- .catch(() => {})
240
+ if (event.type !== 'session.idle') return
241
+ const sessionID = event.properties.sessionID
242
+ const messageID = activeMessages.get(sessionID)
243
+ if (messageID) affinity.clear(messageID)
244
+ activeMessages.delete(sessionID)
245
+ await clearLiveRoute(sessionID)
246
+ },
247
+ 'chat.headers': async (input, output) => {
248
+ const activeMessage = activeMessages.get(input.sessionID) ?? input.message.id
249
+ const affinityKey = addSubrouterHeaders({ input, output, affinityKey: activeMessage })
250
+ if (affinityKey) activeMessages.set(input.sessionID, affinityKey)
169
251
  },
170
- 'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
171
- // OpenCode identity uses the preset id; rewrite it to the live routed model.
172
252
  'experimental.chat.system.transform': async (input, output) => {
173
253
  await revealRoutedModel({
174
254
  providerID: input.model.providerID,
175
255
  preset: input.model.id,
256
+ sessionID: input.sessionID,
176
257
  system: output.system,
177
258
  })
178
259
  },