@subrouter/opencode 0.3.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 +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +164 -42
- package/dist/opencode-e2e.test.js +354 -15
- package/dist/plugin.test.js +436 -13
- package/dist/provider.d.ts +38 -11
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +42 -7
- package/package.json +2 -2
- package/src/index.ts +195 -54
- package/src/opencode-e2e.test.ts +397 -16
- package/src/plugin.test.ts +481 -21
- package/src/provider.ts +81 -9
package/dist/index.d.ts
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
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
8
|
* `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
|
|
9
|
-
* visible name is `subrouter.org`.
|
|
10
|
-
*
|
|
9
|
+
* visible name is `subrouter.org`. Context limits follow the first live
|
|
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.
|
|
11
14
|
*
|
|
12
15
|
* `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
|
|
13
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
|
@@ -6,8 +6,11 @@
|
|
|
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
8
|
* `subrouter preset create`) in opencode. Provider id stays `subrouter`; the
|
|
9
|
-
* visible name is `subrouter.org`.
|
|
10
|
-
*
|
|
9
|
+
* visible name is `subrouter.org`. Context limits follow the first live
|
|
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.
|
|
11
14
|
*
|
|
12
15
|
* `subrouterAuthPlugin` registers the login flow, so `opencode auth login`
|
|
13
16
|
* (and any harness driving opencode's auth hook, like kimaki's Discord
|
|
@@ -17,39 +20,80 @@
|
|
|
17
20
|
* NOTE: only plugin initializer functions may be exported from this module.
|
|
18
21
|
* OpenCode calls every export as a plugin.
|
|
19
22
|
*/
|
|
20
|
-
import { adapters, addAccount, DEFAULT_PRESET_NAME, isProviderId, loadModelsDevCatalog, loadPresets, modelsDevLimit, PROVIDER_DISPLAY_NAME, PROVIDER_ID, PROVIDER_IDS,
|
|
21
|
-
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";
|
|
22
25
|
function providerEntryUrl() {
|
|
23
26
|
const isDev = import.meta.url.endsWith('.ts');
|
|
24
27
|
return new URL(isDev ? './provider.ts' : './provider.js', import.meta.url).href;
|
|
25
28
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
29
|
+
function opencodeLog(client) {
|
|
30
|
+
if (!client?.app?.log)
|
|
31
|
+
return undefined;
|
|
32
|
+
const write = client.app.log.bind(client.app);
|
|
33
|
+
return (entry) => {
|
|
34
|
+
void write({
|
|
35
|
+
body: {
|
|
36
|
+
service: 'subrouter',
|
|
37
|
+
level: entry.level,
|
|
38
|
+
message: entry.message,
|
|
39
|
+
extra: entry.extra,
|
|
40
|
+
},
|
|
41
|
+
}).catch(() => { });
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export const subrouterPlugin = async ({ client, directory }) => {
|
|
45
|
+
const log = opencodeLog(client);
|
|
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')
|
|
51
|
+
return;
|
|
52
|
+
const preferred = `${notice.preferred.provider}/${notice.preferred.modelId}`;
|
|
53
|
+
const active = `${notice.active.provider}/${notice.active.modelId}`;
|
|
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,
|
|
62
|
+
agent: notice.agent,
|
|
63
|
+
model: { providerID: PROVIDER_ID, modelID: notice.preset },
|
|
64
|
+
variant: notice.variant,
|
|
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 });
|
|
81
|
+
};
|
|
44
82
|
return {
|
|
45
83
|
config: async (config) => {
|
|
46
84
|
const presets = await loadPresets().catch(() => {
|
|
47
85
|
return { version: 1, presets: {} };
|
|
48
86
|
});
|
|
49
87
|
const names = new Set([DEFAULT_PRESET_NAME, ...Object.keys(presets.presets)]);
|
|
50
|
-
const catalog = await loadModelsDevCatalog();
|
|
51
|
-
const
|
|
52
|
-
|
|
88
|
+
const catalog = await loadModelsDevCatalog({ log });
|
|
89
|
+
const takenApiIds = new Set();
|
|
90
|
+
const presetByApiId = {};
|
|
91
|
+
const resolved = await Promise.all([...names].map(async (name) => {
|
|
92
|
+
const presetModels = await resolvePresetModels(name);
|
|
93
|
+
const candidates = presetModels instanceof Error
|
|
94
|
+
? []
|
|
95
|
+
: (await resolveCandidates({ presetModels })).candidates;
|
|
96
|
+
const candidate = candidates[0];
|
|
53
97
|
const limit = candidate
|
|
54
98
|
? modelsDevLimit({
|
|
55
99
|
provider: candidate.provider,
|
|
@@ -57,38 +101,116 @@ export const subrouterPlugin = async ({ client }) => {
|
|
|
57
101
|
catalog,
|
|
58
102
|
})
|
|
59
103
|
: null;
|
|
60
|
-
|
|
104
|
+
const input = new Set(['text']);
|
|
105
|
+
let attachment = false;
|
|
106
|
+
for (const current of candidates) {
|
|
107
|
+
const model = modelsDevModel({
|
|
108
|
+
provider: current.provider,
|
|
109
|
+
modelId: current.modelId,
|
|
110
|
+
catalog,
|
|
111
|
+
});
|
|
112
|
+
const modalities = modelsDevInputModalities({
|
|
113
|
+
provider: current.provider,
|
|
114
|
+
modelId: current.modelId,
|
|
115
|
+
catalog,
|
|
116
|
+
});
|
|
117
|
+
if (!model || !modalities)
|
|
118
|
+
continue;
|
|
119
|
+
attachment ||= model.attachment && modalities.some((modality) => modality !== 'text');
|
|
120
|
+
for (const modality of modalities)
|
|
121
|
+
input.add(modality);
|
|
122
|
+
}
|
|
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 = {
|
|
61
156
|
name,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 },
|
|
157
|
+
tool_call: true,
|
|
158
|
+
attachment,
|
|
159
|
+
reasoning,
|
|
160
|
+
modalities: {
|
|
161
|
+
input: [...input],
|
|
162
|
+
output: ['text'],
|
|
73
163
|
},
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
}));
|
|
76
173
|
config.provider = {
|
|
77
174
|
...config.provider,
|
|
78
175
|
[PROVIDER_ID]: {
|
|
79
176
|
name: PROVIDER_DISPLAY_NAME,
|
|
80
177
|
npm: providerEntryUrl(),
|
|
81
178
|
models,
|
|
82
|
-
options: {},
|
|
179
|
+
options: { affinity, log, onCooldownFallback, presetByApiId },
|
|
83
180
|
},
|
|
84
181
|
};
|
|
85
182
|
},
|
|
86
|
-
|
|
87
|
-
|
|
183
|
+
event: async ({ event }) => {
|
|
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);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (event.type !== 'session.idle')
|
|
195
|
+
return;
|
|
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);
|
|
208
|
+
},
|
|
88
209
|
'experimental.chat.system.transform': async (input, output) => {
|
|
89
210
|
await revealRoutedModel({
|
|
90
211
|
providerID: input.model.providerID,
|
|
91
212
|
preset: input.model.id,
|
|
213
|
+
sessionID: input.sessionID,
|
|
92
214
|
system: output.system,
|
|
93
215
|
});
|
|
94
216
|
},
|