@subrouter/opencode 0.1.0 → 0.3.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 +14 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +159 -20
- package/dist/opencode-e2e.test.d.ts +1 -1
- package/dist/opencode-e2e.test.js +132 -27
- package/dist/plugin.test.js +260 -4
- package/dist/provider.d.ts +22 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +24 -0
- package/package.json +2 -2
- package/src/index.ts +182 -20
- package/src/opencode-e2e.test.ts +163 -33
- package/src/plugin.test.ts +310 -8
- package/src/provider.ts +47 -0
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,14 +10,58 @@
|
|
|
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
|
+
import {
|
|
22
|
+
OPENAI_WEBSOCKET_SESSION_HEADER,
|
|
23
|
+
OPENAI_WEBSOCKET_TITLE_HEADER,
|
|
24
|
+
addAccount,
|
|
25
|
+
markCooldown,
|
|
26
|
+
} from '@subrouter/cli'
|
|
27
|
+
import { addSubrouterHeaders } from './provider.ts'
|
|
28
|
+
|
|
29
|
+
function summarizeSessionEvents(events: Event[]) {
|
|
30
|
+
const summary: Array<{
|
|
31
|
+
type: string
|
|
32
|
+
status?: string
|
|
33
|
+
message?: string
|
|
34
|
+
name?: string
|
|
35
|
+
statusCode?: number
|
|
36
|
+
isRetryable?: boolean
|
|
37
|
+
}> = []
|
|
38
|
+
for (const event of events) {
|
|
39
|
+
if (event.type === 'session.status') {
|
|
40
|
+
const status = event.properties.status
|
|
41
|
+
summary.push({
|
|
42
|
+
type: event.type,
|
|
43
|
+
status: status.type,
|
|
44
|
+
message: status.type === 'retry' ? status.message : undefined,
|
|
45
|
+
})
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (event.type === 'session.error') {
|
|
49
|
+
const error = event.properties.error
|
|
50
|
+
if (!error) continue
|
|
51
|
+
const message = typeof error.data.message === 'string' ? error.data.message : undefined
|
|
52
|
+
summary.push({
|
|
53
|
+
type: event.type,
|
|
54
|
+
name: error.name,
|
|
55
|
+
message,
|
|
56
|
+
statusCode: error.name === 'APIError' ? error.data.statusCode : undefined,
|
|
57
|
+
isRetryable: error.name === 'APIError' ? error.data.isRetryable : undefined,
|
|
58
|
+
})
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (event.type === 'session.idle') summary.push({ type: event.type })
|
|
62
|
+
}
|
|
63
|
+
return summary
|
|
64
|
+
}
|
|
21
65
|
|
|
22
66
|
type MockServer = {
|
|
23
67
|
url: string
|
|
@@ -59,7 +103,7 @@ async function startMockServer(
|
|
|
59
103
|
}
|
|
60
104
|
}
|
|
61
105
|
|
|
62
|
-
function sseChunk(data:
|
|
106
|
+
function sseChunk(data: object) {
|
|
63
107
|
return `data: ${JSON.stringify(data)}\n\n`
|
|
64
108
|
}
|
|
65
109
|
|
|
@@ -81,7 +125,7 @@ beforeAll(async () => {
|
|
|
81
125
|
res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }))
|
|
82
126
|
})
|
|
83
127
|
|
|
84
|
-
// Fake opencode
|
|
128
|
+
// Fake opencode-go: streams a canned completion
|
|
85
129
|
zenMock = await startMockServer(({ body }, res) => {
|
|
86
130
|
const streaming = body.includes('"stream":true')
|
|
87
131
|
if (!streaming) {
|
|
@@ -129,37 +173,11 @@ beforeAll(async () => {
|
|
|
129
173
|
// Subrouter state: one rate-limited anthropic account + one zen key
|
|
130
174
|
const subrouterHome = path.join(home, 'subrouter')
|
|
131
175
|
await mkdir(subrouterHome, { recursive: true })
|
|
132
|
-
await writeFile(
|
|
133
|
-
path.join(subrouterHome, 'accounts.json'),
|
|
134
|
-
JSON.stringify({
|
|
135
|
-
version: 1,
|
|
136
|
-
providers: {
|
|
137
|
-
anthropic: {
|
|
138
|
-
activeIndex: 0,
|
|
139
|
-
accounts: [
|
|
140
|
-
{
|
|
141
|
-
type: 'oauth',
|
|
142
|
-
refresh: 'fake-refresh',
|
|
143
|
-
access: 'fake-access',
|
|
144
|
-
expires: Date.now() + 1_000_000_000,
|
|
145
|
-
email: 'a@x.com',
|
|
146
|
-
addedAt: 1,
|
|
147
|
-
lastUsed: 1,
|
|
148
|
-
},
|
|
149
|
-
],
|
|
150
|
-
},
|
|
151
|
-
opencode: {
|
|
152
|
-
activeIndex: 0,
|
|
153
|
-
accounts: [{ type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 }],
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
}),
|
|
157
|
-
)
|
|
158
176
|
|
|
159
177
|
for (const [key, value] of Object.entries({
|
|
160
178
|
SUBROUTER_HOME: subrouterHome,
|
|
161
179
|
SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
|
|
162
|
-
|
|
180
|
+
SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
|
|
163
181
|
// Isolate opencode from the user's real global config and auth
|
|
164
182
|
XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
|
|
165
183
|
XDG_DATA_HOME: path.join(home, 'xdg-data'),
|
|
@@ -170,6 +188,23 @@ beforeAll(async () => {
|
|
|
170
188
|
process.env[key] = value
|
|
171
189
|
}
|
|
172
190
|
|
|
191
|
+
await addAccount({
|
|
192
|
+
provider: 'anthropic',
|
|
193
|
+
account: {
|
|
194
|
+
type: 'oauth',
|
|
195
|
+
refresh: 'fake-refresh',
|
|
196
|
+
access: 'fake-access',
|
|
197
|
+
expires: Date.now() + 1_000_000_000,
|
|
198
|
+
email: 'a@x.com',
|
|
199
|
+
addedAt: 1,
|
|
200
|
+
lastUsed: 1,
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
await addAccount({
|
|
204
|
+
provider: 'opencode-go',
|
|
205
|
+
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
206
|
+
})
|
|
207
|
+
|
|
173
208
|
const providerEntry = pathToFileURL(
|
|
174
209
|
path.join(import.meta.dirname, '..', 'dist', 'provider.js'),
|
|
175
210
|
).href
|
|
@@ -208,6 +243,32 @@ afterAll(async () => {
|
|
|
208
243
|
})
|
|
209
244
|
|
|
210
245
|
describe('opencode + subrouter provider', () => {
|
|
246
|
+
test('adds session affinity headers for subrouter models', async () => {
|
|
247
|
+
const output = { headers: {} }
|
|
248
|
+
addSubrouterHeaders(
|
|
249
|
+
{
|
|
250
|
+
sessionID: 'session-1',
|
|
251
|
+
agent: 'build',
|
|
252
|
+
model: { providerID: 'subrouter' },
|
|
253
|
+
},
|
|
254
|
+
output,
|
|
255
|
+
)
|
|
256
|
+
expect(output.headers).toEqual({ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1' })
|
|
257
|
+
|
|
258
|
+
addSubrouterHeaders(
|
|
259
|
+
{
|
|
260
|
+
sessionID: 'session-2',
|
|
261
|
+
agent: 'title',
|
|
262
|
+
model: { providerID: 'subrouter' },
|
|
263
|
+
},
|
|
264
|
+
output,
|
|
265
|
+
)
|
|
266
|
+
expect(output.headers).toEqual({
|
|
267
|
+
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
|
|
268
|
+
[OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
211
272
|
test('rate-limited provider is cycled to the fallback through opencode', async () => {
|
|
212
273
|
const client = createOpencodeClient({ baseUrl: server.url })
|
|
213
274
|
|
|
@@ -229,7 +290,7 @@ describe('opencode + subrouter provider', () => {
|
|
|
229
290
|
const parts = result.data?.parts ?? []
|
|
230
291
|
const texts = parts
|
|
231
292
|
.filter((part) => part.type === 'text')
|
|
232
|
-
.map((part) =>
|
|
293
|
+
.map((part) => part.text)
|
|
233
294
|
.join('\n')
|
|
234
295
|
expect(texts).toContain('hello from fallback')
|
|
235
296
|
|
|
@@ -237,4 +298,73 @@ describe('opencode + subrouter provider', () => {
|
|
|
237
298
|
expect(anthropicMock.requests.length).toBeGreaterThan(0)
|
|
238
299
|
expect(zenMock.requests.length).toBeGreaterThan(0)
|
|
239
300
|
}, 120_000)
|
|
301
|
+
|
|
302
|
+
test('all cooling-down accounts retry through opencode instead of dying', async () => {
|
|
303
|
+
const untilMs = Date.now() + 2_000
|
|
304
|
+
await markCooldown({
|
|
305
|
+
provider: 'anthropic',
|
|
306
|
+
account: { type: 'oauth', refresh: 'fake-refresh', access: 'fake-access', email: 'a@x.com', addedAt: 1, lastUsed: 1 },
|
|
307
|
+
untilMs,
|
|
308
|
+
})
|
|
309
|
+
await markCooldown({
|
|
310
|
+
provider: 'opencode-go',
|
|
311
|
+
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
312
|
+
untilMs,
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const client = createOpencodeClient({ baseUrl: server.url })
|
|
316
|
+
const events: Event[] = []
|
|
317
|
+
const subscription = await client.event.subscribe({
|
|
318
|
+
query: { directory: projectDir },
|
|
319
|
+
})
|
|
320
|
+
void (async () => {
|
|
321
|
+
for await (const event of subscription.stream) {
|
|
322
|
+
events.push(event)
|
|
323
|
+
}
|
|
324
|
+
})()
|
|
325
|
+
|
|
326
|
+
const session = await client.session.create({
|
|
327
|
+
query: { directory: projectDir },
|
|
328
|
+
body: { title: 'subrouter cooldown retry' },
|
|
329
|
+
})
|
|
330
|
+
expect(session.data).toBeTruthy()
|
|
331
|
+
|
|
332
|
+
const result = await client.session.prompt({
|
|
333
|
+
path: { id: session.data!.id },
|
|
334
|
+
query: { directory: projectDir },
|
|
335
|
+
body: {
|
|
336
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
337
|
+
parts: [{ type: 'text', text: 'say hi' }],
|
|
338
|
+
},
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
const parts = result.data?.parts ?? []
|
|
342
|
+
const texts = parts
|
|
343
|
+
.filter((part) => part.type === 'text')
|
|
344
|
+
.map((part) => part.text)
|
|
345
|
+
.join('\n')
|
|
346
|
+
expect(texts).toContain('hello from fallback')
|
|
347
|
+
|
|
348
|
+
const summary = summarizeSessionEvents(events)
|
|
349
|
+
expect(summary.some((event) => event.status === 'retry')).toBe(true)
|
|
350
|
+
expect(summary.some((event) => event.name === 'UnknownError')).toBe(false)
|
|
351
|
+
expect(
|
|
352
|
+
summary.map((event) => {
|
|
353
|
+
if (event.status === 'retry') {
|
|
354
|
+
return { status: 'retry', coolingDown: event.message?.includes('cooling down') }
|
|
355
|
+
}
|
|
356
|
+
return event.status ?? event.type
|
|
357
|
+
}),
|
|
358
|
+
).toMatchInlineSnapshot(`
|
|
359
|
+
[
|
|
360
|
+
"busy",
|
|
361
|
+
"busy",
|
|
362
|
+
{
|
|
363
|
+
"coolingDown": true,
|
|
364
|
+
"status": "retry",
|
|
365
|
+
},
|
|
366
|
+
"busy",
|
|
367
|
+
]
|
|
368
|
+
`)
|
|
369
|
+
}, 120_000)
|
|
240
370
|
})
|
package/src/plugin.test.ts
CHANGED
|
@@ -1,36 +1,338 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createServer, type Server } from 'node:http'
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
2
3
|
import { tmpdir } from 'node:os'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
import { afterEach, beforeEach, expect, test } from 'vitest'
|
|
5
|
-
import type { PluginInput } from '@opencode-ai/plugin'
|
|
6
|
-
import {
|
|
6
|
+
import type { Config, PluginInput } from '@opencode-ai/plugin'
|
|
7
|
+
import {
|
|
8
|
+
addAccount,
|
|
9
|
+
adapters,
|
|
10
|
+
loadAccounts,
|
|
11
|
+
PROVIDER_DISPLAY_NAME,
|
|
12
|
+
PROVIDER_IDS,
|
|
13
|
+
savePreset,
|
|
14
|
+
setSubrouterLog,
|
|
15
|
+
} from '@subrouter/cli'
|
|
16
|
+
import { subrouterAuthPlugin, subrouterPlugin } from './index.ts'
|
|
17
|
+
import { revealRoutedModel, rewritePoweredByModelLine } from './provider.ts'
|
|
7
18
|
|
|
8
19
|
let home: string
|
|
20
|
+
const openServers: Server[] = []
|
|
21
|
+
const pluginInput = {} as PluginInput
|
|
9
22
|
|
|
10
23
|
beforeEach(async () => {
|
|
11
24
|
home = await mkdtemp(path.join(tmpdir(), 'subrouter-plugin-'))
|
|
12
25
|
process.env.SUBROUTER_HOME = home
|
|
26
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev()
|
|
13
27
|
})
|
|
14
28
|
|
|
15
29
|
afterEach(async () => {
|
|
30
|
+
setSubrouterLog(undefined)
|
|
16
31
|
delete process.env.SUBROUTER_HOME
|
|
32
|
+
delete process.env.SUBROUTER_OPENAI_ISSUER_URL
|
|
33
|
+
delete process.env.SUBROUTER_MODELS_DEV_URL
|
|
34
|
+
for (const server of openServers.splice(0)) {
|
|
35
|
+
await new Promise<void>((resolve) => {
|
|
36
|
+
server.close(() => {
|
|
37
|
+
resolve()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
}
|
|
17
41
|
await rm(home, { recursive: true, force: true })
|
|
18
42
|
})
|
|
19
43
|
|
|
44
|
+
async function startFakeModelsDev(
|
|
45
|
+
providers: Record<
|
|
46
|
+
string,
|
|
47
|
+
{
|
|
48
|
+
models: Record<
|
|
49
|
+
string,
|
|
50
|
+
{
|
|
51
|
+
id: string
|
|
52
|
+
modalities?: { output?: string[] }
|
|
53
|
+
limit?: { context?: number; output?: number; input?: number }
|
|
54
|
+
}
|
|
55
|
+
>
|
|
56
|
+
}
|
|
57
|
+
> = {},
|
|
58
|
+
) {
|
|
59
|
+
const payload = {
|
|
60
|
+
anthropic: { models: {} },
|
|
61
|
+
openai: { models: {} },
|
|
62
|
+
xai: { models: {} },
|
|
63
|
+
'opencode-go': { models: {} },
|
|
64
|
+
'github-copilot': { models: {} },
|
|
65
|
+
poe: { models: {} },
|
|
66
|
+
'minimax-coding-plan': { models: {} },
|
|
67
|
+
'kimi-for-coding': { models: {} },
|
|
68
|
+
'zai-coding-plan': { models: {} },
|
|
69
|
+
'alibaba-coding-plan': { models: {} },
|
|
70
|
+
...providers,
|
|
71
|
+
}
|
|
72
|
+
const server = createServer((_req, res) => {
|
|
73
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
74
|
+
res.end(JSON.stringify(payload))
|
|
75
|
+
})
|
|
76
|
+
await new Promise<void>((resolve) => {
|
|
77
|
+
server.listen(0, '127.0.0.1', resolve)
|
|
78
|
+
})
|
|
79
|
+
const address = server.address()
|
|
80
|
+
if (typeof address === 'string' || !address) throw new Error('failed to bind fake models.dev')
|
|
81
|
+
openServers.push(server)
|
|
82
|
+
return `http://127.0.0.1:${address.port}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Minimal stand-in for the OpenAI Codex device endpoints. */
|
|
86
|
+
async function startFakeOpenAIIssuer() {
|
|
87
|
+
const server = createServer((req, res) => {
|
|
88
|
+
const url = new URL(req.url || '', 'http://localhost')
|
|
89
|
+
const send = (body: unknown) => {
|
|
90
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
91
|
+
res.end(JSON.stringify(body))
|
|
92
|
+
}
|
|
93
|
+
if (url.pathname === '/api/accounts/deviceauth/usercode') {
|
|
94
|
+
return send({ device_auth_id: 'dev-1', user_code: 'ABCD-9876', interval: '0' })
|
|
95
|
+
}
|
|
96
|
+
if (url.pathname === '/api/accounts/deviceauth/token') {
|
|
97
|
+
return send({ authorization_code: 'auth-code', code_verifier: 'verifier' })
|
|
98
|
+
}
|
|
99
|
+
if (url.pathname === '/oauth/token') {
|
|
100
|
+
const claims = Buffer.from(
|
|
101
|
+
JSON.stringify({ email: 'pool@example.com', chatgpt_account_id: 'acct-9' }),
|
|
102
|
+
).toString('base64url')
|
|
103
|
+
return send({
|
|
104
|
+
access_token: 'access-9',
|
|
105
|
+
refresh_token: 'refresh-9',
|
|
106
|
+
expires_in: 3600,
|
|
107
|
+
id_token: `header.${claims}.signature`,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
res.writeHead(404).end('{}')
|
|
111
|
+
})
|
|
112
|
+
await new Promise<void>((resolve) => {
|
|
113
|
+
server.listen(0, '127.0.0.1', resolve)
|
|
114
|
+
})
|
|
115
|
+
const address = server.address()
|
|
116
|
+
if (typeof address === 'string' || !address) throw new Error('failed to bind fake issuer')
|
|
117
|
+
openServers.push(server)
|
|
118
|
+
return `http://127.0.0.1:${address.port}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function authMethod() {
|
|
122
|
+
return subrouterAuthPlugin(pluginInput).then((hooks) => {
|
|
123
|
+
const method = hooks.auth?.methods[0]
|
|
124
|
+
if (!method || method.type !== 'oauth') throw new Error('expected an oauth method')
|
|
125
|
+
return { provider: hooks.auth!.provider, method }
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
20
129
|
test('config hook registers the subrouter provider with preset models', async () => {
|
|
21
|
-
await
|
|
22
|
-
path.join(home, 'presets.json'),
|
|
23
|
-
JSON.stringify({ version: 1, presets: { work: ['anthropic/claude-opus-4-6'] } }),
|
|
24
|
-
)
|
|
130
|
+
await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] })
|
|
25
131
|
|
|
26
|
-
const hooks = await subrouterPlugin(
|
|
132
|
+
const hooks = await subrouterPlugin(pluginInput)
|
|
27
133
|
const config: Record<string, any> = {}
|
|
28
134
|
await hooks.config?.(config as any)
|
|
29
135
|
|
|
30
136
|
const provider = config.provider?.subrouter
|
|
31
137
|
expect(provider).toBeTruthy()
|
|
138
|
+
expect(provider.name).toBe(PROVIDER_DISPLAY_NAME)
|
|
32
139
|
expect(provider.npm.startsWith('file://')).toBe(true)
|
|
33
140
|
expect(provider.npm.endsWith('provider.ts') || provider.npm.endsWith('provider.js')).toBe(true)
|
|
34
141
|
expect(Object.keys(provider.models).sort()).toEqual(['default', 'work'])
|
|
142
|
+
expect(provider.models.default.name).toBe('default')
|
|
143
|
+
expect(provider.models.work.name).toBe('work')
|
|
35
144
|
expect(provider.models.default.cost).toEqual({ input: 0, output: 0, cache_read: 0, cache_write: 0 })
|
|
145
|
+
expect(provider.models.default.limit).toEqual({ context: 200_000, output: 64_000 })
|
|
146
|
+
expect(provider.models.work.limit).toEqual({ context: 200_000, output: 64_000 })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('preset model names show the first live candidate', async () => {
|
|
150
|
+
await addAccount({
|
|
151
|
+
provider: 'anthropic',
|
|
152
|
+
account: {
|
|
153
|
+
type: 'oauth',
|
|
154
|
+
refresh: 'refresh-1',
|
|
155
|
+
access: 'access-1',
|
|
156
|
+
expires: Date.now() + 60_000,
|
|
157
|
+
email: 'a@x.com',
|
|
158
|
+
addedAt: 1,
|
|
159
|
+
lastUsed: 1,
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
await savePreset({ name: 'work', models: ['anthropic/claude-opus-4-6'] })
|
|
163
|
+
|
|
164
|
+
const hooks = await subrouterPlugin(pluginInput)
|
|
165
|
+
const config: Config = {}
|
|
166
|
+
await hooks.config?.(config)
|
|
167
|
+
|
|
168
|
+
expect(config.provider?.subrouter?.name).toBe(PROVIDER_DISPLAY_NAME)
|
|
169
|
+
expect(config.provider?.subrouter?.models?.work?.name).toBe('work (claude-opus-4-6)')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('preset model limits follow the first live candidate', async () => {
|
|
173
|
+
const modelId = adapters.anthropic.defaultModels[0]
|
|
174
|
+
if (!modelId) throw new Error('anthropic adapter has no default model')
|
|
175
|
+
process.env.SUBROUTER_MODELS_DEV_URL = await startFakeModelsDev({
|
|
176
|
+
anthropic: {
|
|
177
|
+
models: {
|
|
178
|
+
[modelId]: {
|
|
179
|
+
id: modelId,
|
|
180
|
+
modalities: { output: ['text'] },
|
|
181
|
+
limit: { context: 1_000_000, output: 128_000 },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
await addAccount({
|
|
187
|
+
provider: 'anthropic',
|
|
188
|
+
account: {
|
|
189
|
+
type: 'oauth',
|
|
190
|
+
refresh: 'refresh-1',
|
|
191
|
+
access: 'access-1',
|
|
192
|
+
expires: Date.now() + 60_000,
|
|
193
|
+
email: 'a@x.com',
|
|
194
|
+
addedAt: 1,
|
|
195
|
+
lastUsed: 1,
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
await savePreset({ name: 'work', models: [`anthropic/${modelId}`] })
|
|
199
|
+
|
|
200
|
+
const hooks = await subrouterPlugin(pluginInput)
|
|
201
|
+
const config: Config = {}
|
|
202
|
+
await hooks.config?.(config)
|
|
203
|
+
|
|
204
|
+
expect(config.provider?.subrouter?.models?.work?.limit).toEqual({
|
|
205
|
+
context: 1_000_000,
|
|
206
|
+
output: 128_000,
|
|
207
|
+
})
|
|
208
|
+
expect(config.provider?.subrouter?.models?.default?.limit).toEqual({
|
|
209
|
+
context: 1_000_000,
|
|
210
|
+
output: 128_000,
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('preset models permit image and PDF attachments', async () => {
|
|
215
|
+
const hooks = await subrouterPlugin(pluginInput)
|
|
216
|
+
const config: Config = {}
|
|
217
|
+
await hooks.config?.(config)
|
|
218
|
+
|
|
219
|
+
expect(config.provider?.subrouter?.models?.default).toMatchObject({
|
|
220
|
+
attachment: true,
|
|
221
|
+
modalities: {
|
|
222
|
+
input: ['text', 'image', 'pdf'],
|
|
223
|
+
output: ['text'],
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('rewrites the OpenCode powered-by line to the routed candidate', () => {
|
|
229
|
+
const system = [
|
|
230
|
+
'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
|
|
231
|
+
]
|
|
232
|
+
rewritePoweredByModelLine({
|
|
233
|
+
system,
|
|
234
|
+
candidate: { provider: 'anthropic', modelId: 'claude-opus-4-6' },
|
|
235
|
+
})
|
|
236
|
+
expect(system[0]).toContain('You are powered by the model named claude-opus-4-6.')
|
|
237
|
+
expect(system[0]).toContain('The exact model ID is anthropic/claude-opus-4-6')
|
|
238
|
+
expect(system[0]).not.toContain('subrouter/build')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('system transform rewrites the powered-by line to the live candidate', async () => {
|
|
242
|
+
await addAccount({
|
|
243
|
+
provider: 'anthropic',
|
|
244
|
+
account: {
|
|
245
|
+
type: 'oauth',
|
|
246
|
+
refresh: 'refresh-1',
|
|
247
|
+
access: 'access-1',
|
|
248
|
+
expires: Date.now() + 60_000,
|
|
249
|
+
email: 'a@x.com',
|
|
250
|
+
addedAt: 1,
|
|
251
|
+
lastUsed: 1,
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
const system = [
|
|
256
|
+
'You are powered by the model named build. The exact model ID is subrouter/build\n<env>\n Working directory: /tmp\n</env>',
|
|
257
|
+
]
|
|
258
|
+
await revealRoutedModel({ providerID: 'subrouter', preset: 'default', system })
|
|
259
|
+
|
|
260
|
+
const modelId = adapters.anthropic.defaultModels[0]
|
|
261
|
+
expect(system[0]).toContain(`You are powered by the model named ${modelId}.`)
|
|
262
|
+
expect(system[0]).toContain(`The exact model ID is anthropic/${modelId}`)
|
|
263
|
+
expect(system[0]).not.toContain('subrouter/build')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('system transform leaves other providers unchanged', async () => {
|
|
267
|
+
const original =
|
|
268
|
+
'You are powered by the model named claude-opus-4-6. The exact model ID is anthropic/claude-opus-4-6'
|
|
269
|
+
const system = [original]
|
|
270
|
+
await revealRoutedModel({ providerID: 'anthropic', preset: 'claude-opus-4-6', system })
|
|
271
|
+
expect(system[0]).toBe(original)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('auth hook asks which subscription to add before authorizing', async () => {
|
|
275
|
+
const { provider, method } = await authMethod()
|
|
276
|
+
|
|
277
|
+
expect(provider).toBe('subrouter')
|
|
278
|
+
const prompt = method.prompts?.[0]
|
|
279
|
+
expect(prompt?.type).toBe('select')
|
|
280
|
+
expect(prompt?.key).toBe('provider')
|
|
281
|
+
expect(prompt?.type === 'select' && prompt.options.map((o) => o.value)).toEqual([...PROVIDER_IDS])
|
|
282
|
+
const methodPrompt = method.prompts?.[1]
|
|
283
|
+
expect(methodPrompt?.type).toBe('select')
|
|
284
|
+
expect(methodPrompt?.key).toBe('method')
|
|
285
|
+
expect(methodPrompt?.type === 'select' && methodPrompt.options.map((o) => o.value)).toEqual([
|
|
286
|
+
'browser',
|
|
287
|
+
'device',
|
|
288
|
+
])
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('authorize rejects a missing or unknown subscription', async () => {
|
|
292
|
+
const { method } = await authMethod()
|
|
293
|
+
|
|
294
|
+
await expect(method.authorize({})).rejects.toThrow(/Pick a subscription/)
|
|
295
|
+
await expect(method.authorize({ provider: 'nope' })).rejects.toThrow(/Pick a subscription/)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('authorize dispatches to the chosen adapter and the callback pools the account', async () => {
|
|
299
|
+
process.env.SUBROUTER_OPENAI_ISSUER_URL = await startFakeOpenAIIssuer()
|
|
300
|
+
const { method } = await authMethod()
|
|
301
|
+
|
|
302
|
+
const result = await method.authorize({ provider: 'openai', method: 'device' })
|
|
303
|
+
expect(result.method).toBe('auto')
|
|
304
|
+
expect(result.instructions).toMatch(/code:\s*ABCD-9876/)
|
|
305
|
+
|
|
306
|
+
if (result.method !== 'auto') throw new Error('expected the device flow')
|
|
307
|
+
const credentials = await result.callback()
|
|
308
|
+
|
|
309
|
+
expect(credentials).toMatchObject({ type: 'success', access: 'access-9', refresh: 'refresh-9' })
|
|
310
|
+
const accounts = await loadAccounts()
|
|
311
|
+
expect(accounts.providers.openai?.accounts).toMatchObject([
|
|
312
|
+
{ type: 'oauth', email: 'pool@example.com', accountId: 'acct-9' },
|
|
313
|
+
])
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('opencode go asks for a pasted key and stores it as an api account', async () => {
|
|
317
|
+
const { method } = await authMethod()
|
|
318
|
+
|
|
319
|
+
const result = await method.authorize({ provider: 'opencode-go' })
|
|
320
|
+
expect(result.method).toBe('code')
|
|
321
|
+
|
|
322
|
+
if (result.method !== 'code') throw new Error('expected a pasted-key flow')
|
|
323
|
+
expect(await result.callback('go-key-1')).toMatchObject({ type: 'success', key: 'go-key-1' })
|
|
324
|
+
|
|
325
|
+
const accounts = await loadAccounts()
|
|
326
|
+
expect(accounts.providers['opencode-go']?.accounts).toMatchObject([{ type: 'api', key: 'go-key-1' }])
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('a failed login reports failure instead of pooling a broken account', async () => {
|
|
330
|
+
const { method } = await authMethod()
|
|
331
|
+
|
|
332
|
+
const result = await method.authorize({ provider: 'opencode-go' })
|
|
333
|
+
if (result.method !== 'code') throw new Error('expected a pasted-key flow')
|
|
334
|
+
|
|
335
|
+
expect(await result.callback(' ')).toEqual({ type: 'failed' })
|
|
336
|
+
const accounts = await loadAccounts()
|
|
337
|
+
expect(accounts.providers['opencode-go']).toBeUndefined()
|
|
36
338
|
})
|
package/src/provider.ts
CHANGED
|
@@ -2,6 +2,53 @@
|
|
|
2
2
|
* Provider entry loaded by opencode via `provider.subrouter.npm` (file:// URL).
|
|
3
3
|
* OpenCode imports this module, calls the first export starting with `create`,
|
|
4
4
|
* then `sdk.languageModel(modelId)` where modelId is a subrouter preset name.
|
|
5
|
+
* Also rewrites OpenCode's powered-by identity to the live routed model.
|
|
5
6
|
*/
|
|
6
7
|
|
|
8
|
+
import {
|
|
9
|
+
OPENAI_WEBSOCKET_SESSION_HEADER,
|
|
10
|
+
OPENAI_WEBSOCKET_TITLE_HEADER,
|
|
11
|
+
PROVIDER_ID,
|
|
12
|
+
resolveActiveCandidate,
|
|
13
|
+
} from '@subrouter/cli'
|
|
14
|
+
|
|
7
15
|
export { createSubrouter } from '@subrouter/cli'
|
|
16
|
+
|
|
17
|
+
const POWERED_BY_MODEL = /You are powered by the model named [^\n]+/
|
|
18
|
+
|
|
19
|
+
export function rewritePoweredByModelLine({
|
|
20
|
+
system,
|
|
21
|
+
candidate,
|
|
22
|
+
}: {
|
|
23
|
+
system: string[]
|
|
24
|
+
candidate: { provider: string; modelId: string }
|
|
25
|
+
}) {
|
|
26
|
+
const line = `You are powered by the model named ${candidate.modelId}. The exact model ID is ${candidate.provider}/${candidate.modelId}`
|
|
27
|
+
for (let i = 0; i < system.length; i++) {
|
|
28
|
+
system[i] = system[i]!.replace(POWERED_BY_MODEL, line)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function revealRoutedModel({
|
|
33
|
+
providerID,
|
|
34
|
+
preset,
|
|
35
|
+
system,
|
|
36
|
+
}: {
|
|
37
|
+
providerID: string
|
|
38
|
+
preset: string
|
|
39
|
+
system: string[]
|
|
40
|
+
}) {
|
|
41
|
+
if (providerID !== PROVIDER_ID) return
|
|
42
|
+
const candidate = await resolveActiveCandidate(preset)
|
|
43
|
+
if (!candidate) return
|
|
44
|
+
rewritePoweredByModelLine({ system, candidate })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function addSubrouterHeaders(
|
|
48
|
+
input: { sessionID: string; agent: string; model: { providerID: string } },
|
|
49
|
+
output: { headers: Record<string, string> },
|
|
50
|
+
) {
|
|
51
|
+
if (input.model.providerID !== PROVIDER_ID) return
|
|
52
|
+
output.headers[OPENAI_WEBSOCKET_SESSION_HEADER] = input.sessionID
|
|
53
|
+
if (input.agent === 'title') output.headers[OPENAI_WEBSOCKET_TITLE_HEADER] = 'true'
|
|
54
|
+
}
|