@subrouter/opencode 0.2.0 → 0.4.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 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +121 -19
- package/dist/opencode-e2e.test.d.ts +1 -1
- package/dist/opencode-e2e.test.js +245 -32
- package/dist/plugin.test.js +378 -11
- package/dist/provider.d.ts +21 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +24 -2
- package/package.json +2 -2
- package/src/index.ts +131 -19
- package/src/opencode-e2e.test.ts +289 -37
- package/src/plugin.test.ts +425 -15
- package/src/provider.ts +51 -2
package/src/index.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* provider whose npm field points at this package's provider module (file://
|
|
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
|
-
* `subrouter preset create`) in opencode.
|
|
8
|
+
* `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
|
|
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.
|
|
9
12
|
*
|
|
10
13
|
* `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
|
|
11
14
|
* (and any harness driving opencode's auth hook, like kimaki's Discord
|
|
@@ -16,54 +19,163 @@
|
|
|
16
19
|
* OpenCode calls every export as a plugin.
|
|
17
20
|
*/
|
|
18
21
|
|
|
19
|
-
import type { Plugin } from '@opencode-ai/plugin'
|
|
22
|
+
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
20
23
|
import {
|
|
21
24
|
adapters,
|
|
22
25
|
addAccount,
|
|
23
26
|
DEFAULT_PRESET_NAME,
|
|
24
27
|
isProviderId,
|
|
28
|
+
loadModelsDevCatalog,
|
|
25
29
|
loadPresets,
|
|
30
|
+
modelsDevInputModalities,
|
|
31
|
+
modelsDevLimit,
|
|
32
|
+
modelsDevModel,
|
|
33
|
+
PROVIDER_DISPLAY_NAME,
|
|
34
|
+
PROVIDER_ID,
|
|
26
35
|
PROVIDER_IDS,
|
|
36
|
+
resolveCandidates,
|
|
37
|
+
resolvePresetModels,
|
|
38
|
+
type CooldownFallbackNotice,
|
|
27
39
|
type StoredAccount,
|
|
40
|
+
type SubrouterLog,
|
|
28
41
|
} from '@subrouter/cli'
|
|
29
|
-
import { addSubrouterHeaders } from './provider.ts'
|
|
42
|
+
import { addSubrouterHeaders, revealRoutedModel } from './provider.ts'
|
|
30
43
|
|
|
31
44
|
function providerEntryUrl() {
|
|
32
45
|
const isDev = import.meta.url.endsWith('.ts')
|
|
33
46
|
return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href
|
|
34
47
|
}
|
|
35
48
|
|
|
36
|
-
|
|
49
|
+
function opencodeLog(client: PluginInput['client'] | undefined): SubrouterLog | undefined {
|
|
50
|
+
if (!client?.app?.log) return undefined
|
|
51
|
+
const write = client.app.log.bind(client.app)
|
|
52
|
+
return (entry) => {
|
|
53
|
+
void write({
|
|
54
|
+
body: {
|
|
55
|
+
service: 'subrouter',
|
|
56
|
+
level: entry.level,
|
|
57
|
+
message: entry.message,
|
|
58
|
+
extra: entry.extra,
|
|
59
|
+
},
|
|
60
|
+
}).catch(() => {})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const subrouterPlugin: Plugin = async ({ client, directory }) => {
|
|
65
|
+
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
|
|
73
|
+
const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`
|
|
74
|
+
const active = `${notice.active.provider}/${notice.active.modelId}`
|
|
75
|
+
pendingNotices.set(notice.sessionID, {
|
|
76
|
+
agent: notice.agent,
|
|
77
|
+
variant: notice.variant,
|
|
78
|
+
preset: notice.preset,
|
|
79
|
+
text: `Subrouter: ${preferred} was rate limited. This message started with ${active}.`,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
37
82
|
return {
|
|
38
83
|
config: async (config) => {
|
|
39
84
|
const presets = await loadPresets().catch(() => {
|
|
40
85
|
return { version: 1 as const, presets: {} }
|
|
41
86
|
})
|
|
42
87
|
const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)])
|
|
88
|
+
const catalog = await loadModelsDevCatalog({ log })
|
|
43
89
|
const models = Object.fromEntries(
|
|
44
|
-
|
|
45
|
-
name
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
limit
|
|
53
|
-
|
|
54
|
-
|
|
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,
|
|
101
|
+
modelId: candidate.modelId,
|
|
102
|
+
catalog,
|
|
103
|
+
})
|
|
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,
|
|
111
|
+
catalog,
|
|
112
|
+
})
|
|
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)
|
|
121
|
+
}
|
|
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
|
+
),
|
|
55
139
|
)
|
|
56
140
|
config.provider = {
|
|
57
141
|
...config.provider,
|
|
58
|
-
|
|
59
|
-
name:
|
|
142
|
+
[PROVIDER_ID]: {
|
|
143
|
+
name: PROVIDER_DISPLAY_NAME,
|
|
60
144
|
npm: providerEntryUrl(),
|
|
61
145
|
models,
|
|
62
|
-
options: {},
|
|
146
|
+
options: { log, onCooldownFallback },
|
|
63
147
|
},
|
|
64
148
|
}
|
|
65
149
|
},
|
|
150
|
+
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 }],
|
|
161
|
+
}
|
|
162
|
+
await client.session
|
|
163
|
+
.prompt({
|
|
164
|
+
path: { id: event.properties.sessionID },
|
|
165
|
+
query: { directory },
|
|
166
|
+
body,
|
|
167
|
+
})
|
|
168
|
+
.catch(() => {})
|
|
169
|
+
},
|
|
66
170
|
'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
|
|
171
|
+
// OpenCode identity uses the preset id; rewrite it to the live routed model.
|
|
172
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
173
|
+
await revealRoutedModel({
|
|
174
|
+
providerID: input.model.providerID,
|
|
175
|
+
preset: input.model.id,
|
|
176
|
+
system: output.system,
|
|
177
|
+
})
|
|
178
|
+
},
|
|
67
179
|
}
|
|
68
180
|
}
|
|
69
181
|
|
|
@@ -88,7 +200,7 @@ function toOpencodeCredentials(account: StoredAccount) {
|
|
|
88
200
|
export const subrouterAuthPlugin: Plugin = async () => {
|
|
89
201
|
return {
|
|
90
202
|
auth: {
|
|
91
|
-
provider:
|
|
203
|
+
provider: PROVIDER_ID,
|
|
92
204
|
methods: [
|
|
93
205
|
{
|
|
94
206
|
type: 'oauth',
|
package/src/opencode-e2e.test.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* End-to-end test: a real opencode server drives the subrouter provider.
|
|
3
3
|
*
|
|
4
4
|
* No real API requests. Fake HTTP servers play the provider endpoints:
|
|
5
|
-
* anthropic always answers 429 (rate limited), the opencode
|
|
5
|
+
* anthropic always answers 429 (rate limited), the opencode-go mock streams
|
|
6
6
|
* a canned completion. The test prompts opencode with model subrouter/default
|
|
7
7
|
* and asserts the reply came from the fallback provider, proving the cycling
|
|
8
8
|
* works through the whole opencode -> provider -> router pipeline.
|
|
@@ -10,37 +10,78 @@
|
|
|
10
10
|
* Requires built dist (pnpm build) because opencode loads dist/provider.js.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { createOpencodeClient } from '@opencode-ai/sdk'
|
|
13
|
+
import { createOpencodeClient, type Event } from '@opencode-ai/sdk'
|
|
14
14
|
import { createOpencodeServer } from '@opencode-ai/sdk/server'
|
|
15
15
|
import { createServer, type Server } from 'node:http'
|
|
16
|
-
import { mkdtemp, rm,
|
|
16
|
+
import { mkdtemp, rm, mkdir } from 'node:fs/promises'
|
|
17
17
|
import { tmpdir } from 'node:os'
|
|
18
18
|
import path from 'node:path'
|
|
19
19
|
import { pathToFileURL } from 'node:url'
|
|
20
20
|
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
|
21
21
|
import {
|
|
22
|
+
OPENCODE_AGENT_HEADER,
|
|
23
|
+
OPENCODE_VARIANT_HEADER,
|
|
22
24
|
OPENAI_WEBSOCKET_SESSION_HEADER,
|
|
23
25
|
OPENAI_WEBSOCKET_TITLE_HEADER,
|
|
26
|
+
addAccount,
|
|
27
|
+
markCooldown,
|
|
24
28
|
} from '@subrouter/cli'
|
|
25
29
|
import { addSubrouterHeaders } from './provider.ts'
|
|
26
30
|
|
|
31
|
+
function summarizeSessionEvents(events: Event[]) {
|
|
32
|
+
const summary: Array<{
|
|
33
|
+
type: string
|
|
34
|
+
status?: string
|
|
35
|
+
message?: string
|
|
36
|
+
name?: string
|
|
37
|
+
statusCode?: number
|
|
38
|
+
isRetryable?: boolean
|
|
39
|
+
}> = []
|
|
40
|
+
for (const event of events) {
|
|
41
|
+
if (event.type === 'session.status') {
|
|
42
|
+
const status = event.properties.status
|
|
43
|
+
summary.push({
|
|
44
|
+
type: event.type,
|
|
45
|
+
status: status.type,
|
|
46
|
+
message: status.type === 'retry' ? status.message : undefined,
|
|
47
|
+
})
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (event.type === 'session.error') {
|
|
51
|
+
const error = event.properties.error
|
|
52
|
+
if (!error) continue
|
|
53
|
+
const message = typeof error.data.message === 'string' ? error.data.message : undefined
|
|
54
|
+
summary.push({
|
|
55
|
+
type: event.type,
|
|
56
|
+
name: error.name,
|
|
57
|
+
message,
|
|
58
|
+
statusCode: error.name === 'APIError' ? error.data.statusCode : undefined,
|
|
59
|
+
isRetryable: error.name === 'APIError' ? error.data.isRetryable : undefined,
|
|
60
|
+
})
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (event.type === 'session.idle') summary.push({ type: event.type })
|
|
64
|
+
}
|
|
65
|
+
return summary
|
|
66
|
+
}
|
|
67
|
+
|
|
27
68
|
type MockServer = {
|
|
28
69
|
url: string
|
|
29
|
-
requests: string
|
|
70
|
+
requests: Array<{ path: string; body: string }>
|
|
30
71
|
close: () => Promise<void>
|
|
31
72
|
}
|
|
32
73
|
|
|
33
74
|
async function startMockServer(
|
|
34
75
|
handler: (args: { path: string; body: string }, res: import('node:http').ServerResponse) => void,
|
|
35
76
|
): Promise<MockServer> {
|
|
36
|
-
const requests:
|
|
77
|
+
const requests: MockServer['requests'] = []
|
|
37
78
|
const server: Server = createServer((req, res) => {
|
|
38
79
|
let body = ''
|
|
39
80
|
req.on('data', (chunk) => {
|
|
40
81
|
body += String(chunk)
|
|
41
82
|
})
|
|
42
83
|
req.on('end', () => {
|
|
43
|
-
requests.push(req.url ?? '')
|
|
84
|
+
requests.push({ path: req.url ?? '', body })
|
|
44
85
|
handler({ path: req.url ?? '', body }, res)
|
|
45
86
|
})
|
|
46
87
|
})
|
|
@@ -71,6 +112,7 @@ function sseChunk(data: object) {
|
|
|
71
112
|
let home: string
|
|
72
113
|
let projectDir: string
|
|
73
114
|
let anthropicMock: MockServer
|
|
115
|
+
let modelsDevMock: MockServer
|
|
74
116
|
let zenMock: MockServer
|
|
75
117
|
let server: { url: string; close: () => void }
|
|
76
118
|
const savedEnv: Record<string, string | undefined> = {}
|
|
@@ -86,7 +128,7 @@ beforeAll(async () => {
|
|
|
86
128
|
res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }))
|
|
87
129
|
})
|
|
88
130
|
|
|
89
|
-
// Fake opencode
|
|
131
|
+
// Fake opencode-go: streams a canned completion
|
|
90
132
|
zenMock = await startMockServer(({ body }, res) => {
|
|
91
133
|
const streaming = body.includes('"stream":true')
|
|
92
134
|
if (!streaming) {
|
|
@@ -131,40 +173,40 @@ beforeAll(async () => {
|
|
|
131
173
|
res.end()
|
|
132
174
|
})
|
|
133
175
|
|
|
176
|
+
modelsDevMock = await startMockServer((_request, res) => {
|
|
177
|
+
const emptyProvider = { models: {} }
|
|
178
|
+
const pdfModel = (id: string) => ({
|
|
179
|
+
id,
|
|
180
|
+
attachment: true,
|
|
181
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
182
|
+
limit: { context: 200_000, output: 64_000 },
|
|
183
|
+
})
|
|
184
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
185
|
+
res.end(
|
|
186
|
+
JSON.stringify({
|
|
187
|
+
anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
|
|
188
|
+
openai: emptyProvider,
|
|
189
|
+
xai: emptyProvider,
|
|
190
|
+
'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
|
|
191
|
+
'github-copilot': emptyProvider,
|
|
192
|
+
poe: emptyProvider,
|
|
193
|
+
'minimax-coding-plan': emptyProvider,
|
|
194
|
+
'kimi-for-coding': emptyProvider,
|
|
195
|
+
'zai-coding-plan': emptyProvider,
|
|
196
|
+
'alibaba-coding-plan': emptyProvider,
|
|
197
|
+
}),
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
134
201
|
// Subrouter state: one rate-limited anthropic account + one zen key
|
|
135
202
|
const subrouterHome = path.join(home, 'subrouter')
|
|
136
203
|
await mkdir(subrouterHome, { recursive: true })
|
|
137
|
-
await writeFile(
|
|
138
|
-
path.join(subrouterHome, 'accounts.json'),
|
|
139
|
-
JSON.stringify({
|
|
140
|
-
version: 1,
|
|
141
|
-
providers: {
|
|
142
|
-
anthropic: {
|
|
143
|
-
activeIndex: 0,
|
|
144
|
-
accounts: [
|
|
145
|
-
{
|
|
146
|
-
type: 'oauth',
|
|
147
|
-
refresh: 'fake-refresh',
|
|
148
|
-
access: 'fake-access',
|
|
149
|
-
expires: Date.now() + 1_000_000_000,
|
|
150
|
-
email: 'a@x.com',
|
|
151
|
-
addedAt: 1,
|
|
152
|
-
lastUsed: 1,
|
|
153
|
-
},
|
|
154
|
-
],
|
|
155
|
-
},
|
|
156
|
-
opencode: {
|
|
157
|
-
activeIndex: 0,
|
|
158
|
-
accounts: [{ type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 }],
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
}),
|
|
162
|
-
)
|
|
163
204
|
|
|
164
205
|
for (const [key, value] of Object.entries({
|
|
165
206
|
SUBROUTER_HOME: subrouterHome,
|
|
166
207
|
SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
|
|
167
|
-
|
|
208
|
+
SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
|
|
209
|
+
SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
|
|
168
210
|
// Isolate opencode from the user's real global config and auth
|
|
169
211
|
XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
|
|
170
212
|
XDG_DATA_HOME: path.join(home, 'xdg-data'),
|
|
@@ -175,14 +217,35 @@ beforeAll(async () => {
|
|
|
175
217
|
process.env[key] = value
|
|
176
218
|
}
|
|
177
219
|
|
|
220
|
+
await addAccount({
|
|
221
|
+
provider: 'anthropic',
|
|
222
|
+
account: {
|
|
223
|
+
type: 'oauth',
|
|
224
|
+
refresh: 'fake-refresh',
|
|
225
|
+
access: 'fake-access',
|
|
226
|
+
expires: Date.now() + 1_000_000_000,
|
|
227
|
+
email: 'a@x.com',
|
|
228
|
+
addedAt: 1,
|
|
229
|
+
lastUsed: 1,
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
await addAccount({
|
|
233
|
+
provider: 'opencode-go',
|
|
234
|
+
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
235
|
+
})
|
|
236
|
+
|
|
178
237
|
const providerEntry = pathToFileURL(
|
|
179
238
|
path.join(import.meta.dirname, '..', 'dist', 'provider.js'),
|
|
180
239
|
).href
|
|
240
|
+
const pluginEntry = pathToFileURL(
|
|
241
|
+
path.join(import.meta.dirname, '..', 'dist', 'index.js'),
|
|
242
|
+
).href
|
|
181
243
|
|
|
182
244
|
server = await createOpencodeServer({
|
|
183
245
|
port: 0,
|
|
184
246
|
timeout: 60_000,
|
|
185
247
|
config: {
|
|
248
|
+
plugin: [pluginEntry],
|
|
186
249
|
provider: {
|
|
187
250
|
subrouter: {
|
|
188
251
|
name: 'Subrouter',
|
|
@@ -191,6 +254,8 @@ beforeAll(async () => {
|
|
|
191
254
|
default: {
|
|
192
255
|
name: 'subrouter default',
|
|
193
256
|
tool_call: true,
|
|
257
|
+
attachment: true,
|
|
258
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
194
259
|
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
195
260
|
limit: { context: 200_000, output: 64_000 },
|
|
196
261
|
},
|
|
@@ -204,6 +269,7 @@ beforeAll(async () => {
|
|
|
204
269
|
afterAll(async () => {
|
|
205
270
|
server?.close()
|
|
206
271
|
await anthropicMock?.close()
|
|
272
|
+
await modelsDevMock?.close()
|
|
207
273
|
await zenMock?.close()
|
|
208
274
|
for (const [key, value] of Object.entries(savedEnv)) {
|
|
209
275
|
if (value === undefined) delete process.env[key]
|
|
@@ -220,20 +286,33 @@ describe('opencode + subrouter provider', () => {
|
|
|
220
286
|
sessionID: 'session-1',
|
|
221
287
|
agent: 'build',
|
|
222
288
|
model: { providerID: 'subrouter' },
|
|
289
|
+
message: {
|
|
290
|
+
agent: 'build',
|
|
291
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
292
|
+
},
|
|
223
293
|
},
|
|
224
294
|
output,
|
|
225
295
|
)
|
|
226
|
-
expect(output.headers).toEqual({
|
|
296
|
+
expect(output.headers).toEqual({
|
|
297
|
+
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
|
|
298
|
+
[OPENCODE_AGENT_HEADER]: 'build',
|
|
299
|
+
[OPENCODE_VARIANT_HEADER]: 'high',
|
|
300
|
+
})
|
|
227
301
|
|
|
302
|
+
const titleOutput = { headers: {} }
|
|
228
303
|
addSubrouterHeaders(
|
|
229
304
|
{
|
|
230
305
|
sessionID: 'session-2',
|
|
231
306
|
agent: 'title',
|
|
232
307
|
model: { providerID: 'subrouter' },
|
|
308
|
+
message: {
|
|
309
|
+
agent: 'build',
|
|
310
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
311
|
+
},
|
|
233
312
|
},
|
|
234
|
-
|
|
313
|
+
titleOutput,
|
|
235
314
|
)
|
|
236
|
-
expect(
|
|
315
|
+
expect(titleOutput.headers).toEqual({
|
|
237
316
|
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
|
|
238
317
|
[OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
|
|
239
318
|
})
|
|
@@ -268,4 +347,177 @@ describe('opencode + subrouter provider', () => {
|
|
|
268
347
|
expect(anthropicMock.requests.length).toBeGreaterThan(0)
|
|
269
348
|
expect(zenMock.requests.length).toBeGreaterThan(0)
|
|
270
349
|
}, 120_000)
|
|
350
|
+
|
|
351
|
+
test('a pre-existing cooldown leaves a visible ignored route notice without another model turn', async () => {
|
|
352
|
+
const client = createOpencodeClient({ baseUrl: server.url })
|
|
353
|
+
const session = await client.session.create({
|
|
354
|
+
query: { directory: projectDir },
|
|
355
|
+
body: { title: 'subrouter route notice' },
|
|
356
|
+
})
|
|
357
|
+
expect(session.data).toBeTruthy()
|
|
358
|
+
const requestsBefore = zenMock.requests.length
|
|
359
|
+
|
|
360
|
+
const result = await client.session.prompt({
|
|
361
|
+
path: { id: session.data!.id },
|
|
362
|
+
query: { directory: projectDir },
|
|
363
|
+
body: {
|
|
364
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
365
|
+
parts: [{ type: 'text', text: 'say hi again' }],
|
|
366
|
+
},
|
|
367
|
+
})
|
|
368
|
+
expect(
|
|
369
|
+
(result.data?.parts ?? [])
|
|
370
|
+
.filter((part) => part.type === 'text')
|
|
371
|
+
.map((part) => part.text)
|
|
372
|
+
.join('\n'),
|
|
373
|
+
).toContain('hello from fallback')
|
|
374
|
+
|
|
375
|
+
await expect
|
|
376
|
+
.poll(async () => {
|
|
377
|
+
const messages = await client.session.messages({
|
|
378
|
+
path: { id: session.data!.id },
|
|
379
|
+
query: { directory: projectDir },
|
|
380
|
+
})
|
|
381
|
+
return (messages.data ?? []).some(({ parts }) =>
|
|
382
|
+
parts.some(
|
|
383
|
+
(part) =>
|
|
384
|
+
part.type === 'text' &&
|
|
385
|
+
part.ignored === true &&
|
|
386
|
+
part.text.includes('was rate limited. This message started with opencode-go/'),
|
|
387
|
+
),
|
|
388
|
+
)
|
|
389
|
+
})
|
|
390
|
+
.toBe(true)
|
|
391
|
+
|
|
392
|
+
const messages = await client.session.messages({
|
|
393
|
+
path: { id: session.data!.id },
|
|
394
|
+
query: { directory: projectDir },
|
|
395
|
+
})
|
|
396
|
+
const notice = (messages.data ?? []).find(({ parts }) =>
|
|
397
|
+
parts.some((part) => part.type === 'text' && part.ignored === true),
|
|
398
|
+
)
|
|
399
|
+
expect(notice?.info).toMatchObject({
|
|
400
|
+
role: 'user',
|
|
401
|
+
agent: 'build',
|
|
402
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
403
|
+
})
|
|
404
|
+
expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1)
|
|
405
|
+
expect(zenMock.requests).toHaveLength(requestsBefore + 1)
|
|
406
|
+
}, 120_000)
|
|
407
|
+
|
|
408
|
+
test('PDF file parts reach a compatible fallback through opencode', async () => {
|
|
409
|
+
const client = createOpencodeClient({ baseUrl: server.url })
|
|
410
|
+
const session = await client.session.create({
|
|
411
|
+
query: { directory: projectDir },
|
|
412
|
+
body: { title: 'subrouter PDF e2e' },
|
|
413
|
+
})
|
|
414
|
+
expect(session.data).toBeTruthy()
|
|
415
|
+
|
|
416
|
+
const result = await client.session.prompt({
|
|
417
|
+
path: { id: session.data!.id },
|
|
418
|
+
query: { directory: projectDir },
|
|
419
|
+
body: {
|
|
420
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
421
|
+
parts: [
|
|
422
|
+
{
|
|
423
|
+
type: 'file',
|
|
424
|
+
filename: 'document.pdf',
|
|
425
|
+
mime: 'application/pdf',
|
|
426
|
+
url: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
|
|
427
|
+
},
|
|
428
|
+
{ type: 'text', text: 'read the PDF' },
|
|
429
|
+
],
|
|
430
|
+
},
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
const texts = (result.data?.parts ?? [])
|
|
434
|
+
.filter((part) => part.type === 'text')
|
|
435
|
+
.map((part) => part.text)
|
|
436
|
+
.join('\n')
|
|
437
|
+
expect(texts).toContain('hello from fallback')
|
|
438
|
+
const request = zenMock.requests.at(-1)
|
|
439
|
+
expect(request).toBeTruthy()
|
|
440
|
+
const body = JSON.parse(request!.body) as {
|
|
441
|
+
messages: Array<{ content: Array<{ type: string; file?: { filename?: string } }> }>
|
|
442
|
+
}
|
|
443
|
+
expect(body.messages.at(-1)?.content).toEqual([
|
|
444
|
+
{
|
|
445
|
+
type: 'file',
|
|
446
|
+
file: {
|
|
447
|
+
filename: 'document.pdf',
|
|
448
|
+
file_data: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
{ type: 'text', text: 'read the PDF' },
|
|
452
|
+
])
|
|
453
|
+
}, 120_000)
|
|
454
|
+
|
|
455
|
+
test('all cooling-down accounts retry through opencode instead of dying', async () => {
|
|
456
|
+
const untilMs = Date.now() + 2_000
|
|
457
|
+
await markCooldown({
|
|
458
|
+
provider: 'anthropic',
|
|
459
|
+
account: { type: 'oauth', refresh: 'fake-refresh', access: 'fake-access', email: 'a@x.com', addedAt: 1, lastUsed: 1 },
|
|
460
|
+
untilMs,
|
|
461
|
+
})
|
|
462
|
+
await markCooldown({
|
|
463
|
+
provider: 'opencode-go',
|
|
464
|
+
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
465
|
+
untilMs,
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
const client = createOpencodeClient({ baseUrl: server.url })
|
|
469
|
+
const events: Event[] = []
|
|
470
|
+
const subscription = await client.event.subscribe({
|
|
471
|
+
query: { directory: projectDir },
|
|
472
|
+
})
|
|
473
|
+
void (async () => {
|
|
474
|
+
for await (const event of subscription.stream) {
|
|
475
|
+
events.push(event)
|
|
476
|
+
}
|
|
477
|
+
})()
|
|
478
|
+
|
|
479
|
+
const session = await client.session.create({
|
|
480
|
+
query: { directory: projectDir },
|
|
481
|
+
body: { title: 'subrouter cooldown retry' },
|
|
482
|
+
})
|
|
483
|
+
expect(session.data).toBeTruthy()
|
|
484
|
+
|
|
485
|
+
const result = await client.session.prompt({
|
|
486
|
+
path: { id: session.data!.id },
|
|
487
|
+
query: { directory: projectDir },
|
|
488
|
+
body: {
|
|
489
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
490
|
+
parts: [{ type: 'text', text: 'say hi' }],
|
|
491
|
+
},
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
const parts = result.data?.parts ?? []
|
|
495
|
+
const texts = parts
|
|
496
|
+
.filter((part) => part.type === 'text')
|
|
497
|
+
.map((part) => part.text)
|
|
498
|
+
.join('\n')
|
|
499
|
+
expect(texts).toContain('hello from fallback')
|
|
500
|
+
|
|
501
|
+
const summary = summarizeSessionEvents(events)
|
|
502
|
+
expect(summary.some((event) => event.status === 'retry')).toBe(true)
|
|
503
|
+
expect(summary.some((event) => event.name === 'UnknownError')).toBe(false)
|
|
504
|
+
expect(
|
|
505
|
+
summary.map((event) => {
|
|
506
|
+
if (event.status === 'retry') {
|
|
507
|
+
return { status: 'retry', coolingDown: event.message?.includes('cooling down') }
|
|
508
|
+
}
|
|
509
|
+
return event.status ?? event.type
|
|
510
|
+
}),
|
|
511
|
+
).toMatchInlineSnapshot(`
|
|
512
|
+
[
|
|
513
|
+
"busy",
|
|
514
|
+
"busy",
|
|
515
|
+
{
|
|
516
|
+
"coolingDown": true,
|
|
517
|
+
"status": "retry",
|
|
518
|
+
},
|
|
519
|
+
"busy",
|
|
520
|
+
]
|
|
521
|
+
`)
|
|
522
|
+
}, 120_000)
|
|
271
523
|
})
|