@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/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +164 -42
- package/dist/opencode-e2e.test.js +354 -15
- package/dist/plugin.test.js +436 -13
- package/dist/provider.d.ts +38 -11
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +42 -7
- package/package.json +2 -2
- package/src/index.ts +195 -54
- package/src/opencode-e2e.test.ts +397 -16
- package/src/plugin.test.ts +481 -21
- package/src/provider.ts +81 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@subrouter/opencode",
|
|
3
|
-
"version": "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.
|
|
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`.
|
|
10
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
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 {
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
160
|
+
taken: takenApiIds,
|
|
78
161
|
})
|
|
79
|
-
:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
},
|