@subrouter/opencode 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +125 -53
- package/dist/opencode-e2e.test.js +252 -35
- package/dist/plugin.test.js +231 -35
- package/dist/provider.d.ts +34 -15
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +36 -7
- package/package.json +2 -2
- package/src/index.ts +174 -82
- package/src/opencode-e2e.test.ts +273 -36
- package/src/plugin.test.ts +267 -44
- package/src/provider.ts +65 -9
package/dist/index.d.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* model: pick `subrouter/default` (or any preset created with
|
|
8
8
|
* `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
|
|
9
9
|
* visible name is `subrouter.org`. Context limits follow the first live
|
|
10
|
-
* candidate.
|
|
11
|
-
*
|
|
10
|
+
* candidate. GPT candidates spoof a `gpt-*` api id so OpenCode prefers
|
|
11
|
+
* apply_patch over edit/write. Input modalities cover every usable candidate so the
|
|
12
|
+
* router can select a compatible subscription for each prompt. Tool
|
|
13
|
+
* follow-ups stay on the selected route until the session becomes idle.
|
|
12
14
|
*
|
|
13
15
|
* `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
|
|
14
16
|
* (and any harness driving opencode's auth hook, like kimaki's Discord
|
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;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAA;AAkD9D,eAAO,MAAM,eAAe,EAAE,MA0L7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MAsFjC,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* model: pick `subrouter/default` (or any preset created with
|
|
8
8
|
* `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
|
|
9
9
|
* visible name is `subrouter.org`. Context limits follow the first live
|
|
10
|
-
* candidate.
|
|
11
|
-
*
|
|
10
|
+
* candidate. GPT candidates spoof a `gpt-*` api id so OpenCode prefers
|
|
11
|
+
* apply_patch over edit/write. Input modalities cover every usable candidate so the
|
|
12
|
+
* router can select a compatible subscription for each prompt. Tool
|
|
13
|
+
* follow-ups stay on the selected route until the session becomes idle.
|
|
12
14
|
*
|
|
13
15
|
* `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
|
|
14
16
|
* (and any harness driving opencode's auth hook, like kimaki's Discord
|
|
@@ -18,8 +20,8 @@
|
|
|
18
20
|
* NOTE: only plugin initializer functions may be exported from this module.
|
|
19
21
|
* OpenCode calls every export as a plugin.
|
|
20
22
|
*/
|
|
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";
|
|
23
|
+
import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadModelsDevCatalog, loadPresets, modelsDevInputModalities, modelsDevLimit, modelsDevModel, variantProviderOptions, PROVIDER_DISPLAY_NAME, PROVIDER_ID, PROVIDER_IDS, clearLiveRoute, resolveCandidates, resolvePresetModels, RouteAffinity, } from '@subrouter/cli';
|
|
24
|
+
import { addSubrouterHeaders, applyPatchApiId, revealRoutedModel, shouldUseApplyPatch, } from "./provider.js";
|
|
23
25
|
function providerEntryUrl() {
|
|
24
26
|
const isDev = import.meta.url.endsWith('.ts');
|
|
25
27
|
return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href;
|
|
@@ -41,20 +43,40 @@ function opencodeLog(client) {
|
|
|
41
43
|
}
|
|
42
44
|
export const subrouterPlugin = async ({ client, directory }) => {
|
|
43
45
|
const log = opencodeLog(client);
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
const affinity = new RouteAffinity();
|
|
47
|
+
const activeMessages = new Map();
|
|
48
|
+
const deliveredNotices = new Map();
|
|
49
|
+
// Surface the fallback as a TUI toast, the same channel OpenCode plugins use for
|
|
50
|
+
// notifications (see kimaki's legacy anthropic/openai/xai auth plugins). A toast is
|
|
51
|
+
// not persisted into the transcript, so it never becomes a fake user/assistant
|
|
52
|
+
// message or enters model context. tui.toast.show is a global event with no session
|
|
53
|
+
// field, so append the OpenCode session id at the end: kimaki routes the toast to
|
|
54
|
+
// the matching Discord thread by that marker and strips it before display. Plain
|
|
55
|
+
// OpenCode TUI just shows the toast for the active session.
|
|
56
|
+
const onCooldownFallback = async (notice) => {
|
|
57
|
+
if (!notice.sessionID || !notice.agent || notice.agent === 'title')
|
|
49
58
|
return;
|
|
50
59
|
const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`;
|
|
51
60
|
const active = `${notice.active.provider}/${notice.active.modelId}`;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
const text = `Subrouter: Using ${active} because ${preferred} is rate limited.`;
|
|
62
|
+
const delivered = deliveredNotices.get(notice.sessionID);
|
|
63
|
+
if (delivered?.text === text && delivered.expiresAt > Date.now())
|
|
64
|
+
return;
|
|
65
|
+
const current = { text, expiresAt: Date.now() + notice.preferred.retryAfterMs };
|
|
66
|
+
deliveredNotices.set(notice.sessionID, current);
|
|
67
|
+
const result = await client.tui
|
|
68
|
+
.showToast({
|
|
69
|
+
query: { directory },
|
|
70
|
+
body: { message: `${text} ${notice.sessionID}`, variant: 'info' },
|
|
71
|
+
throwOnError: true,
|
|
72
|
+
})
|
|
73
|
+
.catch((cause) => new Error('failed to show cooldown fallback toast', { cause }));
|
|
74
|
+
if (!(result instanceof Error))
|
|
75
|
+
return;
|
|
76
|
+
if (deliveredNotices.get(notice.sessionID) === current) {
|
|
77
|
+
deliveredNotices.delete(notice.sessionID);
|
|
78
|
+
}
|
|
79
|
+
void log?.({ level: 'warn', message: result.message });
|
|
58
80
|
};
|
|
59
81
|
return {
|
|
60
82
|
config: async (config) => {
|
|
@@ -63,7 +85,9 @@ export const subrouterPlugin = async ({ client, directory }) => {
|
|
|
63
85
|
});
|
|
64
86
|
const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)]);
|
|
65
87
|
const catalog = await loadModelsDevCatalog({ log });
|
|
66
|
-
const
|
|
88
|
+
const takenApiIds = new Set();
|
|
89
|
+
const presetByApiId = {};
|
|
90
|
+
const resolved = await Promise.all([...names].map(async (name) => {
|
|
67
91
|
const presetModels = await resolvePresetModels(name);
|
|
68
92
|
const candidates = presetModels instanceof Error
|
|
69
93
|
? []
|
|
@@ -95,60 +119,97 @@ export const subrouterPlugin = async ({ client, directory }) => {
|
|
|
95
119
|
for (const modality of modalities)
|
|
96
120
|
input.add(modality);
|
|
97
121
|
}
|
|
98
|
-
return
|
|
122
|
+
return { name, candidate, attachment, input, limit };
|
|
123
|
+
}));
|
|
124
|
+
const models = Object.fromEntries(resolved.map(({ name, candidate, attachment, input, limit }) => {
|
|
125
|
+
const apiId = candidate && shouldUseApplyPatch(candidate.modelId)
|
|
126
|
+
? applyPatchApiId({
|
|
127
|
+
preset: name,
|
|
128
|
+
modelId: candidate.modelId,
|
|
129
|
+
taken: takenApiIds,
|
|
130
|
+
})
|
|
131
|
+
: undefined;
|
|
132
|
+
if (apiId) {
|
|
133
|
+
takenApiIds.add(apiId);
|
|
134
|
+
presetByApiId[apiId] = name;
|
|
135
|
+
}
|
|
136
|
+
const catalogModel = candidate
|
|
137
|
+
? modelsDevModel({
|
|
138
|
+
provider: candidate.provider,
|
|
139
|
+
modelId: candidate.modelId,
|
|
140
|
+
catalog,
|
|
141
|
+
})
|
|
142
|
+
: null;
|
|
143
|
+
const reasoning = catalogModel?.reasoning ?? false;
|
|
144
|
+
const variants = candidate
|
|
145
|
+
? Object.fromEntries((catalogModel?.variants ?? []).map((variant) => [
|
|
146
|
+
variant,
|
|
147
|
+
variantProviderOptions({
|
|
148
|
+
provider: candidate.provider,
|
|
149
|
+
modelId: candidate.modelId,
|
|
150
|
+
variant,
|
|
151
|
+
}),
|
|
152
|
+
]))
|
|
153
|
+
: {};
|
|
154
|
+
const model = {
|
|
99
155
|
name,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 },
|
|
156
|
+
tool_call: true,
|
|
157
|
+
attachment,
|
|
158
|
+
reasoning,
|
|
159
|
+
modalities: {
|
|
160
|
+
input: [...input],
|
|
161
|
+
output: ['text'],
|
|
111
162
|
},
|
|
112
|
-
|
|
113
|
-
|
|
163
|
+
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
164
|
+
limit: limit ?? { context: 200_000, output: 64_000 },
|
|
165
|
+
};
|
|
166
|
+
if (apiId)
|
|
167
|
+
model.id = apiId;
|
|
168
|
+
if (Object.keys(variants).length > 0)
|
|
169
|
+
model.variants = variants;
|
|
170
|
+
return [name, model];
|
|
171
|
+
}));
|
|
114
172
|
config.provider = {
|
|
115
173
|
...config.provider,
|
|
116
174
|
[PROVIDER_ID]: {
|
|
117
175
|
name: PROVIDER_DISPLAY_NAME,
|
|
118
176
|
npm: providerEntryUrl(),
|
|
119
177
|
models,
|
|
120
|
-
options: { log, onCooldownFallback },
|
|
178
|
+
options: { affinity, log, onCooldownFallback, presetByApiId },
|
|
121
179
|
},
|
|
122
180
|
};
|
|
123
181
|
},
|
|
124
182
|
event: async ({ event }) => {
|
|
125
|
-
if (event.type
|
|
183
|
+
if (event.type === 'session.deleted') {
|
|
184
|
+
const sessionID = event.properties.info.id;
|
|
185
|
+
const messageID = activeMessages.get(sessionID);
|
|
186
|
+
if (messageID)
|
|
187
|
+
affinity.clear(messageID);
|
|
188
|
+
activeMessages.delete(sessionID);
|
|
189
|
+
deliveredNotices.delete(sessionID);
|
|
190
|
+
await clearLiveRoute(sessionID);
|
|
126
191
|
return;
|
|
127
|
-
|
|
128
|
-
if (
|
|
192
|
+
}
|
|
193
|
+
if (event.type !== 'session.idle')
|
|
129
194
|
return;
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
body,
|
|
143
|
-
})
|
|
144
|
-
.catch(() => { });
|
|
195
|
+
const sessionID = event.properties.sessionID;
|
|
196
|
+
const messageID = activeMessages.get(sessionID);
|
|
197
|
+
if (messageID)
|
|
198
|
+
affinity.clear(messageID);
|
|
199
|
+
activeMessages.delete(sessionID);
|
|
200
|
+
await clearLiveRoute(sessionID);
|
|
201
|
+
},
|
|
202
|
+
'chat.headers': async (input, output) => {
|
|
203
|
+
const activeMessage = activeMessages.get(input.sessionID) ?? input.message.id;
|
|
204
|
+
const affinityKey = addSubrouterHeaders({ input, output, affinityKey: activeMessage });
|
|
205
|
+
if (affinityKey)
|
|
206
|
+
activeMessages.set(input.sessionID, affinityKey);
|
|
145
207
|
},
|
|
146
|
-
'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
|
|
147
|
-
// OpenCode identity uses the preset id; rewrite it to the live routed model.
|
|
148
208
|
'experimental.chat.system.transform': async (input, output) => {
|
|
149
209
|
await revealRoutedModel({
|
|
150
210
|
providerID: input.model.providerID,
|
|
151
211
|
preset: input.model.id,
|
|
212
|
+
sessionID: input.sessionID,
|
|
152
213
|
system: output.system,
|
|
153
214
|
});
|
|
154
215
|
},
|
|
@@ -171,7 +232,8 @@ function toOpencodeCredentials(account) {
|
|
|
171
232
|
accountId: account.accountId,
|
|
172
233
|
};
|
|
173
234
|
}
|
|
174
|
-
export const subrouterAuthPlugin = async () => {
|
|
235
|
+
export const subrouterAuthPlugin = async ({ client }) => {
|
|
236
|
+
const log = opencodeLog(client);
|
|
175
237
|
return {
|
|
176
238
|
auth: {
|
|
177
239
|
provider: PROVIDER_ID,
|
|
@@ -218,8 +280,18 @@ export const subrouterAuthPlugin = async () => {
|
|
|
218
280
|
throw session;
|
|
219
281
|
const finish = async (input) => {
|
|
220
282
|
const account = await session.complete(input);
|
|
221
|
-
if (account instanceof Error)
|
|
283
|
+
if (account instanceof Error) {
|
|
284
|
+
// opencode's AuthOAuthResult has no failure-with-message
|
|
285
|
+
// variant, so the real reason cannot travel back through
|
|
286
|
+
// oauth.callback and harnesses show a generic fallback. Log it
|
|
287
|
+
// here so the cause is diagnosable instead of lost.
|
|
288
|
+
void log?.({
|
|
289
|
+
level: 'error',
|
|
290
|
+
message: `login failed: ${account.message}`,
|
|
291
|
+
extra: { provider: providerId },
|
|
292
|
+
});
|
|
222
293
|
return { type: 'failed' };
|
|
294
|
+
}
|
|
223
295
|
await addAccount({ provider: providerId, account });
|
|
224
296
|
return toOpencodeCredentials(account);
|
|
225
297
|
};
|
|
@@ -12,12 +12,12 @@
|
|
|
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, mkdir } from 'node:fs/promises';
|
|
15
|
+
import { mkdtemp, rm, mkdir, writeFile } 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
|
-
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
|
20
|
-
import { OPENCODE_AGENT_HEADER, OPENCODE_VARIANT_HEADER, OPENAI_WEBSOCKET_SESSION_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, addAccount, markCooldown, } from '@subrouter/cli';
|
|
19
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
|
|
20
|
+
import { OPENCODE_AGENT_HEADER, OPENCODE_VARIANT_HEADER, OPENAI_WEBSOCKET_SESSION_HEADER, ROUTE_AFFINITY_HEADER, OPENAI_WEBSOCKET_TITLE_HEADER, addAccount, clearCooldowns, markCooldown, savePreset, } from '@subrouter/cli';
|
|
21
21
|
import { addSubrouterHeaders } from "./provider.js";
|
|
22
22
|
function summarizeSessionEvents(events) {
|
|
23
23
|
const summary = [];
|
|
@@ -90,6 +90,9 @@ let projectDir;
|
|
|
90
90
|
let anthropicMock;
|
|
91
91
|
let modelsDevMock;
|
|
92
92
|
let zenMock;
|
|
93
|
+
let openaiMock;
|
|
94
|
+
let zenRespond;
|
|
95
|
+
let defaultZenRespond;
|
|
93
96
|
let server;
|
|
94
97
|
const savedEnv = {};
|
|
95
98
|
beforeAll(async () => {
|
|
@@ -102,7 +105,7 @@ beforeAll(async () => {
|
|
|
102
105
|
res.end(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }));
|
|
103
106
|
});
|
|
104
107
|
// Fake opencode-go: streams a canned completion
|
|
105
|
-
|
|
108
|
+
defaultZenRespond = ({ body }, res) => {
|
|
106
109
|
const streaming = body.includes('"stream":true');
|
|
107
110
|
if (!streaming) {
|
|
108
111
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -138,6 +141,53 @@ beforeAll(async () => {
|
|
|
138
141
|
}));
|
|
139
142
|
res.write('data: [DONE]\n\n');
|
|
140
143
|
res.end();
|
|
144
|
+
};
|
|
145
|
+
zenRespond = defaultZenRespond;
|
|
146
|
+
zenMock = await startMockServer((request, response) => zenRespond(request, response));
|
|
147
|
+
openaiMock = await startMockServer((_request, res) => {
|
|
148
|
+
const text = 'hello from openai';
|
|
149
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
150
|
+
for (const event of [
|
|
151
|
+
{
|
|
152
|
+
type: 'response.created',
|
|
153
|
+
response: { id: 'resp-1', created_at: 1, model: 'gpt-5.5', service_tier: null },
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
type: 'response.output_item.added',
|
|
157
|
+
output_index: 0,
|
|
158
|
+
item: { type: 'message', id: 'msg-1', role: 'assistant', status: 'in_progress', content: [] },
|
|
159
|
+
},
|
|
160
|
+
{ type: 'response.content_part.added', part: { type: 'output_text', text: '' } },
|
|
161
|
+
{ type: 'response.output_text.delta', item_id: 'msg-1', delta: text },
|
|
162
|
+
{
|
|
163
|
+
type: 'response.output_item.done',
|
|
164
|
+
output_index: 0,
|
|
165
|
+
item: {
|
|
166
|
+
type: 'message',
|
|
167
|
+
id: 'msg-1',
|
|
168
|
+
role: 'assistant',
|
|
169
|
+
status: 'completed',
|
|
170
|
+
content: [{ type: 'output_text', text }],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
type: 'response.completed',
|
|
175
|
+
response: {
|
|
176
|
+
id: 'resp-1',
|
|
177
|
+
status: 'completed',
|
|
178
|
+
usage: {
|
|
179
|
+
input_tokens: 5,
|
|
180
|
+
output_tokens: 3,
|
|
181
|
+
total_tokens: 8,
|
|
182
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
]) {
|
|
187
|
+
res.write(sseChunk(event));
|
|
188
|
+
}
|
|
189
|
+
res.write('data: [DONE]\n\n');
|
|
190
|
+
res.end();
|
|
141
191
|
});
|
|
142
192
|
modelsDevMock = await startMockServer((_request, res) => {
|
|
143
193
|
const emptyProvider = { models: {} };
|
|
@@ -150,7 +200,7 @@ beforeAll(async () => {
|
|
|
150
200
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
151
201
|
res.end(JSON.stringify({
|
|
152
202
|
anthropic: { models: { 'claude-opus-4-6': pdfModel('claude-opus-4-6') } },
|
|
153
|
-
openai:
|
|
203
|
+
openai: { models: { 'gpt-5.5': pdfModel('gpt-5.5') } },
|
|
154
204
|
xai: emptyProvider,
|
|
155
205
|
'opencode-go': { models: { 'grok-4.6': pdfModel('grok-4.6') } },
|
|
156
206
|
'github-copilot': emptyProvider,
|
|
@@ -169,6 +219,7 @@ beforeAll(async () => {
|
|
|
169
219
|
SUBROUTER_ANTHROPIC_BASE_URL: `${anthropicMock.url}/v1`,
|
|
170
220
|
SUBROUTER_MODELS_DEV_URL: modelsDevMock.url,
|
|
171
221
|
SUBROUTER_OPENCODE_GO_BASE_URL: `${zenMock.url}/v1`,
|
|
222
|
+
SUBROUTER_OPENAI_BASE_URL: openaiMock.url,
|
|
172
223
|
// Isolate opencode from the user's real global config and auth
|
|
173
224
|
XDG_CONFIG_HOME: path.join(home, 'xdg-config'),
|
|
174
225
|
XDG_DATA_HOME: path.join(home, 'xdg-data'),
|
|
@@ -194,6 +245,23 @@ beforeAll(async () => {
|
|
|
194
245
|
provider: 'opencode-go',
|
|
195
246
|
account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
|
|
196
247
|
});
|
|
248
|
+
await addAccount({
|
|
249
|
+
provider: 'openai',
|
|
250
|
+
account: {
|
|
251
|
+
type: 'oauth',
|
|
252
|
+
refresh: 'openai-refresh',
|
|
253
|
+
access: 'openai-access',
|
|
254
|
+
expires: Date.now() + 1_000_000_000,
|
|
255
|
+
email: 'o@x.com',
|
|
256
|
+
addedAt: 1,
|
|
257
|
+
lastUsed: 1,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
await savePreset({
|
|
261
|
+
name: 'default',
|
|
262
|
+
models: ['anthropic/claude-opus-4-6', 'opencode-go/grok-4.6'],
|
|
263
|
+
});
|
|
264
|
+
await savePreset({ name: 'openai-only', models: ['openai/gpt-5.5'] });
|
|
197
265
|
const providerEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'provider.js')).href;
|
|
198
266
|
const pluginEntry = pathToFileURL(path.join(import.meta.dirname, '..', 'dist', 'index.js')).href;
|
|
199
267
|
server = await createOpencodeServer({
|
|
@@ -220,11 +288,15 @@ beforeAll(async () => {
|
|
|
220
288
|
},
|
|
221
289
|
});
|
|
222
290
|
}, 120_000);
|
|
291
|
+
afterEach(() => {
|
|
292
|
+
zenRespond = defaultZenRespond;
|
|
293
|
+
});
|
|
223
294
|
afterAll(async () => {
|
|
224
295
|
server?.close();
|
|
225
296
|
await anthropicMock?.close();
|
|
226
297
|
await modelsDevMock?.close();
|
|
227
298
|
await zenMock?.close();
|
|
299
|
+
await openaiMock?.close();
|
|
228
300
|
for (const [key, value] of Object.entries(savedEnv)) {
|
|
229
301
|
if (value === undefined)
|
|
230
302
|
delete process.env[key];
|
|
@@ -237,29 +309,38 @@ describe('opencode + subrouter provider', () => {
|
|
|
237
309
|
test('adds session affinity headers for subrouter models', async () => {
|
|
238
310
|
const output = { headers: {} };
|
|
239
311
|
addSubrouterHeaders({
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
model: { providerID: 'subrouter' },
|
|
243
|
-
message: {
|
|
312
|
+
input: {
|
|
313
|
+
sessionID: 'session-1',
|
|
244
314
|
agent: 'build',
|
|
245
|
-
model: { providerID: 'subrouter'
|
|
315
|
+
model: { providerID: 'subrouter' },
|
|
316
|
+
message: {
|
|
317
|
+
id: 'message-1',
|
|
318
|
+
agent: 'build',
|
|
319
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
320
|
+
},
|
|
246
321
|
},
|
|
247
|
-
|
|
322
|
+
output,
|
|
323
|
+
});
|
|
248
324
|
expect(output.headers).toEqual({
|
|
249
325
|
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-1',
|
|
326
|
+
[ROUTE_AFFINITY_HEADER]: 'message-1',
|
|
250
327
|
[OPENCODE_AGENT_HEADER]: 'build',
|
|
251
328
|
[OPENCODE_VARIANT_HEADER]: 'high',
|
|
252
329
|
});
|
|
253
330
|
const titleOutput = { headers: {} };
|
|
254
331
|
addSubrouterHeaders({
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
332
|
+
input: {
|
|
333
|
+
sessionID: 'session-2',
|
|
334
|
+
agent: 'title',
|
|
335
|
+
model: { providerID: 'subrouter' },
|
|
336
|
+
message: {
|
|
337
|
+
id: 'message-1',
|
|
338
|
+
agent: 'build',
|
|
339
|
+
model: { providerID: 'subrouter', modelID: 'build', variant: 'high' },
|
|
340
|
+
},
|
|
261
341
|
},
|
|
262
|
-
|
|
342
|
+
output: titleOutput,
|
|
343
|
+
});
|
|
263
344
|
expect(titleOutput.headers).toEqual({
|
|
264
345
|
[OPENAI_WEBSOCKET_SESSION_HEADER]: 'session-2',
|
|
265
346
|
[OPENAI_WEBSOCKET_TITLE_HEADER]: 'true',
|
|
@@ -290,8 +371,18 @@ describe('opencode + subrouter provider', () => {
|
|
|
290
371
|
expect(anthropicMock.requests.length).toBeGreaterThan(0);
|
|
291
372
|
expect(zenMock.requests.length).toBeGreaterThan(0);
|
|
292
373
|
}, 120_000);
|
|
293
|
-
test('a pre-existing cooldown
|
|
374
|
+
test('a pre-existing cooldown shows one session toast without another model turn', async () => {
|
|
294
375
|
const client = createOpencodeClient({ baseUrl: server.url });
|
|
376
|
+
const toasts = [];
|
|
377
|
+
const subscription = await client.event.subscribe({
|
|
378
|
+
query: { directory: projectDir },
|
|
379
|
+
});
|
|
380
|
+
void (async () => {
|
|
381
|
+
for await (const event of subscription.stream) {
|
|
382
|
+
if (event.type === 'tui.toast.show')
|
|
383
|
+
toasts.push({ message: event.properties.message });
|
|
384
|
+
}
|
|
385
|
+
})();
|
|
295
386
|
const session = await client.session.create({
|
|
296
387
|
query: { directory: projectDir },
|
|
297
388
|
body: { title: 'subrouter route notice' },
|
|
@@ -310,27 +401,16 @@ describe('opencode + subrouter provider', () => {
|
|
|
310
401
|
.filter((part) => part.type === 'text')
|
|
311
402
|
.map((part) => part.text)
|
|
312
403
|
.join('\n')).toContain('hello from fallback');
|
|
404
|
+
// The toast is session-scoped through the trailing session-id marker and never
|
|
405
|
+
// enters the transcript, so no extra user/assistant message and no extra model turn.
|
|
313
406
|
await expect
|
|
314
|
-
.poll(
|
|
315
|
-
|
|
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);
|
|
407
|
+
.poll(() => toasts.filter(({ message }) => message.startsWith('Subrouter: Using ') && message.endsWith(session.data.id)).length)
|
|
408
|
+
.toBe(1);
|
|
324
409
|
const messages = await client.session.messages({
|
|
325
410
|
path: { id: session.data.id },
|
|
326
411
|
query: { directory: projectDir },
|
|
327
412
|
});
|
|
328
|
-
|
|
329
|
-
expect(notice?.info).toMatchObject({
|
|
330
|
-
role: 'user',
|
|
331
|
-
agent: 'build',
|
|
332
|
-
model: { providerID: 'subrouter', modelID: 'default' },
|
|
333
|
-
});
|
|
413
|
+
expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(1);
|
|
334
414
|
expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1);
|
|
335
415
|
expect(zenMock.requests).toHaveLength(requestsBefore + 1);
|
|
336
416
|
}, 120_000);
|
|
@@ -437,4 +517,141 @@ describe('opencode + subrouter provider', () => {
|
|
|
437
517
|
]
|
|
438
518
|
`);
|
|
439
519
|
}, 120_000);
|
|
520
|
+
test('keeps the fallback candidate through tool follow-ups until the session is idle', async () => {
|
|
521
|
+
await clearCooldowns();
|
|
522
|
+
await markCooldown({
|
|
523
|
+
provider: 'anthropic',
|
|
524
|
+
account: {
|
|
525
|
+
type: 'oauth',
|
|
526
|
+
refresh: 'fake-refresh',
|
|
527
|
+
access: 'fake-access',
|
|
528
|
+
email: 'a@x.com',
|
|
529
|
+
addedAt: 1,
|
|
530
|
+
lastUsed: 1,
|
|
531
|
+
},
|
|
532
|
+
untilMs: Date.now() + 60_000,
|
|
533
|
+
});
|
|
534
|
+
const readable = path.join(projectDir, 'message.txt');
|
|
535
|
+
await writeFile(readable, 'tool result');
|
|
536
|
+
const fallbackBodies = [];
|
|
537
|
+
let fallbackCalls = 0;
|
|
538
|
+
let cooldownCleared = Promise.resolve();
|
|
539
|
+
zenRespond = ({ body }, res) => {
|
|
540
|
+
fallbackBodies.push(body);
|
|
541
|
+
fallbackCalls++;
|
|
542
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
543
|
+
if (fallbackCalls === 1) {
|
|
544
|
+
cooldownCleared = clearCooldowns();
|
|
545
|
+
res.write(sseChunk({
|
|
546
|
+
id: 'tool-1',
|
|
547
|
+
object: 'chat.completion.chunk',
|
|
548
|
+
created: 1,
|
|
549
|
+
model: 'fake-model',
|
|
550
|
+
choices: [
|
|
551
|
+
{
|
|
552
|
+
index: 0,
|
|
553
|
+
delta: {
|
|
554
|
+
role: 'assistant',
|
|
555
|
+
tool_calls: [
|
|
556
|
+
{
|
|
557
|
+
index: 0,
|
|
558
|
+
id: 'call-read',
|
|
559
|
+
type: 'function',
|
|
560
|
+
function: { name: 'read', arguments: JSON.stringify({ filePath: readable }) },
|
|
561
|
+
},
|
|
562
|
+
],
|
|
563
|
+
},
|
|
564
|
+
finish_reason: null,
|
|
565
|
+
},
|
|
566
|
+
],
|
|
567
|
+
}));
|
|
568
|
+
res.write(sseChunk({
|
|
569
|
+
id: 'tool-1',
|
|
570
|
+
object: 'chat.completion.chunk',
|
|
571
|
+
created: 1,
|
|
572
|
+
model: 'fake-model',
|
|
573
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
|
574
|
+
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
|
|
575
|
+
}));
|
|
576
|
+
res.end('data: [DONE]\n\n');
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
res.write(sseChunk({
|
|
580
|
+
id: 'text-1',
|
|
581
|
+
object: 'chat.completion.chunk',
|
|
582
|
+
created: 1,
|
|
583
|
+
model: 'fake-model',
|
|
584
|
+
choices: [{ index: 0, delta: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }],
|
|
585
|
+
}));
|
|
586
|
+
res.write(sseChunk({
|
|
587
|
+
id: 'text-1',
|
|
588
|
+
object: 'chat.completion.chunk',
|
|
589
|
+
created: 1,
|
|
590
|
+
model: 'fake-model',
|
|
591
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
|
592
|
+
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
|
|
593
|
+
}));
|
|
594
|
+
res.end('data: [DONE]\n\n');
|
|
595
|
+
};
|
|
596
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
597
|
+
const session = await client.session.create({
|
|
598
|
+
query: { directory: projectDir },
|
|
599
|
+
body: { title: 'subrouter tool affinity' },
|
|
600
|
+
});
|
|
601
|
+
const anthropicBefore = anthropicMock.requests.length;
|
|
602
|
+
await client.session.prompt({
|
|
603
|
+
path: { id: session.data.id },
|
|
604
|
+
query: { directory: projectDir },
|
|
605
|
+
body: {
|
|
606
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
607
|
+
parts: [{ type: 'text', text: 'read the file' }],
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
expect(anthropicMock.requests).toHaveLength(anthropicBefore);
|
|
611
|
+
expect(fallbackBodies
|
|
612
|
+
.slice(0, 2)
|
|
613
|
+
.map((body) => body.includes('You are powered by the model named grok-4.6'))).toEqual([true, true]);
|
|
614
|
+
await cooldownCleared;
|
|
615
|
+
await clearCooldowns();
|
|
616
|
+
await client.session.prompt({
|
|
617
|
+
path: { id: session.data.id },
|
|
618
|
+
query: { directory: projectDir },
|
|
619
|
+
body: {
|
|
620
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
621
|
+
parts: [{ type: 'text', text: 'say done' }],
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
expect(anthropicMock.requests).toHaveLength(anthropicBefore + 1);
|
|
625
|
+
expect(fallbackCalls).toBeGreaterThanOrEqual(3);
|
|
626
|
+
}, 120_000);
|
|
627
|
+
test('openai live model advertises apply_patch and not edit or write', async () => {
|
|
628
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
629
|
+
const session = await client.session.create({
|
|
630
|
+
query: { directory: projectDir },
|
|
631
|
+
body: { title: 'subrouter apply_patch' },
|
|
632
|
+
});
|
|
633
|
+
expect(session.data).toBeTruthy();
|
|
634
|
+
const result = await client.session.prompt({
|
|
635
|
+
path: { id: session.data.id },
|
|
636
|
+
query: { directory: projectDir },
|
|
637
|
+
body: {
|
|
638
|
+
model: { providerID: 'subrouter', modelID: 'openai-only' },
|
|
639
|
+
parts: [{ type: 'text', text: 'say hi' }],
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
const texts = (result.data?.parts ?? [])
|
|
643
|
+
.filter((part) => part.type === 'text')
|
|
644
|
+
.map((part) => part.text)
|
|
645
|
+
.join('\n');
|
|
646
|
+
expect(texts).toContain('hello from openai');
|
|
647
|
+
expect(openaiMock.requests.length).toBeGreaterThan(0);
|
|
648
|
+
const raw = openaiMock.requests.at(-1).body;
|
|
649
|
+
const body = JSON.parse(raw);
|
|
650
|
+
const names = (body.tools ?? []).map((tool) => tool.name ?? tool.function?.name);
|
|
651
|
+
expect(names).toContain('apply_patch');
|
|
652
|
+
expect(names).not.toContain('edit');
|
|
653
|
+
expect(names).not.toContain('write');
|
|
654
|
+
expect(raw).toContain('apply_patch');
|
|
655
|
+
expect(raw.includes('"name":"edit"') || raw.includes('"name": "edit"')).toBe(false);
|
|
656
|
+
}, 120_000);
|
|
440
657
|
});
|