@subrouter/opencode 0.4.0 → 0.5.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 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +111 -49
- package/dist/opencode-e2e.test.js +240 -29
- package/dist/plugin.test.js +228 -23
- 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 +159 -78
- package/src/opencode-e2e.test.ts +258 -30
- package/src/plugin.test.ts +263 -32
- 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,MA2L7B,CAAA;AAoBD,eAAO,MAAM,mBAAmB,EAAE,MA0EjC,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,41 @@ 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
|
-
if (
|
|
46
|
+
const affinity = new RouteAffinity();
|
|
47
|
+
const activeMessages = new Map();
|
|
48
|
+
const deliveredNotices = new Map();
|
|
49
|
+
const onCooldownFallback = async (notice) => {
|
|
50
|
+
if (!notice.sessionID || !notice.agent || notice.agent === 'title')
|
|
49
51
|
return;
|
|
50
52
|
const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`;
|
|
51
53
|
const active = `${notice.active.provider}/${notice.active.modelId}`;
|
|
52
|
-
|
|
54
|
+
const text = `Subrouter: Using ${active} because ${preferred} is rate limited.`;
|
|
55
|
+
const delivered = deliveredNotices.get(notice.sessionID);
|
|
56
|
+
if (delivered?.text === text && delivered.expiresAt > Date.now())
|
|
57
|
+
return;
|
|
58
|
+
const current = { text, expiresAt: Date.now() + notice.preferred.retryAfterMs };
|
|
59
|
+
deliveredNotices.set(notice.sessionID, current);
|
|
60
|
+
const body = {
|
|
61
|
+
noReply: true,
|
|
53
62
|
agent: notice.agent,
|
|
63
|
+
model: { providerID: PROVIDER_ID, modelID: notice.preset },
|
|
54
64
|
variant: notice.variant,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
parts: [{ type: 'text', text, ignored: true }],
|
|
66
|
+
};
|
|
67
|
+
const result = await client.session
|
|
68
|
+
.prompt({
|
|
69
|
+
path: { id: notice.sessionID },
|
|
70
|
+
query: { directory },
|
|
71
|
+
body,
|
|
72
|
+
throwOnError: true,
|
|
73
|
+
})
|
|
74
|
+
.catch((cause) => new Error('failed to persist cooldown fallback notice', { cause }));
|
|
75
|
+
if (!(result instanceof Error))
|
|
76
|
+
return;
|
|
77
|
+
if (deliveredNotices.get(notice.sessionID) === current) {
|
|
78
|
+
deliveredNotices.delete(notice.sessionID);
|
|
79
|
+
}
|
|
80
|
+
void log?.({ level: 'warn', message: result.message });
|
|
58
81
|
};
|
|
59
82
|
return {
|
|
60
83
|
config: async (config) => {
|
|
@@ -63,7 +86,9 @@ export const subrouterPlugin = async ({ client, directory }) => {
|
|
|
63
86
|
});
|
|
64
87
|
const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)]);
|
|
65
88
|
const catalog = await loadModelsDevCatalog({ log });
|
|
66
|
-
const
|
|
89
|
+
const takenApiIds = new Set();
|
|
90
|
+
const presetByApiId = {};
|
|
91
|
+
const resolved = await Promise.all([...names].map(async (name) => {
|
|
67
92
|
const presetModels = await resolvePresetModels(name);
|
|
68
93
|
const candidates = presetModels instanceof Error
|
|
69
94
|
? []
|
|
@@ -95,60 +120,97 @@ export const subrouterPlugin = async ({ client, directory }) => {
|
|
|
95
120
|
for (const modality of modalities)
|
|
96
121
|
input.add(modality);
|
|
97
122
|
}
|
|
98
|
-
return
|
|
123
|
+
return { name, candidate, attachment, input, limit };
|
|
124
|
+
}));
|
|
125
|
+
const models = Object.fromEntries(resolved.map(({ name, candidate, attachment, input, limit }) => {
|
|
126
|
+
const apiId = candidate && shouldUseApplyPatch(candidate.modelId)
|
|
127
|
+
? applyPatchApiId({
|
|
128
|
+
preset: name,
|
|
129
|
+
modelId: candidate.modelId,
|
|
130
|
+
taken: takenApiIds,
|
|
131
|
+
})
|
|
132
|
+
: undefined;
|
|
133
|
+
if (apiId) {
|
|
134
|
+
takenApiIds.add(apiId);
|
|
135
|
+
presetByApiId[apiId] = name;
|
|
136
|
+
}
|
|
137
|
+
const catalogModel = candidate
|
|
138
|
+
? modelsDevModel({
|
|
139
|
+
provider: candidate.provider,
|
|
140
|
+
modelId: candidate.modelId,
|
|
141
|
+
catalog,
|
|
142
|
+
})
|
|
143
|
+
: null;
|
|
144
|
+
const reasoning = catalogModel?.reasoning ?? false;
|
|
145
|
+
const variants = candidate
|
|
146
|
+
? Object.fromEntries((catalogModel?.variants ?? []).map((variant) => [
|
|
147
|
+
variant,
|
|
148
|
+
variantProviderOptions({
|
|
149
|
+
provider: candidate.provider,
|
|
150
|
+
modelId: candidate.modelId,
|
|
151
|
+
variant,
|
|
152
|
+
}),
|
|
153
|
+
]))
|
|
154
|
+
: {};
|
|
155
|
+
const model = {
|
|
99
156
|
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 },
|
|
157
|
+
tool_call: true,
|
|
158
|
+
attachment,
|
|
159
|
+
reasoning,
|
|
160
|
+
modalities: {
|
|
161
|
+
input: [...input],
|
|
162
|
+
output: ['text'],
|
|
111
163
|
},
|
|
112
|
-
|
|
113
|
-
|
|
164
|
+
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
|
|
165
|
+
limit: limit ?? { context: 200_000, output: 64_000 },
|
|
166
|
+
};
|
|
167
|
+
if (apiId)
|
|
168
|
+
model.id = apiId;
|
|
169
|
+
if (Object.keys(variants).length > 0)
|
|
170
|
+
model.variants = variants;
|
|
171
|
+
return [name, model];
|
|
172
|
+
}));
|
|
114
173
|
config.provider = {
|
|
115
174
|
...config.provider,
|
|
116
175
|
[PROVIDER_ID]: {
|
|
117
176
|
name: PROVIDER_DISPLAY_NAME,
|
|
118
177
|
npm: providerEntryUrl(),
|
|
119
178
|
models,
|
|
120
|
-
options: { log, onCooldownFallback },
|
|
179
|
+
options: { affinity, log, onCooldownFallback, presetByApiId },
|
|
121
180
|
},
|
|
122
181
|
};
|
|
123
182
|
},
|
|
124
183
|
event: async ({ event }) => {
|
|
125
|
-
if (event.type
|
|
184
|
+
if (event.type === 'session.deleted') {
|
|
185
|
+
const sessionID = event.properties.info.id;
|
|
186
|
+
const messageID = activeMessages.get(sessionID);
|
|
187
|
+
if (messageID)
|
|
188
|
+
affinity.clear(messageID);
|
|
189
|
+
activeMessages.delete(sessionID);
|
|
190
|
+
deliveredNotices.delete(sessionID);
|
|
191
|
+
await clearLiveRoute(sessionID);
|
|
126
192
|
return;
|
|
127
|
-
|
|
128
|
-
if (
|
|
193
|
+
}
|
|
194
|
+
if (event.type !== 'session.idle')
|
|
129
195
|
return;
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
body,
|
|
143
|
-
})
|
|
144
|
-
.catch(() => { });
|
|
196
|
+
const sessionID = event.properties.sessionID;
|
|
197
|
+
const messageID = activeMessages.get(sessionID);
|
|
198
|
+
if (messageID)
|
|
199
|
+
affinity.clear(messageID);
|
|
200
|
+
activeMessages.delete(sessionID);
|
|
201
|
+
await clearLiveRoute(sessionID);
|
|
202
|
+
},
|
|
203
|
+
'chat.headers': async (input, output) => {
|
|
204
|
+
const activeMessage = activeMessages.get(input.sessionID) ?? input.message.id;
|
|
205
|
+
const affinityKey = addSubrouterHeaders({ input, output, affinityKey: activeMessage });
|
|
206
|
+
if (affinityKey)
|
|
207
|
+
activeMessages.set(input.sessionID, affinityKey);
|
|
145
208
|
},
|
|
146
|
-
'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
|
|
147
|
-
// OpenCode identity uses the preset id; rewrite it to the live routed model.
|
|
148
209
|
'experimental.chat.system.transform': async (input, output) => {
|
|
149
210
|
await revealRoutedModel({
|
|
150
211
|
providerID: input.model.providerID,
|
|
151
212
|
preset: input.model.id,
|
|
213
|
+
sessionID: input.sessionID,
|
|
152
214
|
system: output.system,
|
|
153
215
|
});
|
|
154
216
|
},
|
|
@@ -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,7 +371,7 @@ 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 appends one ignored notice without another model turn', async () => {
|
|
294
375
|
const client = createOpencodeClient({ baseUrl: server.url });
|
|
295
376
|
const session = await client.session.create({
|
|
296
377
|
query: { directory: projectDir },
|
|
@@ -316,21 +397,14 @@ describe('opencode + subrouter provider', () => {
|
|
|
316
397
|
path: { id: session.data.id },
|
|
317
398
|
query: { directory: projectDir },
|
|
318
399
|
});
|
|
319
|
-
return (messages.data ?? []).
|
|
320
|
-
part.ignored === true &&
|
|
321
|
-
part.text.includes('was rate limited. This message started with opencode-go/')));
|
|
400
|
+
return (messages.data ?? []).filter(({ parts }) => parts.some((part) => part.type === 'text' && part.ignored === true)).length;
|
|
322
401
|
})
|
|
323
|
-
.toBe(
|
|
402
|
+
.toBe(1);
|
|
324
403
|
const messages = await client.session.messages({
|
|
325
404
|
path: { id: session.data.id },
|
|
326
405
|
query: { directory: projectDir },
|
|
327
406
|
});
|
|
328
|
-
|
|
329
|
-
expect(notice?.info).toMatchObject({
|
|
330
|
-
role: 'user',
|
|
331
|
-
agent: 'build',
|
|
332
|
-
model: { providerID: 'subrouter', modelID: 'default' },
|
|
333
|
-
});
|
|
407
|
+
expect((messages.data ?? []).filter(({ info }) => info.role === 'user')).toHaveLength(2);
|
|
334
408
|
expect((messages.data ?? []).filter(({ info }) => info.role === 'assistant')).toHaveLength(1);
|
|
335
409
|
expect(zenMock.requests).toHaveLength(requestsBefore + 1);
|
|
336
410
|
}, 120_000);
|
|
@@ -437,4 +511,141 @@ describe('opencode + subrouter provider', () => {
|
|
|
437
511
|
]
|
|
438
512
|
`);
|
|
439
513
|
}, 120_000);
|
|
514
|
+
test('keeps the fallback candidate through tool follow-ups until the session is idle', async () => {
|
|
515
|
+
await clearCooldowns();
|
|
516
|
+
await markCooldown({
|
|
517
|
+
provider: 'anthropic',
|
|
518
|
+
account: {
|
|
519
|
+
type: 'oauth',
|
|
520
|
+
refresh: 'fake-refresh',
|
|
521
|
+
access: 'fake-access',
|
|
522
|
+
email: 'a@x.com',
|
|
523
|
+
addedAt: 1,
|
|
524
|
+
lastUsed: 1,
|
|
525
|
+
},
|
|
526
|
+
untilMs: Date.now() + 60_000,
|
|
527
|
+
});
|
|
528
|
+
const readable = path.join(projectDir, 'message.txt');
|
|
529
|
+
await writeFile(readable, 'tool result');
|
|
530
|
+
const fallbackBodies = [];
|
|
531
|
+
let fallbackCalls = 0;
|
|
532
|
+
let cooldownCleared = Promise.resolve();
|
|
533
|
+
zenRespond = ({ body }, res) => {
|
|
534
|
+
fallbackBodies.push(body);
|
|
535
|
+
fallbackCalls++;
|
|
536
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
537
|
+
if (fallbackCalls === 1) {
|
|
538
|
+
cooldownCleared = clearCooldowns();
|
|
539
|
+
res.write(sseChunk({
|
|
540
|
+
id: 'tool-1',
|
|
541
|
+
object: 'chat.completion.chunk',
|
|
542
|
+
created: 1,
|
|
543
|
+
model: 'fake-model',
|
|
544
|
+
choices: [
|
|
545
|
+
{
|
|
546
|
+
index: 0,
|
|
547
|
+
delta: {
|
|
548
|
+
role: 'assistant',
|
|
549
|
+
tool_calls: [
|
|
550
|
+
{
|
|
551
|
+
index: 0,
|
|
552
|
+
id: 'call-read',
|
|
553
|
+
type: 'function',
|
|
554
|
+
function: { name: 'read', arguments: JSON.stringify({ filePath: readable }) },
|
|
555
|
+
},
|
|
556
|
+
],
|
|
557
|
+
},
|
|
558
|
+
finish_reason: null,
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
}));
|
|
562
|
+
res.write(sseChunk({
|
|
563
|
+
id: 'tool-1',
|
|
564
|
+
object: 'chat.completion.chunk',
|
|
565
|
+
created: 1,
|
|
566
|
+
model: 'fake-model',
|
|
567
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
|
568
|
+
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
|
|
569
|
+
}));
|
|
570
|
+
res.end('data: [DONE]\n\n');
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
res.write(sseChunk({
|
|
574
|
+
id: 'text-1',
|
|
575
|
+
object: 'chat.completion.chunk',
|
|
576
|
+
created: 1,
|
|
577
|
+
model: 'fake-model',
|
|
578
|
+
choices: [{ index: 0, delta: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }],
|
|
579
|
+
}));
|
|
580
|
+
res.write(sseChunk({
|
|
581
|
+
id: 'text-1',
|
|
582
|
+
object: 'chat.completion.chunk',
|
|
583
|
+
created: 1,
|
|
584
|
+
model: 'fake-model',
|
|
585
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
|
586
|
+
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
|
|
587
|
+
}));
|
|
588
|
+
res.end('data: [DONE]\n\n');
|
|
589
|
+
};
|
|
590
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
591
|
+
const session = await client.session.create({
|
|
592
|
+
query: { directory: projectDir },
|
|
593
|
+
body: { title: 'subrouter tool affinity' },
|
|
594
|
+
});
|
|
595
|
+
const anthropicBefore = anthropicMock.requests.length;
|
|
596
|
+
await client.session.prompt({
|
|
597
|
+
path: { id: session.data.id },
|
|
598
|
+
query: { directory: projectDir },
|
|
599
|
+
body: {
|
|
600
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
601
|
+
parts: [{ type: 'text', text: 'read the file' }],
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
expect(anthropicMock.requests).toHaveLength(anthropicBefore);
|
|
605
|
+
expect(fallbackBodies
|
|
606
|
+
.slice(0, 2)
|
|
607
|
+
.map((body) => body.includes('You are powered by the model named grok-4.6'))).toEqual([true, true]);
|
|
608
|
+
await cooldownCleared;
|
|
609
|
+
await clearCooldowns();
|
|
610
|
+
await client.session.prompt({
|
|
611
|
+
path: { id: session.data.id },
|
|
612
|
+
query: { directory: projectDir },
|
|
613
|
+
body: {
|
|
614
|
+
model: { providerID: 'subrouter', modelID: 'default' },
|
|
615
|
+
parts: [{ type: 'text', text: 'say done' }],
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
expect(anthropicMock.requests).toHaveLength(anthropicBefore + 1);
|
|
619
|
+
expect(fallbackCalls).toBeGreaterThanOrEqual(3);
|
|
620
|
+
}, 120_000);
|
|
621
|
+
test('openai live model advertises apply_patch and not edit or write', async () => {
|
|
622
|
+
const client = createOpencodeClient({ baseUrl: server.url });
|
|
623
|
+
const session = await client.session.create({
|
|
624
|
+
query: { directory: projectDir },
|
|
625
|
+
body: { title: 'subrouter apply_patch' },
|
|
626
|
+
});
|
|
627
|
+
expect(session.data).toBeTruthy();
|
|
628
|
+
const result = await client.session.prompt({
|
|
629
|
+
path: { id: session.data.id },
|
|
630
|
+
query: { directory: projectDir },
|
|
631
|
+
body: {
|
|
632
|
+
model: { providerID: 'subrouter', modelID: 'openai-only' },
|
|
633
|
+
parts: [{ type: 'text', text: 'say hi' }],
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
const texts = (result.data?.parts ?? [])
|
|
637
|
+
.filter((part) => part.type === 'text')
|
|
638
|
+
.map((part) => part.text)
|
|
639
|
+
.join('\n');
|
|
640
|
+
expect(texts).toContain('hello from openai');
|
|
641
|
+
expect(openaiMock.requests.length).toBeGreaterThan(0);
|
|
642
|
+
const raw = openaiMock.requests.at(-1).body;
|
|
643
|
+
const body = JSON.parse(raw);
|
|
644
|
+
const names = (body.tools ?? []).map((tool) => tool.name ?? tool.function?.name);
|
|
645
|
+
expect(names).toContain('apply_patch');
|
|
646
|
+
expect(names).not.toContain('edit');
|
|
647
|
+
expect(names).not.toContain('write');
|
|
648
|
+
expect(raw).toContain('apply_patch');
|
|
649
|
+
expect(raw.includes('"name":"edit"') || raw.includes('"name": "edit"')).toBe(false);
|
|
650
|
+
}, 120_000);
|
|
440
651
|
});
|