@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 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`. Model names, context limits, and
10
- * `experimental.chat.system.transform` follow the first live routed candidate.
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
@@ -1 +1 @@
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"}
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`. Model names, context limits, and
10
- * `experimental.chat.system.transform` follow the first live routed candidate.
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, resolveActiveCandidate, setSubrouterLog, } from '@subrouter/cli';
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
- 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
- }
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 models = Object.fromEntries(await Promise.all([...names].map(async (name) => {
52
- const candidate = await resolveActiveCandidate(name);
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
- return [
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
- 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 },
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
- 'chat.headers': async (input, output) => addSubrouterHeaders(input, output),
87
- // OpenCode identity uses the preset id; rewrite it to the live routed model.
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
  },