@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/dist/index.d.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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAA;AA0C9D,eAAO,MAAM,eAAe,EAAE,MAoH7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MA0EjC,CAAA"}
|
package/dist/index.js
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
|
|
@@ -15,41 +18,140 @@
|
|
|
15
18
|
* NOTE: only plugin initializer functions may be exported from this module.
|
|
16
19
|
* OpenCode calls every export as a plugin.
|
|
17
20
|
*/
|
|
18
|
-
import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadPresets, PROVIDER_IDS, } from '@subrouter/cli';
|
|
19
|
-
import { addSubrouterHeaders } from "./provider.js";
|
|
21
|
+
import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadModelsDevCatalog, loadPresets, modelsDevInputModalities, modelsDevLimit, modelsDevModel, PROVIDER_DISPLAY_NAME, PROVIDER_ID, PROVIDER_IDS, resolveCandidates, resolvePresetModels, } from '@subrouter/cli';
|
|
22
|
+
import { addSubrouterHeaders, revealRoutedModel } from "./provider.js";
|
|
20
23
|
function providerEntryUrl() {
|
|
21
24
|
const isDev = import.meta.url.endsWith('.ts');
|
|
22
25
|
return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href;
|
|
23
26
|
}
|
|
24
|
-
|
|
27
|
+
function opencodeLog(client) {
|
|
28
|
+
if (!client?.app?.log)
|
|
29
|
+
return undefined;
|
|
30
|
+
const write = client.app.log.bind(client.app);
|
|
31
|
+
return (entry) => {
|
|
32
|
+
void write({
|
|
33
|
+
body: {
|
|
34
|
+
service: 'subrouter',
|
|
35
|
+
level: entry.level,
|
|
36
|
+
message: entry.message,
|
|
37
|
+
extra: entry.extra,
|
|
38
|
+
},
|
|
39
|
+
}).catch(() => { });
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export const subrouterPlugin = async ({ client, directory }) => {
|
|
43
|
+
const log = opencodeLog(client);
|
|
44
|
+
const pendingNotices = new Map();
|
|
45
|
+
const onCooldownFallback = (notice) => {
|
|
46
|
+
if (!client || !notice.sessionID || !notice.agent || notice.agent === 'title')
|
|
47
|
+
return;
|
|
48
|
+
if (pendingNotices.has(notice.sessionID))
|
|
49
|
+
return;
|
|
50
|
+
const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`;
|
|
51
|
+
const active = `${notice.active.provider}/${notice.active.modelId}`;
|
|
52
|
+
pendingNotices.set(notice.sessionID, {
|
|
53
|
+
agent: notice.agent,
|
|
54
|
+
variant: notice.variant,
|
|
55
|
+
preset: notice.preset,
|
|
56
|
+
text: `Subrouter: ${preferred} was rate limited. This message started with ${active}.`,
|
|
57
|
+
});
|
|
58
|
+
};
|
|
25
59
|
return {
|
|
26
60
|
config: async (config) => {
|
|
27
61
|
const presets = await loadPresets().catch(() => {
|
|
28
62
|
return { version: 1, presets: {} };
|
|
29
63
|
});
|
|
30
64
|
const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)]);
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
const catalog = await loadModelsDevCatalog({ log });
|
|
66
|
+
const models = Object.fromEntries(await Promise.all([...names].map(async (name) => {
|
|
67
|
+
const presetModels = await resolvePresetModels(name);
|
|
68
|
+
const candidates = presetModels instanceof Error
|
|
69
|
+
? []
|
|
70
|
+
: (await resolveCandidates({ presetModels })).candidates;
|
|
71
|
+
const candidate = candidates[0];
|
|
72
|
+
const limit = candidate
|
|
73
|
+
? modelsDevLimit({
|
|
74
|
+
provider: candidate.provider,
|
|
75
|
+
modelId: candidate.modelId,
|
|
76
|
+
catalog,
|
|
77
|
+
})
|
|
78
|
+
: null;
|
|
79
|
+
const input = new Set(['text']);
|
|
80
|
+
let attachment = false;
|
|
81
|
+
for (const current of candidates) {
|
|
82
|
+
const model = modelsDevModel({
|
|
83
|
+
provider: current.provider,
|
|
84
|
+
modelId: current.modelId,
|
|
85
|
+
catalog,
|
|
86
|
+
});
|
|
87
|
+
const modalities = modelsDevInputModalities({
|
|
88
|
+
provider: current.provider,
|
|
89
|
+
modelId: current.modelId,
|
|
90
|
+
catalog,
|
|
91
|
+
});
|
|
92
|
+
if (!model || !modalities)
|
|
93
|
+
continue;
|
|
94
|
+
attachment ||= model.attachment && modalities.some((modality) => modality !== 'text');
|
|
95
|
+
for (const modality of modalities)
|
|
96
|
+
input.add(modality);
|
|
97
|
+
}
|
|
98
|
+
return [
|
|
99
|
+
name,
|
|
100
|
+
{
|
|
101
|
+
name,
|
|
102
|
+
tool_call: true,
|
|
103
|
+
attachment,
|
|
104
|
+
reasoning: false,
|
|
105
|
+
modalities: {
|
|
106
|
+
input: [...input],
|
|
107
|
+
output: ['text'],
|
|
108
|
+
},
|
|
109
|
+
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
110
|
+
limit: limit ?? { context: 200_000, output: 64_000 },
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
})));
|
|
42
114
|
config.provider = {
|
|
43
115
|
...config.provider,
|
|
44
|
-
|
|
45
|
-
name:
|
|
116
|
+
[PROVIDER_ID]: {
|
|
117
|
+
name: PROVIDER_DISPLAY_NAME,
|
|
46
118
|
npm: providerEntryUrl(),
|
|
47
119
|
models,
|
|
48
|
-
options: {},
|
|
120
|
+
options: { log, onCooldownFallback },
|
|
49
121
|
},
|
|
50
122
|
};
|
|
51
123
|
},
|
|
124
|
+
event: async ({ event }) => {
|
|
125
|
+
if (event.type !== 'session.idle')
|
|
126
|
+
return;
|
|
127
|
+
const pending = pendingNotices.get(event.properties.sessionID);
|
|
128
|
+
if (!pending)
|
|
129
|
+
return;
|
|
130
|
+
pendingNotices.delete(event.properties.sessionID);
|
|
131
|
+
const body = {
|
|
132
|
+
noReply: true,
|
|
133
|
+
agent: pending.agent,
|
|
134
|
+
model: { providerID: PROVIDER_ID, modelID: pending.preset },
|
|
135
|
+
variant: pending.variant,
|
|
136
|
+
parts: [{ type: 'text', text: pending.text, ignored: true }],
|
|
137
|
+
};
|
|
138
|
+
await client.session
|
|
139
|
+
.prompt({
|
|
140
|
+
path: { id: event.properties.sessionID },
|
|
141
|
+
query: { directory },
|
|
142
|
+
body,
|
|
143
|
+
})
|
|
144
|
+
.catch(() => { });
|
|
145
|
+
},
|
|
52
146
|
'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
|
|
147
|
+
// OpenCode identity uses the preset id; rewrite it to the live routed model.
|
|
148
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
149
|
+
await revealRoutedModel({
|
|
150
|
+
providerID: input.model.providerID,
|
|
151
|
+
preset: input.model.id,
|
|
152
|
+
system: output.system,
|
|
153
|
+
});
|
|
154
|
+
},
|
|
53
155
|
};
|
|
54
156
|
};
|
|
55
157
|
/**
|
|
@@ -72,7 +174,7 @@ function toOpencodeCredentials(account) {
|
|
|
72
174
|
export const subrouterAuthPlugin = async () => {
|
|
73
175
|
return {
|
|
74
176
|
auth: {
|
|
75
|
-
provider:
|
|
177
|
+
provider: PROVIDER_ID,
|
|
76
178
|
methods: [
|
|
77
179
|
{
|
|
78
180
|
type: 'oauth',
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -12,13 +12,44 @@
|
|
|
12
12
|
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
13
13
|
import { createOpencodeServer } from '@opencode-ai/sdk/server';
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
|
-
import { mkdtemp, rm,
|
|
15
|
+
import { mkdtemp, rm, mkdir } from 'node:fs/promises';
|
|
16
16
|
import { tmpdir } from 'node:os';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { pathToFileURL } from 'node:url';
|
|
19
19
|
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
|
20
|
-
import { OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, } from '@subrouter/cli';
|
|
20
|
+
import { OPENCODE_AGENT_HEADER, OPENCODE_VARIANT_HEADER, OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, addAccount, markCooldown, } from '@subrouter/cli';
|
|
21
21
|
import { addSubrouterHeaders } from "./provider.js";
|
|
22
|
+
function summarizeSessionEvents(events) {
|
|
23
|
+
const summary = [];
|
|
24
|
+
for (const event of events) {
|
|
25
|
+
if (event.type === 'session.status') {
|
|
26
|
+
const status = event.properties.status;
|
|
27
|
+
summary.push({
|
|
28
|
+
type: event.type,
|
|
29
|
+
status: status.type,
|
|
30
|
+
message: status.type === 'retry' ? status.message : undefined,
|
|
31
|
+
});
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (event.type === 'session.error') {
|
|
35
|
+
const error = event.properties.error;
|
|
36
|
+
if (!error)
|
|
37
|
+
continue;
|
|
38
|
+
const message = typeof error.data.message === 'string' ? error.data.message : undefined;
|
|
39
|
+
summary.push({
|
|
40
|
+
type: event.type,
|
|
41
|
+
name: error.name,
|
|
42
|
+
message,
|
|
43
|
+
statusCode: error.name === 'APIError' ? error.data.statusCode : undefined,
|
|
44
|
+
isRetryable: error.name === 'APIError' ? error.data.isRetryable : undefined,
|
|
45
|
+
});
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (event.type === 'session.idle')
|
|
49
|
+
summary.push({ type: event.type });
|
|
50
|
+
}
|
|
51
|
+
return summary;
|
|
52
|
+
}
|
|
22
53
|
async function startMockServer(handler) {
|
|
23
54
|
const requests = [];
|
|
24
55
|
const server = createServer((req, res) => {
|
|
@@ -27,7 +58,7 @@ async function startMockServer(handler) {
|
|
|
27
58
|
body += String(chunk);
|
|
28
59
|
});
|
|
29
60
|
req.on('end', () => {
|
|
30
|
-
requests.push(req.url ?? '');
|
|
61
|
+
requests.push({ path: req.url ?? '', body });
|
|
31
62
|
handler({ path: req.url ?? '', body }, res);
|
|
32
63
|
});
|
|
33
64
|
});
|
|
@@ -57,6 +88,7 @@ function sseChunk(data) {
|
|
|
57
88
|
let home;
|
|
58
89
|
let projectDir;
|
|
59
90
|
let anthropicMock;
|
|
91
|
+
let modelsDevMock;
|
|
60
92
|
let zenMock;
|
|
61
93
|
let server;
|
|
62
94
|
const savedEnv = {};
|
|
@@ -69,7 +101,7 @@ beforeAll(async () => {
|
|
|
69
101
|
res.writeHead(429, { 'content-type': 'application/json' });
|
|
70
102
|
res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }));
|
|
71
103
|
});
|
|
72
|
-
// Fake opencode
|
|
104
|
+
// Fake opencode-go: streams a canned completion
|
|
73
105
|
zenMock = await startMockServer(({ body }, res) => {
|
|
74
106
|
const streaming = body.includes('"stream":true');
|
|
75
107
|
if (!streaming) {
|
|
@@ -107,36 +139,36 @@ beforeAll(async () => {
|
|
|
107
139
|
res.write('data: [DONE]\n\n');
|
|
108
140
|
res.end();
|
|
109
141
|
});
|
|
142
|
+
modelsDevMock = await startMockServer((_request, res) => {
|
|
143
|
+
const emptyProvider = { models: {} };
|
|
144
|
+
const pdfModel = (id) => ({
|
|
145
|
+
id,
|
|
146
|
+
attachment: true,
|
|
147
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
148
|
+
limit: { context: 200_000, output: 64_000 },
|
|
149
|
+
});
|
|
150
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
151
|
+
res.end(JSON.stringify({
|
|
152
|
+
anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
|
|
153
|
+
openai: emptyProvider,
|
|
154
|
+
xai: emptyProvider,
|
|
155
|
+
'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
|
|
156
|
+
'github-copilot': emptyProvider,
|
|
157
|
+
poe: emptyProvider,
|
|
158
|
+
'minimax-coding-plan': emptyProvider,
|
|
159
|
+
'kimi-for-coding': emptyProvider,
|
|
160
|
+
'zai-coding-plan': emptyProvider,
|
|
161
|
+
'alibaba-coding-plan': emptyProvider,
|
|
162
|
+
}));
|
|
163
|
+
});
|
|
110
164
|
// Subrouter state: one rate-limited anthropic account + one zen key
|
|
111
165
|
const subrouterHome = path.join(home, 'subrouter');
|
|
112
166
|
await mkdir(subrouterHome, { recursive: true });
|
|
113
|
-
await writeFile(path.join(subrouterHome, 'accounts.json'), JSON.stringify({
|
|
114
|
-
version: 1,
|
|
115
|
-
providers: {
|
|
116
|
-
anthropic: {
|
|
117
|
-
activeIndex: 0,
|
|
118
|
-
accounts: [
|
|
119
|
-
{
|
|
120
|
-
type: 'oauth',
|
|
121
|
-
refresh: 'fake-refresh',
|
|
122
|
-
access: 'fake-access',
|
|
123
|
-
expires: Date.now() + 1_000_000_000,
|
|
124
|
-
email: 'a@x.com',
|
|
125
|
-
addedAt: 1,
|
|
126
|
-
lastUsed: 1,
|
|
127
|
-
},
|
|
128
|
-
],
|
|
129
|
-
},
|
|
130
|
-
opencode: {
|
|
131
|
-
activeIndex: 0,
|
|
132
|
-
accounts: [{ type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 }],
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
-
}));
|
|
136
167
|
for (const [key, value] of Object.entries({
|
|
137
168
|
SUBROUTER_HOME: subrouterHome,
|
|
138
169
|
SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
|
|
139
|
-
|
|
170
|
+
SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
|
|
171
|
+
SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
|
|
140
172
|
// Isolate opencode from the user's real global config and auth
|
|
141
173
|
XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
|
|
142
174
|
XDG_DATA_HOME: path.join(home, 'xdg-data'),
|
|
@@ -146,11 +178,29 @@ beforeAll(async () => {
|
|
|
146
178
|
savedEnv[key] = process.env[key];
|
|
147
179
|
process.env[key] = value;
|
|
148
180
|
}
|
|
181
|
+
await addAccount({
|
|
182
|
+
provider: 'anthropic',
|
|
183
|
+
account: {
|
|
184
|
+
type: 'oauth',
|
|
185
|
+
refresh: 'fake-refresh',
|
|
186
|
+
access: 'fake-access',
|
|
187
|
+
expires: Date.now() + 1_000_000_000,
|
|
188
|
+
email: 'a@x.com',
|
|
189
|
+
addedAt: 1,
|
|
190
|
+
lastUsed: 1,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
await addAccount({
|
|
194
|
+
provider: 'opencode-go',
|
|
195
|
+
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
196
|
+
});
|
|
149
197
|
const providerEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'provider.js')).href;
|
|
198
|
+
const pluginEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'index.js')).href;
|
|
150
199
|
server = await createOpencodeServer({
|
|
151
200
|
port: 0,
|
|
152
201
|
timeout: 60_000,
|
|
153
202
|
config: {
|
|
203
|
+
plugin: [pluginEntry],
|
|
154
204
|
provider: {
|
|
155
205
|
subrouter: {
|
|
156
206
|
name: 'Subrouter',
|
|
@@ -159,6 +209,8 @@ beforeAll(async () => {
|
|
|
159
209
|
default: {
|
|
160
210
|
name: 'subrouter default',
|
|
161
211
|
tool_call: true,
|
|
212
|
+
attachment: true,
|
|
213
|
+
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
|
162
214
|
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
163
215
|
limit: { context: 200_000, output: 64_000 },
|
|
164
216
|
},
|
|
@@ -171,6 +223,7 @@ beforeAll(async () => {
|
|
|
171
223
|
afterAll(async () => {
|
|
172
224
|
server?.close();
|
|
173
225
|
await anthropicMock?.close();
|
|
226
|
+
await modelsDevMock?.close();
|
|
174
227
|
await zenMock?.close();
|
|
175
228
|
for (const [key, value] of Object.entries(savedEnv)) {
|
|
176
229
|
if (value === undefined)
|
|
@@ -187,14 +240,27 @@ describe('opencode + subrouter provider', () => {
|
|
|
187
240
|
sessionID: 'session-1',
|
|
188
241
|
agent: 'build',
|
|
189
242
|
model: { providerID: 'subrouter' },
|
|
243
|
+
message: {
|
|
244
|
+
agent: 'build',
|
|
245
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
246
|
+
},
|
|
190
247
|
}, output);
|
|
191
|
-
expect(output.headers).toEqual({
|
|
248
|
+
expect(output.headers).toEqual({
|
|
249
|
+
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
|
|
250
|
+
[OPENCODE_AGENT_HEADER]: 'build',
|
|
251
|
+
[OPENCODE_VARIANT_HEADER]: 'high',
|
|
252
|
+
});
|
|
253
|
+
const titleOutput = { headers: {} };
|
|
192
254
|
addSubrouterHeaders({
|
|
193
255
|
sessionID: 'session-2',
|
|
194
256
|
agent: 'title',
|
|
195
257
|
model: { providerID: 'subrouter' },
|
|
196
|
-
|
|
197
|
-
|
|
258
|
+
message: {
|
|
259
|
+
agent: 'build',
|
|
260
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
261
|
+
},
|
|
262
|
+
}, titleOutput);
|
|
263
|
+
expect(titleOutput.headers).toEqual({
|
|
198
264
|
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
|
|
199
265
|
[OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
|
|
200
266
|
});
|
|
@@ -224,4 +290,151 @@ describe('opencode + subrouter provider', () => {
|
|
|
224
290
|
expect(anthropicMock.requests.length).toBeGreaterThan(0);
|
|
225
291
|
expect(zenMock.requests.length).toBeGreaterThan(0);
|
|
226
292
|
}, 120_000);
|
|
293
|
+
test('a pre-existing cooldown leaves a visible ignored route notice without another model turn', async () => {
|
|
294
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
295
|
+
const session = await client.session.create({
|
|
296
|
+
query: { directory: projectDir },
|
|
297
|
+
body: { title: 'subrouter route notice' },
|
|
298
|
+
});
|
|
299
|
+
expect(session.data).toBeTruthy();
|
|
300
|
+
const requestsBefore = zenMock.requests.length;
|
|
301
|
+
const result = await client.session.prompt({
|
|
302
|
+
path: { id: session.data.id },
|
|
303
|
+
query: { directory: projectDir },
|
|
304
|
+
body: {
|
|
305
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
306
|
+
parts: [{ type: 'text', text: 'say hi again' }],
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
expect((result.data?.parts ?? [])
|
|
310
|
+
.filter((part) => part.type === 'text')
|
|
311
|
+
.map((part) => part.text)
|
|
312
|
+
.join('\n')).toContain('hello from fallback');
|
|
313
|
+
await expect
|
|
314
|
+
.poll(async () => {
|
|
315
|
+
const messages = await client.session.messages({
|
|
316
|
+
path: { id: session.data.id },
|
|
317
|
+
query: { directory: projectDir },
|
|
318
|
+
});
|
|
319
|
+
return (messages.data ?? []).some(({ parts }) => parts.some((part) => part.type === 'text' &&
|
|
320
|
+
part.ignored === true &&
|
|
321
|
+
part.text.includes('was rate limited. This message started with opencode-go/')));
|
|
322
|
+
})
|
|
323
|
+
.toBe(true);
|
|
324
|
+
const messages = await client.session.messages({
|
|
325
|
+
path: { id: session.data.id },
|
|
326
|
+
query: { directory: projectDir },
|
|
327
|
+
});
|
|
328
|
+
const notice = (messages.data ?? []).find(({ parts }) => parts.some((part) => part.type === 'text' && part.ignored === true));
|
|
329
|
+
expect(notice?.info).toMatchObject({
|
|
330
|
+
role: 'user',
|
|
331
|
+
agent: 'build',
|
|
332
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
333
|
+
});
|
|
334
|
+
expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1);
|
|
335
|
+
expect(zenMock.requests).toHaveLength(requestsBefore + 1);
|
|
336
|
+
}, 120_000);
|
|
337
|
+
test('PDF file parts reach a compatible fallback through opencode', async () => {
|
|
338
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
339
|
+
const session = await client.session.create({
|
|
340
|
+
query: { directory: projectDir },
|
|
341
|
+
body: { title: 'subrouter PDF e2e' },
|
|
342
|
+
});
|
|
343
|
+
expect(session.data).toBeTruthy();
|
|
344
|
+
const result = await client.session.prompt({
|
|
345
|
+
path: { id: session.data.id },
|
|
346
|
+
query: { directory: projectDir },
|
|
347
|
+
body: {
|
|
348
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
349
|
+
parts: [
|
|
350
|
+
{
|
|
351
|
+
type: 'file',
|
|
352
|
+
filename: 'document.pdf',
|
|
353
|
+
mime: 'application/pdf',
|
|
354
|
+
url: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
|
|
355
|
+
},
|
|
356
|
+
{ type: 'text', text: 'read the PDF' },
|
|
357
|
+
],
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
const texts = (result.data?.parts ?? [])
|
|
361
|
+
.filter((part) => part.type === 'text')
|
|
362
|
+
.map((part) => part.text)
|
|
363
|
+
.join('\n');
|
|
364
|
+
expect(texts).toContain('hello from fallback');
|
|
365
|
+
const request = zenMock.requests.at(-1);
|
|
366
|
+
expect(request).toBeTruthy();
|
|
367
|
+
const body = JSON.parse(request.body);
|
|
368
|
+
expect(body.messages.at(-1)?.content).toEqual([
|
|
369
|
+
{
|
|
370
|
+
type: 'file',
|
|
371
|
+
file: {
|
|
372
|
+
filename: 'document.pdf',
|
|
373
|
+
file_data: `data:application/pdf;base64,${Buffer.from('%PDF-1.4\n%%EOF').toString('base64')}`,
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
{ type: 'text', text: 'read the PDF' },
|
|
377
|
+
]);
|
|
378
|
+
}, 120_000);
|
|
379
|
+
test('all cooling-down accounts retry through opencode instead of dying', async () => {
|
|
380
|
+
const untilMs = Date.now() + 2_000;
|
|
381
|
+
await markCooldown({
|
|
382
|
+
provider: 'anthropic',
|
|
383
|
+
account: { type: 'oauth', refresh: 'fake-refresh', access: 'fake-access', email: 'a@x.com', addedAt: 1, lastUsed: 1 },
|
|
384
|
+
untilMs,
|
|
385
|
+
});
|
|
386
|
+
await markCooldown({
|
|
387
|
+
provider: 'opencode-go',
|
|
388
|
+
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
389
|
+
untilMs,
|
|
390
|
+
});
|
|
391
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
392
|
+
const events = [];
|
|
393
|
+
const subscription = await client.event.subscribe({
|
|
394
|
+
query: { directory: projectDir },
|
|
395
|
+
});
|
|
396
|
+
void (async () => {
|
|
397
|
+
for await (const event of subscription.stream) {
|
|
398
|
+
events.push(event);
|
|
399
|
+
}
|
|
400
|
+
})();
|
|
401
|
+
const session = await client.session.create({
|
|
402
|
+
query: { directory: projectDir },
|
|
403
|
+
body: { title: 'subrouter cooldown retry' },
|
|
404
|
+
});
|
|
405
|
+
expect(session.data).toBeTruthy();
|
|
406
|
+
const result = await client.session.prompt({
|
|
407
|
+
path: { id: session.data.id },
|
|
408
|
+
query: { directory: projectDir },
|
|
409
|
+
body: {
|
|
410
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
411
|
+
parts: [{ type: 'text', text: 'say hi' }],
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
const parts = result.data?.parts ?? [];
|
|
415
|
+
const texts = parts
|
|
416
|
+
.filter((part) => part.type === 'text')
|
|
417
|
+
.map((part) => part.text)
|
|
418
|
+
.join('\n');
|
|
419
|
+
expect(texts).toContain('hello from fallback');
|
|
420
|
+
const summary = summarizeSessionEvents(events);
|
|
421
|
+
expect(summary.some((event) => event.status === 'retry')).toBe(true);
|
|
422
|
+
expect(summary.some((event) => event.name === 'UnknownError')).toBe(false);
|
|
423
|
+
expect(summary.map((event) => {
|
|
424
|
+
if (event.status === 'retry') {
|
|
425
|
+
return { status: 'retry', coolingDown: event.message?.includes('cooling down') };
|
|
426
|
+
}
|
|
427
|
+
return event.status ?? event.type;
|
|
428
|
+
})).toMatchInlineSnapshot(`
|
|
429
|
+
[
|
|
430
|
+
"busy",
|
|
431
|
+
"busy",
|
|
432
|
+
{
|
|
433
|
+
"coolingDown": true,
|
|
434
|
+
"status": "retry",
|
|
435
|
+
},
|
|
436
|
+
"busy",
|
|
437
|
+
]
|
|
438
|
+
`);
|
|
439
|
+
}, 120_000);
|
|
227
440
|
});
|