@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/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +125 -53
- package/dist/opencode-e2e.test.js +252 -35
- package/dist/plugin.test.js +231 -35
- 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 +174 -82
- package/src/opencode-e2e.test.ts +273 -36
- package/src/plugin.test.ts +267 -44
- 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,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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
159
|
+
taken: takenApiIds,
|
|
103
160
|
})
|
|
104
|
-
:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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)
|
|
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
|
}
|