opencode-jev-model-router 0.1.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/dist/adapter.d.ts +23 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +509 -0
  6. package/dist/adapter.js.map +1 -0
  7. package/dist/config.d.ts +46 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +398 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/index.d.ts +7 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +36 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/jev.d.ts +45 -0
  16. package/dist/jev.d.ts.map +1 -0
  17. package/dist/jev.js +135 -0
  18. package/dist/jev.js.map +1 -0
  19. package/dist/models.d.ts +47 -0
  20. package/dist/models.d.ts.map +1 -0
  21. package/dist/models.js +132 -0
  22. package/dist/models.js.map +1 -0
  23. package/dist/policy.d.ts +33 -0
  24. package/dist/policy.d.ts.map +1 -0
  25. package/dist/policy.js +100 -0
  26. package/dist/policy.js.map +1 -0
  27. package/dist/prompt.d.ts +12 -0
  28. package/dist/prompt.d.ts.map +1 -0
  29. package/dist/prompt.js +32 -0
  30. package/dist/prompt.js.map +1 -0
  31. package/dist/quota.d.ts +31 -0
  32. package/dist/quota.d.ts.map +1 -0
  33. package/dist/quota.js +149 -0
  34. package/dist/quota.js.map +1 -0
  35. package/dist/schedule.d.ts +16 -0
  36. package/dist/schedule.d.ts.map +1 -0
  37. package/dist/schedule.js +44 -0
  38. package/dist/schedule.js.map +1 -0
  39. package/dist/sessions.d.ts +37 -0
  40. package/dist/sessions.d.ts.map +1 -0
  41. package/dist/sessions.js +119 -0
  42. package/dist/sessions.js.map +1 -0
  43. package/dist/types.d.ts +82 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +9 -0
  46. package/dist/types.js.map +1 -0
  47. package/opencode-jev-router.example.json +58 -0
  48. package/package.json +61 -0
  49. package/schemas/config.schema.json +83 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaron Shafovaloff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # opencode-jev-model-router
2
+
3
+ Automatic per-turn model picking for [OpenCode](https://opencode.ai), powered by [Jev](https://typesafe.ai). Built for **[OpenCode Go](https://opencode.ai/docs/go/)**: easy turns burn generous models; hard turns get Luna.
4
+
5
+ ## Quick start
6
+
7
+ **1. Install the plugin**
8
+
9
+ ```bash
10
+ git clone https://github.com/aaronshaf/opencode-jev-model-router.git
11
+ cd opencode-jev-model-router
12
+ npm install && npm run build
13
+ opencode plugin "$(pwd)" -g
14
+ ```
15
+
16
+ **2. Add your Jev key** (OpenCode usually does not see shell `export`s)
17
+
18
+ ```bash
19
+ printf '%s\n' "$JEV_KEY" > ~/.config/opencode/opencode-jev-router.key
20
+ chmod 600 ~/.config/opencode/opencode-jev-router.key
21
+ ```
22
+
23
+ **3. Optional:** copy defaults you can edit later
24
+
25
+ ```bash
26
+ cp opencode-jev-router.example.json ~/.config/opencode/opencode-jev-router.json
27
+ ```
28
+
29
+ **4. Restart OpenCode**, then check:
30
+
31
+ ```text
32
+ /jev-status
33
+ ```
34
+
35
+ You want: `Jev key present; routing ON; …`
36
+
37
+ ## Day to day
38
+
39
+ 1. Select a Go model the router manages (DeepSeek Flash, Muse, Luna, …). Avoid one-off pins like `hy3` if you want routing.
40
+ 2. Chat as usual.
41
+ 3. Watch for a toast such as `Routed to opencode-go/muse-spark-1.3-contributor · jev fast 98% 280ms`.
42
+
43
+ | Kind of ask | Typical tier | Default model |
44
+ |---|---|---|
45
+ | Typo, rename, “say hi” | `fast` | Muse Spark |
46
+ | Normal feature / fix | `balanced` | DeepSeek V4.1 Flash |
47
+ | Hard debug / design | `strong` | Luna |
48
+ | Huge migrations | `long` | Kimi K3 (off unless you enable it) |
49
+
50
+ If Jev is unreachable, your current model stays put.
51
+
52
+ ### Force a tier in the prompt
53
+
54
+ ```text
55
+ use muse for this typo
56
+ use strong to debug this race
57
+ use luna
58
+ use balanced for this endpoint
59
+ ```
60
+
61
+ ### Skip routing for a session
62
+
63
+ - `/jev-off` — stay on whatever model you picked
64
+ - `/jev-on` — turn automatic routing back on
65
+ - Or pick an unmanaged model (e.g. `opencode-go/hy3`) to **pin**
66
+
67
+ ### When a model hits its Go allowance
68
+
69
+ ```text
70
+ /jev-exhausted strong # skip Luna for the default cooldown
71
+ /jev-exhausted opencode-go/gpt-5.6-luna 8
72
+ /jev-quota # what’s blocked
73
+ /jev-reset strong # clear that mark
74
+ ```
75
+
76
+ ### Inspect a decision
77
+
78
+ ```text
79
+ /jev-explain
80
+ ```
81
+
82
+ ## Commands
83
+
84
+ | Command | What it does |
85
+ |---|---|
86
+ | `/jev-status` | Key OK? Routing on? DeepSeek peak or off-peak? |
87
+ | `/jev-explain` | Why the last turn chose its model |
88
+ | `/jev-on` / `/jev-off` | Enable / disable routing this session |
89
+ | `/jev-quota` | List exhausted models |
90
+ | `/jev-exhausted <tier\|model> [hours]` | Mark exhausted |
91
+ | `/jev-reset [tier\|model]` | Clear exhaustion (all if omitted) |
92
+
93
+ ## Privacy
94
+
95
+ Each user turn (up to ~16 KB of text) is sent to typesafe.ai so Jev can classify the tier.
96
+
97
+ ## More
98
+
99
+ - Go limits & peak hours: [docs/OPENCODE_GO.md](./docs/OPENCODE_GO.md)
100
+ - Building / testing the plugin: [DEVELOPMENT.md](./DEVELOPMENT.md)
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,23 @@
1
+ import type { Hooks, PluginInput } from "@opencode-ai/plugin";
2
+ import type { AskJevDeps, AskJevInput } from "./jev.js";
3
+ import type { JevResult } from "./types.js";
4
+ import { QuotaStore } from "./quota.js";
5
+ import type { ModelRef, RouterConfig } from "./types.js";
6
+ export type HookDeps = {
7
+ quota?: QuotaStore;
8
+ known?: Set<string>;
9
+ /** Fixed clock for peak/off-peak tests. */
10
+ now?: () => number;
11
+ askJev?: (input: AskJevInput, deps?: AskJevDeps) => Promise<JevResult | null>;
12
+ resolveKey?: () => string | undefined;
13
+ };
14
+ /** Set pending message model; variant nests inside `model` (Phase 0). */
15
+ export declare function applyModel(message: {
16
+ model: {
17
+ providerID: string;
18
+ modelID: string;
19
+ variant?: string;
20
+ };
21
+ }, target: ModelRef): void;
22
+ export declare function createHooks(input: PluginInput, config?: RouterConfig, deps?: HookDeps): Promise<Hooks>;
23
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAG9D,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACxD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAc5C,OAAO,EAAE,UAAU,EAAgB,MAAM,YAAY,CAAC;AAGtD,OAAO,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAQ,MAAM,YAAY,CAAC;AAK/D,MAAM,MAAM,QAAQ,GAAG;IACrB,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpB,2CAA2C;IAC3C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,CACP,KAAK,EAAE,WAAW,EAClB,IAAI,CAAC,EAAE,UAAU,KACd,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;CACvC,CAAC;AAoBF,yEAAyE;AACzE,wBAAgB,UAAU,CACxB,OAAO,EAAE;IAAE,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,EAC7E,MAAM,EAAE,QAAQ,GACf,IAAI,CAQN;AA6BD,wBAAsB,WAAW,CAC/B,KAAK,EAAE,WAAW,EAClB,MAAM,CAAC,EAAE,YAAY,EACrB,IAAI,GAAE,QAAa,GAClB,OAAO,CAAC,KAAK,CAAC,CAugBhB"}
@@ -0,0 +1,509 @@
1
+ import { loadConfig } from "./config.js";
2
+ import { askJev as defaultAskJev, resolveApiKey as defaultResolveApiKey } from "./jev.js";
3
+ import { configAvailableTiers, formatModelRef, isEligibleCandidate, isManagedModel, modelForTier, parseModelRef, resolveAvailableTiers, tierCandidates, tierOfModel, } from "./models.js";
4
+ import { buildOverridePatterns, decide, detectOverride } from "./policy.js";
5
+ import { extractPromptText } from "./prompt.js";
6
+ import { QuotaStore, isQuotaError } from "./quota.js";
7
+ import { deepSeekPeriod } from "./schedule.js";
8
+ import { SessionStore } from "./sessions.js";
9
+ import { isTier } from "./types.js";
10
+ function cleanError(value) {
11
+ return String(value || "")
12
+ .replace(/[\r\n\t]+/g, " ")
13
+ .trim()
14
+ .slice(0, 240);
15
+ }
16
+ function setCommandResult(output, text) {
17
+ output.parts.splice(0, output.parts.length, {
18
+ type: "text",
19
+ text,
20
+ synthetic: true,
21
+ });
22
+ }
23
+ /** Set pending message model; variant nests inside `model` (Phase 0). */
24
+ export function applyModel(message, target) {
25
+ message.model = target.variant
26
+ ? {
27
+ providerID: target.providerID,
28
+ modelID: target.modelID,
29
+ variant: target.variant,
30
+ }
31
+ : { providerID: target.providerID, modelID: target.modelID };
32
+ }
33
+ async function loadKnownModels(input) {
34
+ try {
35
+ const result = await input.client.config.providers({
36
+ query: { directory: input.directory },
37
+ signal: AbortSignal.timeout(3000),
38
+ });
39
+ if (result.error || !result.data?.providers)
40
+ return undefined;
41
+ const known = new Set();
42
+ for (const provider of result.data.providers) {
43
+ const models = provider.models ?? {};
44
+ for (const modelID of Object.keys(models)) {
45
+ known.add(`${provider.id}/${modelID}`);
46
+ }
47
+ }
48
+ return known;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ function modelPropAsRef(value) {
55
+ if (typeof value === "string" && value.includes("/"))
56
+ return value;
57
+ return undefined;
58
+ }
59
+ export async function createHooks(input, config, deps = {}) {
60
+ const resolved = config ?? (await loadConfig(input.directory));
61
+ if (!resolved.enabled)
62
+ return {};
63
+ const sessions = new SessionStore({
64
+ maxEntries: resolved.history.maxEntries,
65
+ retainPrompt: resolved.history.retainPrompt,
66
+ historyEnabled: resolved.history.enabled,
67
+ });
68
+ const quota = deps.quota ?? new QuotaStore();
69
+ const ask = deps.askJev ?? defaultAskJev;
70
+ const resolveKey = deps.resolveKey ?? defaultResolveApiKey;
71
+ const nowFn = deps.now ?? Date.now;
72
+ const overridePatterns = buildOverridePatterns(resolved.tiers);
73
+ let known = deps.known;
74
+ let catalogFailedAt = 0;
75
+ const toast = async (message, variant = "info") => {
76
+ try {
77
+ await Promise.race([
78
+ input.client.tui.showToast({
79
+ body: { title: "Jev Router", message, variant, duration: 5000 },
80
+ }),
81
+ new Promise((_, reject) => setTimeout(() => reject(new Error("toast timeout")), 2000)),
82
+ ]);
83
+ }
84
+ catch {
85
+ // Headless clients have no TUI.
86
+ }
87
+ };
88
+ const log = async (message, level = "info") => {
89
+ try {
90
+ await input.client.app.log({
91
+ body: { service: "opencode-jev-router", level, message },
92
+ signal: AbortSignal.timeout(2000),
93
+ });
94
+ }
95
+ catch {
96
+ // optional
97
+ }
98
+ };
99
+ const markQuota = async (modelRef, reason) => {
100
+ if (quota.isExhausted(modelRef, nowFn()))
101
+ return;
102
+ const until = nowFn() + resolved.quota.cooldownHours * 60 * 60 * 1000;
103
+ quota.mark(modelRef, until, reason, nowFn());
104
+ await toast(`${modelRef} allowance exhausted; routing around it until ${new Date(until).toLocaleTimeString()}`, "warning");
105
+ };
106
+ const ensureKnown = async () => {
107
+ if (known !== undefined)
108
+ return known;
109
+ if (catalogFailedAt && nowFn() - catalogFailedAt < 60_000) {
110
+ return undefined;
111
+ }
112
+ const loaded = await loadKnownModels(input);
113
+ if (!loaded) {
114
+ catalogFailedAt = nowFn();
115
+ return undefined;
116
+ }
117
+ known = loaded;
118
+ catalogFailedAt = 0;
119
+ const invalid = [];
120
+ for (const tier of Object.values(resolved.tiers)) {
121
+ if (tier.enabled === false)
122
+ continue;
123
+ if (!known.has(tier.model))
124
+ invalid.push(tier.model);
125
+ for (const fb of tier.fallbacks ?? []) {
126
+ if (!known.has(fb))
127
+ invalid.push(fb);
128
+ }
129
+ }
130
+ if (invalid.length) {
131
+ await log(`Unknown configured models (will skip): ${[...new Set(invalid)].join(", ")}`, "warn");
132
+ }
133
+ return known;
134
+ };
135
+ return {
136
+ config: async (cfg) => {
137
+ cfg.command = {
138
+ ...cfg.command,
139
+ "jev-on": {
140
+ template: "jev-on",
141
+ description: "Enable Jev automatic model routing for this session",
142
+ },
143
+ "jev-off": {
144
+ template: "jev-off",
145
+ description: "Disable Jev automatic model routing for this session",
146
+ },
147
+ "jev-status": {
148
+ template: "jev-status",
149
+ description: "Show Jev router status for this session",
150
+ },
151
+ "jev-explain": {
152
+ template: "jev-explain",
153
+ description: "Explain the last Jev routing decision",
154
+ },
155
+ "jev-quota": {
156
+ template: "jev-quota",
157
+ description: "List models marked quota-exhausted",
158
+ },
159
+ "jev-exhausted": {
160
+ template: "jev-exhausted $1",
161
+ description: "Mark a tier or provider/model exhausted: /jev-exhausted balanced [hours]",
162
+ },
163
+ "jev-reset": {
164
+ template: "jev-reset $1",
165
+ description: "Clear exhaustion for one model or all: /jev-reset [provider/model]",
166
+ },
167
+ };
168
+ },
169
+ event: async ({ event }) => {
170
+ try {
171
+ const props = event
172
+ .properties;
173
+ if (!props)
174
+ return;
175
+ if (event.type === "session.deleted") {
176
+ const info = props.info;
177
+ if (typeof info?.id === "string")
178
+ sessions.forget(info.id);
179
+ return;
180
+ }
181
+ if (event.type === "session.created" || event.type === "session.updated") {
182
+ const info = props.info;
183
+ if (typeof info?.id === "string" && info.parentID) {
184
+ sessions.markInternal(info.id);
185
+ }
186
+ return;
187
+ }
188
+ if (event.type === "session.error") {
189
+ const sessionID = props.sessionID;
190
+ const error = props.error;
191
+ const served = sessionID
192
+ ? sessions.getLastServed(sessionID)
193
+ : undefined;
194
+ const modelRef = modelPropAsRef(props.model) ||
195
+ (served ? formatModelRef(served) : undefined);
196
+ if (error) {
197
+ quota.appendError({
198
+ kind: "session.error",
199
+ sessionID,
200
+ model: modelRef,
201
+ error,
202
+ });
203
+ }
204
+ if (modelRef && isQuotaError(error)) {
205
+ await markQuota(modelRef, "session.error");
206
+ }
207
+ return;
208
+ }
209
+ if (event.type === "message.updated") {
210
+ const info = props.info;
211
+ if (!info || info.role !== "assistant" || !info.sessionID)
212
+ return;
213
+ if (info.providerID && info.modelID) {
214
+ const ref = {
215
+ providerID: info.providerID,
216
+ modelID: info.modelID,
217
+ ...(info.variant ? { variant: info.variant } : {}),
218
+ };
219
+ sessions.setLastServed(info.sessionID, ref);
220
+ if (info.error) {
221
+ const modelRef = formatModelRef(ref);
222
+ quota.appendError({
223
+ kind: "message.error",
224
+ sessionID: info.sessionID,
225
+ model: modelRef,
226
+ error: info.error,
227
+ });
228
+ if (isQuotaError(info.error)) {
229
+ await markQuota(modelRef, "message.error");
230
+ }
231
+ }
232
+ }
233
+ const tokens = info.tokens;
234
+ if (tokens) {
235
+ const total = (tokens.input ?? 0) +
236
+ (tokens.cache?.read ?? 0) +
237
+ (tokens.cache?.write ?? 0);
238
+ if (total > 0)
239
+ sessions.setContextTokens(info.sessionID, total);
240
+ }
241
+ }
242
+ }
243
+ catch (error) {
244
+ await log(`event handler error: ${cleanError(error)}`, "warn");
245
+ }
246
+ },
247
+ "command.execute.before": async (cmdInput, output) => {
248
+ if (cmdInput.command === "jev-off") {
249
+ sessions.setAutomatic(cmdInput.sessionID, false);
250
+ setCommandResult(output, "Jev automatic routing is OFF for this session.");
251
+ await toast("Automatic routing OFF for this session", "warning");
252
+ }
253
+ else if (cmdInput.command === "jev-on") {
254
+ sessions.setAutomatic(cmdInput.sessionID, true);
255
+ setCommandResult(output, "Jev automatic routing is ON for this session.");
256
+ await toast("Automatic routing ON for this session", "success");
257
+ }
258
+ else if (cmdInput.command === "jev-status") {
259
+ const on = sessions.isAutomatic(cmdInput.sessionID, resolved.enabled);
260
+ const key = Boolean(resolveKey());
261
+ const period = deepSeekPeriod(new Date(nowFn()));
262
+ const message = [
263
+ key
264
+ ? "Jev key present"
265
+ : "Jev key MISSING — set JEV_KEY or ~/.config/opencode/opencode-jev-router.key",
266
+ `routing ${on ? "ON" : "OFF"}`,
267
+ `DeepSeek ${period}`,
268
+ ].join("; ");
269
+ setCommandResult(output, message);
270
+ await toast(message, key ? "info" : "warning");
271
+ }
272
+ else if (cmdInput.command === "jev-explain") {
273
+ setCommandResult(output, sessions.explain(cmdInput.sessionID));
274
+ await toast("Last routing decision shown", "info");
275
+ }
276
+ else if (cmdInput.command === "jev-quota") {
277
+ const rows = quota.list(nowFn());
278
+ if (!rows.length) {
279
+ setCommandResult(output, "No models currently marked exhausted.");
280
+ }
281
+ else {
282
+ const lines = rows.map((row) => {
283
+ const until = new Date(row.exhaustedUntil).toLocaleString();
284
+ return `${row.ref} until ${until} (${row.reason})`;
285
+ });
286
+ setCommandResult(output, ["Exhausted models:", ...lines].join("\n"));
287
+ }
288
+ }
289
+ else if (cmdInput.command === "jev-exhausted") {
290
+ const args = cmdInput.arguments.trim().split(/\s+/).filter(Boolean);
291
+ const target = args[0];
292
+ if (!target) {
293
+ setCommandResult(output, "Usage: /jev-exhausted <tier|provider/model> [hours]");
294
+ return;
295
+ }
296
+ const hours = args[1] === undefined
297
+ ? resolved.quota.cooldownHours
298
+ : Number(args[1]);
299
+ if (!Number.isFinite(hours) || hours < 0.1 || hours > 168) {
300
+ setCommandResult(output, "Hours must be a number between 0.1 and 168.");
301
+ return;
302
+ }
303
+ const refs = [];
304
+ if (isTier(target)) {
305
+ refs.push(...tierCandidates(resolved, target));
306
+ }
307
+ else if (parseModelRef(target)) {
308
+ refs.push(target);
309
+ }
310
+ else {
311
+ setCommandResult(output, `Unknown target: ${target}`);
312
+ return;
313
+ }
314
+ if (!refs.length) {
315
+ setCommandResult(output, `No models configured for ${target}`);
316
+ return;
317
+ }
318
+ const until = nowFn() + hours * 60 * 60 * 1000;
319
+ for (const ref of refs)
320
+ quota.mark(ref, until, "manual", nowFn());
321
+ setCommandResult(output, `Marked exhausted until ${new Date(until).toLocaleString()}: ${refs.join(", ")}`);
322
+ await toast(`Exhausted: ${refs.join(", ")}`, "warning");
323
+ }
324
+ else if (cmdInput.command === "jev-reset") {
325
+ const target = cmdInput.arguments.trim();
326
+ if (!target) {
327
+ quota.clear();
328
+ setCommandResult(output, "Cleared all exhaustion marks.");
329
+ }
330
+ else if (parseModelRef(target)) {
331
+ quota.clear(target);
332
+ setCommandResult(output, `Cleared exhaustion for ${target}.`);
333
+ }
334
+ else if (isTier(target)) {
335
+ const refs = tierCandidates(resolved, target);
336
+ for (const ref of refs)
337
+ quota.clear(ref);
338
+ setCommandResult(output, refs.length
339
+ ? `Cleared exhaustion for ${refs.join(", ")}.`
340
+ : `No models configured for ${target}.`);
341
+ }
342
+ else {
343
+ setCommandResult(output, `Unknown target: ${target}`);
344
+ }
345
+ }
346
+ },
347
+ "chat.message": async (msgInput, output) => {
348
+ try {
349
+ if (output.message.role !== "user")
350
+ return;
351
+ if (sessions.isInternal(msgInput.sessionID))
352
+ return;
353
+ if (!sessions.isAutomatic(msgInput.sessionID, resolved.enabled))
354
+ return;
355
+ const prompt = extractPromptText(output.parts, resolved.routing.maxPromptBytes);
356
+ if (!prompt)
357
+ return;
358
+ const incoming = msgInput.model ??
359
+ output.message.model;
360
+ if (!isManagedModel(resolved, incoming)) {
361
+ sessions.record({
362
+ at: nowFn(),
363
+ sessionID: msgInput.sessionID,
364
+ prompt,
365
+ currentTier: tierOfModel(resolved, incoming),
366
+ jev: null,
367
+ decision: {
368
+ tier: tierOfModel(resolved, incoming),
369
+ reason: "pinned",
370
+ changed: false,
371
+ },
372
+ });
373
+ if (sessions.shouldToastPin(msgInput.sessionID) &&
374
+ resolved.echoRouting) {
375
+ await toast("Pinned model; Jev routing skipped · /jev-on", "info");
376
+ }
377
+ return;
378
+ }
379
+ const catalog = await ensureKnown();
380
+ if (catalog === undefined) {
381
+ if (resolved.echoRouting) {
382
+ await toast("Model catalog unavailable; keeping current", "warning");
383
+ }
384
+ return;
385
+ }
386
+ const at = new Date(nowFn());
387
+ const selectOpts = { known: catalog, quota, now: nowFn(), at };
388
+ const eligible = resolveAvailableTiers(resolved, selectOpts);
389
+ const configAvailable = configAvailableTiers(resolved, catalog);
390
+ if (!eligible.length && !configAvailable.length)
391
+ return;
392
+ const lastServed = sessions.getLastServed(msgInput.sessionID);
393
+ const current = sessions.getLastTier(msgInput.sessionID) ??
394
+ tierOfModel(resolved, lastServed ?? incoming);
395
+ const contextTokens = sessions.getContextTokens(msgInput.sessionID);
396
+ const override = detectOverride(prompt, overridePatterns);
397
+ let jevError;
398
+ let jev = null;
399
+ // Skip Jev when an explicit override is present or nothing is eligible.
400
+ if (!override && eligible.length > 0) {
401
+ jev = await ask({
402
+ prompt,
403
+ current,
404
+ contextTokens,
405
+ available: eligible,
406
+ routing: resolved.routing,
407
+ }, {
408
+ resolveKey,
409
+ onError: (message) => {
410
+ jevError = message;
411
+ void log(message, "warn");
412
+ },
413
+ });
414
+ }
415
+ const decision = decide({
416
+ prompt,
417
+ jev,
418
+ current,
419
+ available: eligible,
420
+ configAvailable,
421
+ contextTokens,
422
+ overridePatterns,
423
+ thresholds: resolved.routing,
424
+ });
425
+ // Unchanged tier: keep the model already on the message when it is
426
+ // still eligible. Avoids peak-hour swaps when Jev is down.
427
+ if (!decision.changed &&
428
+ isEligibleCandidate(resolved, decision.tier, output.message.model, selectOpts)) {
429
+ sessions.setLastTier(msgInput.sessionID, decision.tier);
430
+ sessions.record({
431
+ at: nowFn(),
432
+ sessionID: msgInput.sessionID,
433
+ prompt,
434
+ currentTier: current,
435
+ jev: jev
436
+ ? { choice: jev.choice, confidence: jev.confidence }
437
+ : null,
438
+ metrics: jev?.metrics,
439
+ decision,
440
+ model: formatModelRef(output.message.model),
441
+ latencyMs: jev?.ms,
442
+ });
443
+ if (!jev && !override && resolved.echoRouting) {
444
+ await toast(jevError
445
+ ? `Jev unavailable (${jevError}); keeping current model`
446
+ : "Routing unavailable; keeping current model", "warning");
447
+ }
448
+ return;
449
+ }
450
+ const selected = modelForTier(resolved, decision.tier, selectOpts);
451
+ const modelLabel = selected
452
+ ? formatModelRef(selected.model)
453
+ : undefined;
454
+ sessions.setLastTier(msgInput.sessionID, decision.tier);
455
+ sessions.record({
456
+ at: nowFn(),
457
+ sessionID: msgInput.sessionID,
458
+ prompt,
459
+ currentTier: current,
460
+ jev: jev
461
+ ? { choice: jev.choice, confidence: jev.confidence }
462
+ : null,
463
+ metrics: jev?.metrics,
464
+ decision,
465
+ model: modelLabel,
466
+ usedFallback: selected?.usedFallback,
467
+ latencyMs: jev?.ms,
468
+ });
469
+ if (!selected) {
470
+ if (resolved.echoRouting) {
471
+ await toast("No eligible Go models; keeping current", "warning");
472
+ }
473
+ return;
474
+ }
475
+ const same = selected.model.providerID === output.message.model.providerID &&
476
+ selected.model.modelID === output.message.model.modelID &&
477
+ (selected.model.variant ?? undefined) ===
478
+ (output.message.model.variant ??
479
+ undefined);
480
+ if (same) {
481
+ if (!jev && !override && resolved.echoRouting) {
482
+ await toast(jevError
483
+ ? `Jev unavailable (${jevError}); keeping current model`
484
+ : "Routing unavailable; keeping current model", "warning");
485
+ }
486
+ return;
487
+ }
488
+ applyModel(output.message, selected.model);
489
+ if (resolved.echoRouting) {
490
+ const label = selected.model.variant
491
+ ? `${formatModelRef(selected.model)} (${selected.model.variant})`
492
+ : formatModelRef(selected.model);
493
+ const via = selected.usedFallback ? " via fallback" : "";
494
+ const why = jev
495
+ ? ` · jev ${decision.tier} ${Math.round(jev.confidence * 100)}% ${jev.ms}ms`
496
+ : ` · ${decision.reason}`;
497
+ await toast(`Routed to ${label}${via}${why} · /jev-off`, "success");
498
+ await log(`Routed session ${msgInput.sessionID} to ${label} (${decision.reason}${jev ? `, jev=${jev.choice}@${jev.confidence}` : ""})`);
499
+ }
500
+ }
501
+ catch (error) {
502
+ if (!resolved.routing.failOpen)
503
+ throw error;
504
+ await toast(`Routing error; keeping current model: ${cleanError(error)}`, "warning");
505
+ }
506
+ },
507
+ };
508
+ }
509
+ //# sourceMappingURL=adapter.js.map