@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/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +111 -49
- package/dist/opencode-e2e.test.js +240 -29
- package/dist/plugin.test.js +228 -23
- package/dist/provider.d.ts +34 -15
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +36 -7
- package/package.json +2 -2
- package/src/index.ts +159 -78
- package/src/opencode-e2e.test.ts +258 -30
- package/src/plugin.test.ts +263 -32
- package/src/provider.ts +65 -9
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.
|
|
11
|
-
*
|
|
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 {
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
160
|
+
taken: takenApiIds,
|
|
103
161
|
})
|
|
104
|
-
:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
},
|