@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/dist/index.d.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenCode
|
|
2
|
+
* OpenCode plugins that expose subrouter inside opencode.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* package's provider module (file://
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* `subrouterPlugin` registers the provider: the config hook injects a custom
|
|
5
|
+
* provider whose npm field points at this package's provider module (file://
|
|
6
|
+
* URL, so opencode never installs anything). Each subrouter preset becomes a
|
|
7
|
+
* model: pick `subrouter/default` (or any preset created with
|
|
8
|
+
* `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
|
|
9
|
+
* visible name is `subrouter.org`. Model names, context limits, and
|
|
10
|
+
* `experimental.chat.system.transform` follow the first live routed candidate.
|
|
11
|
+
*
|
|
12
|
+
* `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
|
|
13
|
+
* (and any harness driving opencode's auth hook, like kimaki's Discord
|
|
14
|
+
* `/login`) can add subscriptions to the pool without leaving the harness.
|
|
15
|
+
* It asks which subscription to add first, then defers to that adapter.
|
|
8
16
|
*
|
|
9
17
|
* NOTE: only plugin initializer functions may be exported from this module.
|
|
10
18
|
* OpenCode calls every export as a plugin.
|
|
11
19
|
*/
|
|
12
20
|
import type { Plugin } from '@opencode-ai/plugin';
|
|
13
21
|
export declare const subrouterPlugin: Plugin;
|
|
22
|
+
export declare const subrouterAuthPlugin: Plugin;
|
|
14
23
|
//# sourceMappingURL=index.d.ts.map
|
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;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAuBjD,eAAO,MAAM,eAAe,EAAE,MA4E7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MA0EjC,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,46 +1,185 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenCode
|
|
2
|
+
* OpenCode plugins that expose subrouter inside opencode.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* package's provider module (file://
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* `subrouterPlugin` registers the provider: the config hook injects a custom
|
|
5
|
+
* provider whose npm field points at this package's provider module (file://
|
|
6
|
+
* URL, so opencode never installs anything). Each subrouter preset becomes a
|
|
7
|
+
* model: pick `subrouter/default` (or any preset created with
|
|
8
|
+
* `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
|
|
9
|
+
* visible name is `subrouter.org`. Model names, context limits, and
|
|
10
|
+
* `experimental.chat.system.transform` follow the first live routed candidate.
|
|
11
|
+
*
|
|
12
|
+
* `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
|
|
13
|
+
* (and any harness driving opencode's auth hook, like kimaki's Discord
|
|
14
|
+
* `/login`) can add subscriptions to the pool without leaving the harness.
|
|
15
|
+
* It asks which subscription to add first, then defers to that adapter.
|
|
8
16
|
*
|
|
9
17
|
* NOTE: only plugin initializer functions may be exported from this module.
|
|
10
18
|
* OpenCode calls every export as a plugin.
|
|
11
19
|
*/
|
|
12
|
-
import { DEFAULT_PRESET_NAME, loadPresets } from '@subrouter/cli';
|
|
20
|
+
import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadModelsDevCatalog, loadPresets, modelsDevLimit, PROVIDER_DISPLAY_NAME, PROVIDER_ID, PROVIDER_IDS, resolveActiveCandidate, setSubrouterLog, } from '@subrouter/cli';
|
|
21
|
+
import { addSubrouterHeaders, revealRoutedModel } from "./provider.js";
|
|
13
22
|
function providerEntryUrl() {
|
|
14
23
|
const isDev = import.meta.url.endsWith('.ts');
|
|
15
24
|
return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href;
|
|
16
25
|
}
|
|
17
|
-
export const subrouterPlugin = async () => {
|
|
26
|
+
export const subrouterPlugin = async ({ client }) => {
|
|
27
|
+
// OpenCode loads this plugin and the provider module separately. Both import
|
|
28
|
+
// @subrouter/cli; this callback is the only log sink the router may use.
|
|
29
|
+
// Never console.log here. OpenCode prints plugin logs via client.app.log.
|
|
30
|
+
if (client?.app?.log) {
|
|
31
|
+
setSubrouterLog((entry) => {
|
|
32
|
+
void client.app
|
|
33
|
+
.log({
|
|
34
|
+
body: {
|
|
35
|
+
service: 'subrouter',
|
|
36
|
+
level: entry.level,
|
|
37
|
+
message: entry.message,
|
|
38
|
+
extra: entry.extra,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
.catch(() => { });
|
|
42
|
+
});
|
|
43
|
+
}
|
|
18
44
|
return {
|
|
19
45
|
config: async (config) => {
|
|
20
46
|
const presets = await loadPresets().catch(() => {
|
|
21
47
|
return { version: 1, presets: {} };
|
|
22
48
|
});
|
|
23
49
|
const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)]);
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
const catalog = await loadModelsDevCatalog();
|
|
51
|
+
const models = Object.fromEntries(await Promise.all([...names].map(async (name) => {
|
|
52
|
+
const candidate = await resolveActiveCandidate(name);
|
|
53
|
+
const limit = candidate
|
|
54
|
+
? modelsDevLimit({
|
|
55
|
+
provider: candidate.provider,
|
|
56
|
+
modelId: candidate.modelId,
|
|
57
|
+
catalog,
|
|
58
|
+
})
|
|
59
|
+
: null;
|
|
60
|
+
return [
|
|
61
|
+
name,
|
|
62
|
+
{
|
|
63
|
+
name: candidate ? `${name} (${candidate.modelId})` : name,
|
|
64
|
+
tool_call: true,
|
|
65
|
+
attachment: true,
|
|
66
|
+
reasoning: false,
|
|
67
|
+
modalities: {
|
|
68
|
+
input: ['text', 'image', 'pdf'],
|
|
69
|
+
output: ['text'],
|
|
70
|
+
},
|
|
71
|
+
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
72
|
+
limit: limit ?? { context: 200_000, output: 64_000 },
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
})));
|
|
35
76
|
config.provider = {
|
|
36
77
|
...config.provider,
|
|
37
|
-
|
|
38
|
-
name:
|
|
78
|
+
[PROVIDER_ID]: {
|
|
79
|
+
name: PROVIDER_DISPLAY_NAME,
|
|
39
80
|
npm: providerEntryUrl(),
|
|
40
81
|
models,
|
|
41
82
|
options: {},
|
|
42
83
|
},
|
|
43
84
|
};
|
|
44
85
|
},
|
|
86
|
+
'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
|
|
87
|
+
// OpenCode identity uses the preset id; rewrite it to the live routed model.
|
|
88
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
89
|
+
await revealRoutedModel({
|
|
90
|
+
providerID: input.model.providerID,
|
|
91
|
+
preset: input.model.id,
|
|
92
|
+
system: output.system,
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Map a subrouter account onto the credential shape opencode stores. The
|
|
99
|
+
* stored copy is redundant (RouterModel only ever reads ~/.subrouter), but
|
|
100
|
+
* opencode needs a success payload to close the login flow.
|
|
101
|
+
*/
|
|
102
|
+
function toOpencodeCredentials(account) {
|
|
103
|
+
if (account.type === 'api') {
|
|
104
|
+
return { type: 'success', key: account.key ?? '' };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
type: 'success',
|
|
108
|
+
refresh: account.refresh ?? '',
|
|
109
|
+
access: account.access ?? '',
|
|
110
|
+
expires: account.expires ?? 0,
|
|
111
|
+
accountId: account.accountId,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export const subrouterAuthPlugin = async () => {
|
|
115
|
+
return {
|
|
116
|
+
auth: {
|
|
117
|
+
provider: PROVIDER_ID,
|
|
118
|
+
methods: [
|
|
119
|
+
{
|
|
120
|
+
type: 'oauth',
|
|
121
|
+
label: 'Add a subscription',
|
|
122
|
+
prompts: [
|
|
123
|
+
{
|
|
124
|
+
type: 'select',
|
|
125
|
+
key: 'provider',
|
|
126
|
+
message: 'Which subscription do you want to add?',
|
|
127
|
+
options: PROVIDER_IDS.map((id) => {
|
|
128
|
+
return { label: adapters[id].name, value: id };
|
|
129
|
+
}),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
type: 'select',
|
|
133
|
+
key: 'method',
|
|
134
|
+
message: 'How do you want to log in to ChatGPT?',
|
|
135
|
+
options: [
|
|
136
|
+
{ label: 'Browser (recommended)', value: 'browser' },
|
|
137
|
+
{ label: 'Device code', value: 'device', hint: 'May be disabled for your account' },
|
|
138
|
+
],
|
|
139
|
+
when: { key: 'provider', op: 'eq', value: 'openai' },
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
authorize: async (inputs) => {
|
|
143
|
+
const providerId = inputs?.provider;
|
|
144
|
+
if (!providerId || !isProviderId(providerId)) {
|
|
145
|
+
// No failure variant exists in AuthOAuthResult, so surface this
|
|
146
|
+
// as a request error instead. Harnesses render the message.
|
|
147
|
+
throw new Error(`Pick a subscription to add. Expected one of: ${PROVIDER_IDS.join(', ')}`);
|
|
148
|
+
}
|
|
149
|
+
const adapter = adapters[providerId];
|
|
150
|
+
// Remote harnesses (a chat bot, a web UI) authorize in a browser
|
|
151
|
+
// that is not on this machine, so a localhost callback never fires
|
|
152
|
+
// and the user has to paste the redirect URL back instead.
|
|
153
|
+
const session = await adapter.beginLogin({
|
|
154
|
+
manualInput: Boolean(process.env.SUBROUTER_MANUAL_OAUTH),
|
|
155
|
+
method: inputs?.method,
|
|
156
|
+
});
|
|
157
|
+
if (session instanceof Error)
|
|
158
|
+
throw session;
|
|
159
|
+
const finish = async (input) => {
|
|
160
|
+
const account = await session.complete(input);
|
|
161
|
+
if (account instanceof Error)
|
|
162
|
+
return { type: 'failed' };
|
|
163
|
+
await addAccount({ provider: providerId, account });
|
|
164
|
+
return toOpencodeCredentials(account);
|
|
165
|
+
};
|
|
166
|
+
if (session.method === 'code') {
|
|
167
|
+
return {
|
|
168
|
+
url: session.url,
|
|
169
|
+
instructions: session.instructions,
|
|
170
|
+
method: 'code',
|
|
171
|
+
callback: (code) => finish(code),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
url: session.url,
|
|
176
|
+
instructions: session.instructions,
|
|
177
|
+
method: 'auto',
|
|
178
|
+
callback: () => finish(),
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
},
|
|
45
184
|
};
|
|
46
185
|
};
|
|
@@ -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,11 +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, addAccount, markCooldown, } from '@subrouter/cli';
|
|
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
|
+
}
|
|
20
53
|
async function startMockServer(handler) {
|
|
21
54
|
const requests = [];
|
|
22
55
|
const server = createServer((req, res) => {
|
|
@@ -67,7 +100,7 @@ beforeAll(async () => {
|
|
|
67
100
|
res.writeHead(429, { 'content-type': 'application/json' });
|
|
68
101
|
res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }));
|
|
69
102
|
});
|
|
70
|
-
// Fake opencode
|
|
103
|
+
// Fake opencode-go: streams a canned completion
|
|
71
104
|
zenMock = await startMockServer(({ body }, res) => {
|
|
72
105
|
const streaming = body.includes('"stream":true');
|
|
73
106
|
if (!streaming) {
|
|
@@ -108,33 +141,10 @@ beforeAll(async () => {
|
|
|
108
141
|
// Subrouter state: one rate-limited anthropic account + one zen key
|
|
109
142
|
const subrouterHome = path.join(home, 'subrouter');
|
|
110
143
|
await mkdir(subrouterHome, { recursive: true });
|
|
111
|
-
await writeFile(path.join(subrouterHome, 'accounts.json'), JSON.stringify({
|
|
112
|
-
version: 1,
|
|
113
|
-
providers: {
|
|
114
|
-
anthropic: {
|
|
115
|
-
activeIndex: 0,
|
|
116
|
-
accounts: [
|
|
117
|
-
{
|
|
118
|
-
type: 'oauth',
|
|
119
|
-
refresh: 'fake-refresh',
|
|
120
|
-
access: 'fake-access',
|
|
121
|
-
expires: Date.now() + 1_000_000_000,
|
|
122
|
-
email: 'a@x.com',
|
|
123
|
-
addedAt: 1,
|
|
124
|
-
lastUsed: 1,
|
|
125
|
-
},
|
|
126
|
-
],
|
|
127
|
-
},
|
|
128
|
-
opencode: {
|
|
129
|
-
activeIndex: 0,
|
|
130
|
-
accounts: [{ type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 }],
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
}));
|
|
134
144
|
for (const [key, value] of Object.entries({
|
|
135
145
|
SUBROUTER_HOME: subrouterHome,
|
|
136
146
|
SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
|
|
137
|
-
|
|
147
|
+
SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
|
|
138
148
|
// Isolate opencode from the user's real global config and auth
|
|
139
149
|
XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
|
|
140
150
|
XDG_DATA_HOME: path.join(home, 'xdg-data'),
|
|
@@ -144,6 +154,22 @@ beforeAll(async () => {
|
|
|
144
154
|
savedEnv[key] = process.env[key];
|
|
145
155
|
process.env[key] = value;
|
|
146
156
|
}
|
|
157
|
+
await addAccount({
|
|
158
|
+
provider: 'anthropic',
|
|
159
|
+
account: {
|
|
160
|
+
type: 'oauth',
|
|
161
|
+
refresh: 'fake-refresh',
|
|
162
|
+
access: 'fake-access',
|
|
163
|
+
expires: Date.now() + 1_000_000_000,
|
|
164
|
+
email: 'a@x.com',
|
|
165
|
+
addedAt: 1,
|
|
166
|
+
lastUsed: 1,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
await addAccount({
|
|
170
|
+
provider: 'opencode-go',
|
|
171
|
+
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
172
|
+
});
|
|
147
173
|
const providerEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'provider.js')).href;
|
|
148
174
|
server = await createOpencodeServer({
|
|
149
175
|
port: 0,
|
|
@@ -179,6 +205,24 @@ afterAll(async () => {
|
|
|
179
205
|
await rm(home, { recursive: true, force: true });
|
|
180
206
|
});
|
|
181
207
|
describe('opencode + subrouter provider', () => {
|
|
208
|
+
test('adds session affinity headers for subrouter models', async () => {
|
|
209
|
+
const output = { headers: {} };
|
|
210
|
+
addSubrouterHeaders({
|
|
211
|
+
sessionID: 'session-1',
|
|
212
|
+
agent: 'build',
|
|
213
|
+
model: { providerID: 'subrouter' },
|
|
214
|
+
}, output);
|
|
215
|
+
expect(output.headers).toEqual({ [OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1' });
|
|
216
|
+
addSubrouterHeaders({
|
|
217
|
+
sessionID: 'session-2',
|
|
218
|
+
agent: 'title',
|
|
219
|
+
model: { providerID: 'subrouter' },
|
|
220
|
+
}, output);
|
|
221
|
+
expect(output.headers).toEqual({
|
|
222
|
+
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
|
|
223
|
+
[OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
|
|
224
|
+
});
|
|
225
|
+
});
|
|
182
226
|
test('rate-limited provider is cycled to the fallback through opencode', async () => {
|
|
183
227
|
const client = createOpencodeClient({ baseUrl: server.url });
|
|
184
228
|
const session = await client.session.create({
|
|
@@ -204,4 +248,65 @@ describe('opencode + subrouter provider', () => {
|
|
|
204
248
|
expect(anthropicMock.requests.length).toBeGreaterThan(0);
|
|
205
249
|
expect(zenMock.requests.length).toBeGreaterThan(0);
|
|
206
250
|
}, 120_000);
|
|
251
|
+
test('all cooling-down accounts retry through opencode instead of dying', async () => {
|
|
252
|
+
const untilMs = Date.now() + 2_000;
|
|
253
|
+
await markCooldown({
|
|
254
|
+
provider: 'anthropic',
|
|
255
|
+
account: { type: 'oauth', refresh: 'fake-refresh', access: 'fake-access', email: 'a@x.com', addedAt: 1, lastUsed: 1 },
|
|
256
|
+
untilMs,
|
|
257
|
+
});
|
|
258
|
+
await markCooldown({
|
|
259
|
+
provider: 'opencode-go',
|
|
260
|
+
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
261
|
+
untilMs,
|
|
262
|
+
});
|
|
263
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
264
|
+
const events = [];
|
|
265
|
+
const subscription = await client.event.subscribe({
|
|
266
|
+
query: { directory: projectDir },
|
|
267
|
+
});
|
|
268
|
+
void (async () => {
|
|
269
|
+
for await (const event of subscription.stream) {
|
|
270
|
+
events.push(event);
|
|
271
|
+
}
|
|
272
|
+
})();
|
|
273
|
+
const session = await client.session.create({
|
|
274
|
+
query: { directory: projectDir },
|
|
275
|
+
body: { title: 'subrouter cooldown retry' },
|
|
276
|
+
});
|
|
277
|
+
expect(session.data).toBeTruthy();
|
|
278
|
+
const result = await client.session.prompt({
|
|
279
|
+
path: { id: session.data.id },
|
|
280
|
+
query: { directory: projectDir },
|
|
281
|
+
body: {
|
|
282
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
283
|
+
parts: [{ type: 'text', text: 'say hi' }],
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
const parts = result.data?.parts ?? [];
|
|
287
|
+
const texts = parts
|
|
288
|
+
.filter((part) => part.type === 'text')
|
|
289
|
+
.map((part) => part.text)
|
|
290
|
+
.join('\n');
|
|
291
|
+
expect(texts).toContain('hello from fallback');
|
|
292
|
+
const summary = summarizeSessionEvents(events);
|
|
293
|
+
expect(summary.some((event) => event.status === 'retry')).toBe(true);
|
|
294
|
+
expect(summary.some((event) => event.name === 'UnknownError')).toBe(false);
|
|
295
|
+
expect(summary.map((event) => {
|
|
296
|
+
if (event.status === 'retry') {
|
|
297
|
+
return { status: 'retry', coolingDown: event.message?.includes('cooling down') };
|
|
298
|
+
}
|
|
299
|
+
return event.status ?? event.type;
|
|
300
|
+
})).toMatchInlineSnapshot(`
|
|
301
|
+
[
|
|
302
|
+
"busy",
|
|
303
|
+
"busy",
|
|
304
|
+
{
|
|
305
|
+
"coolingDown": true,
|
|
306
|
+
"status": "retry",
|
|
307
|
+
},
|
|
308
|
+
"busy",
|
|
309
|
+
]
|
|
310
|
+
`);
|
|
311
|
+
}, 120_000);
|
|
207
312
|
});
|