@subrouter/opencode 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@subrouter/opencode",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin that registers the subrouter provider: cycle through your personal AI subscriptions when one hits rate limits.",
6
6
  "main": "./dist/index.js",
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "errore": "^0.14.1",
47
- "@subrouter/cli": "^0.4.0"
47
+ "@subrouter/cli": "^0.6.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@opencode-ai/plugin": "^1.18.23",
package/src/index.ts CHANGED
@@ -6,8 +6,11 @@
6
6
  * URL, so opencode never installs anything). Each subrouter preset becomes a
7
7
  * model: pick `subrouter/default` (or any preset created with
8
8
  * `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
9
- * visible name is `subrouter.org`. Model names, context limits, and
10
- * `experimental.chat.system.transform` follow the first live routed candidate.
9
+ * visible name is `subrouter.org`. Context limits follow the first live
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.
11
14
  *
12
15
  * `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
13
16
  * (and any harness driving opencode's auth hook, like kimaki's Discord
@@ -18,7 +21,7 @@
18
21
  * OpenCode calls every export as a plugin.
19
22
  */
20
23
 
21
- import type { Plugin } from '@opencode-ai/plugin'
24
+ import type { Plugin, PluginInput } from '@opencode-ai/plugin'
22
25
  import {
23
26
  adapters,
24
27
  addAccount,
@@ -26,38 +29,82 @@ import {
26
29
  isProviderId,
27
30
  loadModelsDevCatalog,
28
31
  loadPresets,
32
+ modelsDevInputModalities,
29
33
  modelsDevLimit,
34
+ modelsDevModel,
35
+ variantProviderOptions,
30
36
  PROVIDER_DISPLAY_NAME,
31
37
  PROVIDER_ID,
32
38
  PROVIDER_IDS,
33
- resolveActiveCandidate,
34
- setSubrouterLog,
39
+ clearLiveRoute,
40
+ resolveCandidates,
41
+ resolvePresetModels,
42
+ RouteAffinity,
43
+ type CooldownFallbackNotice,
35
44
  type StoredAccount,
45
+ type SubrouterLog,
36
46
  } from '@subrouter/cli'
37
- import { addSubrouterHeaders, revealRoutedModel } from './provider.ts'
47
+ import {
48
+ addSubrouterHeaders,
49
+ applyPatchApiId,
50
+ revealRoutedModel,
51
+ shouldUseApplyPatch,
52
+ } from './provider.ts'
38
53
 
39
54
  function providerEntryUrl() {
40
55
  const isDev = import.meta.url.endsWith('.ts')
41
56
  return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href
42
57
  }
43
58
 
44
- export const subrouterPlugin: Plugin = async ({ client }) => {
45
- // OpenCode loads this plugin and the provider module separately. Both import
46
- // @subrouter/cli; this callback is the only log sink the router may use.
47
- // Never console.log here. OpenCode prints plugin logs via client.app.log.
48
- if (client?.app?.log) {
49
- setSubrouterLog((entry) => {
50
- void client.app
51
- .log({
52
- body: {
53
- service: 'subrouter',
54
- level: entry.level,
55
- message: entry.message,
56
- extra: entry.extra,
57
- },
58
- })
59
- .catch(() => {})
60
- })
59
+ function opencodeLog(client: PluginInput['client'] | undefined): SubrouterLog | undefined {
60
+ if (!client?.app?.log) return undefined
61
+ const write = client.app.log.bind(client.app)
62
+ return (entry) => {
63
+ void write({
64
+ body: {
65
+ service: 'subrouter',
66
+ level: entry.level,
67
+ message: entry.message,
68
+ extra: entry.extra,
69
+ },
70
+ }).catch(() => {})
71
+ }
72
+ }
73
+
74
+ export const subrouterPlugin: Plugin = async ({ client, directory }) => {
75
+ const log = opencodeLog(client)
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
81
+ const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`
82
+ const active = `${notice.active.provider}/${notice.active.modelId}`
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,
90
+ agent: notice.agent,
91
+ model: { providerID: PROVIDER_ID, modelID: notice.preset },
92
+ variant: notice.variant,
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 })
61
108
  }
62
109
  return {
63
110
  config: async (config) => {
@@ -65,37 +112,110 @@ export const subrouterPlugin: Plugin = async ({ client }) => {
65
112
  return { version: 1 as const, presets: {} }
66
113
  })
67
114
  const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)])
68
- const catalog = await loadModelsDevCatalog()
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
+ )
69
153
  const models = Object.fromEntries(
70
- await Promise.all(
71
- [...names].map(async (name) => {
72
- const candidate = await resolveActiveCandidate(name)
73
- const limit = candidate
74
- ? modelsDevLimit({
75
- provider: candidate.provider,
154
+ resolved.map(({ name, candidate, attachment, input, limit }) => {
155
+ const apiId =
156
+ candidate && shouldUseApplyPatch(candidate.modelId)
157
+ ? applyPatchApiId({
158
+ preset: name,
76
159
  modelId: candidate.modelId,
77
- catalog,
160
+ taken: takenApiIds,
78
161
  })
79
- : null
80
- return [
81
- name,
82
- {
83
- name: candidate ? `${name} (${candidate.modelId})` : name,
84
- tool_call: true,
85
- attachment: true,
86
- reasoning: false,
87
- modalities: {
88
- input: ['text', 'image', 'pdf'] satisfies Array<
89
- 'text' | 'image' | 'pdf'
90
- >,
91
- output: ['text'] satisfies Array<'text'>,
92
- },
93
- cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
94
- limit: limit ?? { context: 200_000, output: 64_000 },
95
- },
96
- ]
97
- }),
98
- ),
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,
171
+ catalog,
172
+ })
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'>
200
+ }
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
+ }),
99
219
  )
100
220
  config.provider = {
101
221
  ...config.provider,
@@ -103,16 +223,37 @@ export const subrouterPlugin: Plugin = async ({ client }) => {
103
223
  name: PROVIDER_DISPLAY_NAME,
104
224
  npm: providerEntryUrl(),
105
225
  models,
106
- options: {},
226
+ options: { affinity, log, onCooldownFallback, presetByApiId },
107
227
  },
108
228
  }
109
229
  },
110
- 'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
111
- // OpenCode identity uses the preset id; rewrite it to the live routed model.
230
+ event: async ({ event }) => {
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
239
+ }
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)
251
+ },
112
252
  'experimental.chat.system.transform': async (input, output) => {
113
253
  await revealRoutedModel({
114
254
  providerID: input.model.providerID,
115
255
  preset: input.model.id,
256
+ sessionID: input.sessionID,
116
257
  system: output.system,
117
258
  })
118
259
  },