@qadanm/opencode-smart-router 0.4.0 → 0.6.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/CHANGELOG.md +168 -0
- package/CONTRIBUTING.md +7 -0
- package/README.md +89 -5
- package/config.example.json +1 -2
- package/dist/npm/index.js +29 -29
- package/docs/INSTALL.md +35 -4
- package/docs/LIMITATIONS.md +19 -2
- package/docs/SHARED-CORE.md +47 -5
- package/package.json +3 -1
- package/src/core/config.ts +25 -8
- package/src/core/models/registry.ts +1 -0
- package/src/core/models/seed.ts +13 -3
- package/src/core/plans/catalog.ts +6 -0
- package/src/core/policy/eligibility.ts +53 -1
- package/src/core/providers/opendesign-models.ts +33 -7
- package/src/core/quota/ledger.ts +31 -0
- package/src/core/routing/engine.ts +61 -2
- package/src/core/types.ts +46 -5
- package/src/gui/index.tsx +5 -0
- package/src/index.ts +15 -30
- package/src/server/containment.ts +131 -0
- package/src/server/controller.ts +261 -137
- package/src/server/shared-core.ts +32 -2
- package/dist/allowance.js +0 -487
- package/dist/gui-experimental.cjs +0 -1
- package/dist/index.js +0 -11111
- package/dist/tui.js +0 -587
package/src/server/controller.ts
CHANGED
|
@@ -20,7 +20,12 @@ import { repairMessages } from "../core/reprompt.js"
|
|
|
20
20
|
import { providerProtocol } from "../core/models/protocol.js"
|
|
21
21
|
import { PromptRouteTracker } from "./route-tracker.js"
|
|
22
22
|
import { buildProviderUsageReport } from "../core/quota/report.js"
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
OPENDESIGN_DESIGN_PLAN_MODELS,
|
|
25
|
+
OPENDESIGN_PLAN_MODEL_POOL,
|
|
26
|
+
resolvePlan,
|
|
27
|
+
resolvePlanWithReason,
|
|
28
|
+
} from "../core/plans/catalog.js"
|
|
24
29
|
import { loadSettings, validateSettings, type SettingsLoadResult } from "../core/config.js"
|
|
25
30
|
import { writeSettingsPatch } from "../core/config-writer.js"
|
|
26
31
|
import { describeConsoleReading, parseConsolePage, type UsageReading } from "../core/plan-page.js"
|
|
@@ -51,12 +56,10 @@ import { decidePremium, evaluateEligibility } from "../core/policy/eligibility.j
|
|
|
51
56
|
import { sharedCore, type RuntimeOverrides } from "./shared-core.js"
|
|
52
57
|
import {
|
|
53
58
|
ChangeNotifier,
|
|
54
|
-
buildIndicatorTitle,
|
|
55
59
|
formatQuotaLines,
|
|
56
60
|
indicatorState,
|
|
57
61
|
providerShort,
|
|
58
62
|
quotaStateLabel,
|
|
59
|
-
resolveBaseTitle,
|
|
60
63
|
stripTitlePrefix,
|
|
61
64
|
type Indicator,
|
|
62
65
|
} from "../core/ui.js"
|
|
@@ -80,6 +83,7 @@ import {
|
|
|
80
83
|
unique,
|
|
81
84
|
usd,
|
|
82
85
|
} from "../core/util.js"
|
|
86
|
+
import { containHook, withDeadline } from "./containment.js"
|
|
83
87
|
|
|
84
88
|
interface ControllerOptions {
|
|
85
89
|
readonly ctx: any
|
|
@@ -172,7 +176,6 @@ const applyRuntime = (base: RouterSettings, runtime: RuntimeOverrides): RouterSe
|
|
|
172
176
|
if (runtime.mode) next.mode = runtime.mode
|
|
173
177
|
if (runtime.paused === true) next.enabled = false
|
|
174
178
|
if (typeof runtime.dryRun === "boolean") next.dryRun = runtime.dryRun
|
|
175
|
-
if (typeof runtime.titleIndicator === "boolean") next.ui.titleIndicator = runtime.titleIndicator
|
|
176
179
|
if (typeof runtime.compactMode === "boolean") {
|
|
177
180
|
next.ui.compactMode = runtime.compactMode
|
|
178
181
|
next.ui.compact = runtime.compactMode
|
|
@@ -379,6 +382,8 @@ export class RouterController {
|
|
|
379
382
|
}
|
|
380
383
|
|
|
381
384
|
private modelList: unknown = []
|
|
385
|
+
/** Shared in-flight discovery; see ensureModelsLoaded(). */
|
|
386
|
+
private modelLoadPromise?: Promise<void>
|
|
382
387
|
|
|
383
388
|
async initialize(): Promise<() => void | Promise<void>> {
|
|
384
389
|
// Before anything reads settings: a provider the user never configured
|
|
@@ -937,7 +942,7 @@ export class RouterController {
|
|
|
937
942
|
state,
|
|
938
943
|
...(ref ? { provider: providerShort(ref.providerID), model: ref.id } : {}),
|
|
939
944
|
...(model ? { modelLabel: model.name } : ref ? { modelLabel: ref.id } : {}),
|
|
940
|
-
|
|
945
|
+
|
|
941
946
|
}
|
|
942
947
|
}
|
|
943
948
|
|
|
@@ -954,6 +959,19 @@ export class RouterController {
|
|
|
954
959
|
return next
|
|
955
960
|
}
|
|
956
961
|
|
|
962
|
+
/**
|
|
963
|
+
* The router never renames a session.
|
|
964
|
+
*
|
|
965
|
+
* A chat title is the owner's name for the work, and a plugin that rewrites it
|
|
966
|
+
* to advertise which model it picked trades the owner's memory for the
|
|
967
|
+
* plugin's status line. Status lives in the panel, the terminal footer and the
|
|
968
|
+
* decision record instead.
|
|
969
|
+
*
|
|
970
|
+
* One exception remains, and it only runs against titles this plugin wrote in
|
|
971
|
+
* an older version: a single pass that strips the prefix when the session is
|
|
972
|
+
* next touched, so upgrades heal instead of leaving `AUTO · OD · model`
|
|
973
|
+
* forever.
|
|
974
|
+
*/
|
|
957
975
|
private async applyTitleNow(sessionID: string) {
|
|
958
976
|
const session = this.ledger.getSession(sessionID)
|
|
959
977
|
if (!session) return
|
|
@@ -966,7 +984,7 @@ export class RouterController {
|
|
|
966
984
|
if (!this.projectMatches(info?.projectID, info?.location?.directory)) return
|
|
967
985
|
const currentTitle = typeof info?.title === "string" ? info.title : undefined
|
|
968
986
|
|
|
969
|
-
|
|
987
|
+
{
|
|
970
988
|
/**
|
|
971
989
|
* The indicator is off. If a previous version of this plugin left its
|
|
972
990
|
* prefix on the title, put the name back once, the first time the session
|
|
@@ -986,67 +1004,27 @@ export class RouterController {
|
|
|
986
1004
|
return
|
|
987
1005
|
}
|
|
988
1006
|
|
|
989
|
-
const modelRef = session.pinnedModel ?? session.manualModel ?? session.lastModel
|
|
990
|
-
if (!modelRef) return
|
|
991
|
-
|
|
992
|
-
// Adopt a name the router did not write. Never fall back to `session.title`:
|
|
993
|
-
// that value is the router's own previous output, so reading it back is how
|
|
994
|
-
// the prefix used to become the name permanently.
|
|
995
|
-
const baseTitle = resolveBaseTitle({
|
|
996
|
-
currentTitle,
|
|
997
|
-
lastWrittenTitle: session.indicatorTitle,
|
|
998
|
-
storedBase: session.baseTitle,
|
|
999
|
-
})
|
|
1000
|
-
// Nothing worth annotating. Leave the user's title exactly as it is.
|
|
1001
|
-
if (!baseTitle) return
|
|
1002
|
-
|
|
1003
|
-
const model = this.registry.get(modelRef.providerID, modelRef.id)
|
|
1004
|
-
const indicator = this.indicatorFor(sessionID, { ...session, baseTitle })
|
|
1005
|
-
const nextTitle = buildIndicatorTitle({
|
|
1006
|
-
state: indicator.state,
|
|
1007
|
-
provider: modelRef.providerID,
|
|
1008
|
-
modelLabel: model?.name ?? modelRef.id,
|
|
1009
|
-
baseTitle,
|
|
1010
|
-
compact: this.settings.ui.compactMode || this.settings.ui.compact || this.settings.compactMode || this.settings.compact,
|
|
1011
|
-
})
|
|
1012
|
-
if (currentTitle === nextTitle && session.indicatorTitle === nextTitle) return
|
|
1013
|
-
session.baseTitle = baseTitle
|
|
1014
|
-
session.indicatorTitle = nextTitle
|
|
1015
|
-
session.title = nextTitle
|
|
1016
|
-
this.ledger.patchSession(sessionID, {
|
|
1017
|
-
baseTitle,
|
|
1018
|
-
indicatorTitle: nextTitle,
|
|
1019
|
-
title: nextTitle,
|
|
1020
|
-
})
|
|
1021
|
-
try {
|
|
1022
|
-
await this.ctx.session.update({ sessionID, title: nextTitle })
|
|
1023
|
-
} catch (error) {
|
|
1024
|
-
this.settingsWarnings.push(`Could not update the session title indicator: ${safeError(error)}`)
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
private async refreshKnownTitles() {
|
|
1029
|
-
const ids = Object.values(this.ledger.data.sessions)
|
|
1030
|
-
.filter((session) => this.ownsSession(session))
|
|
1031
|
-
.map((session) => session.sessionID)
|
|
1032
|
-
await Promise.allSettled(ids.map((sessionID) => this.queueTitle(sessionID)))
|
|
1033
1007
|
}
|
|
1034
1008
|
|
|
1035
|
-
|
|
1009
|
+
/** Strips prefixes this plugin wrote. Returns how many titles changed. */
|
|
1010
|
+
private async restoreTitles(): Promise<number> {
|
|
1011
|
+
let restored = 0
|
|
1036
1012
|
for (const session of Object.values(this.ledger.data.sessions)) {
|
|
1037
1013
|
if (!this.ownsSession(session)) continue
|
|
1038
|
-
const
|
|
1039
|
-
|
|
1014
|
+
const visible = session.title ?? session.indicatorTitle
|
|
1015
|
+
const base = session.baseTitle || stripTitlePrefix(visible)
|
|
1016
|
+
// A title that is only a prefix has no name left to restore. Rewriting it
|
|
1017
|
+
// to the prefix again would keep the damage; leave it for the owner.
|
|
1018
|
+
if (!base || base === visible) continue
|
|
1040
1019
|
try {
|
|
1041
1020
|
await this.ctx.session.update({ sessionID: session.sessionID, title: base })
|
|
1042
|
-
this.ledger.patchSession(session.sessionID, {
|
|
1043
|
-
|
|
1044
|
-
indicatorTitle: undefined,
|
|
1045
|
-
})
|
|
1021
|
+
this.ledger.patchSession(session.sessionID, { title: base, indicatorTitle: undefined })
|
|
1022
|
+
restored += 1
|
|
1046
1023
|
} catch (error) {
|
|
1047
1024
|
this.settingsWarnings.push(`Could not restore session title: ${safeError(error)}`)
|
|
1048
1025
|
}
|
|
1049
1026
|
}
|
|
1027
|
+
return restored
|
|
1050
1028
|
}
|
|
1051
1029
|
|
|
1052
1030
|
private async notifyChange(key: string, message: string) {
|
|
@@ -1084,18 +1062,54 @@ export class RouterController {
|
|
|
1084
1062
|
return out
|
|
1085
1063
|
}
|
|
1086
1064
|
|
|
1087
|
-
private premiumState(session?: SessionState): PremiumState {
|
|
1065
|
+
private premiumState(session?: SessionState): PremiumState {
|
|
1066
|
+
// Reading budget state must never be able to fail a prompt or a status
|
|
1067
|
+
// request. Every value here degrades to a conservative default, and a
|
|
1068
|
+
// ledger that cannot answer is reported as unknown rather than thrown.
|
|
1069
|
+
const read = <T>(fn: () => T, fallback: T): T => {
|
|
1070
|
+
try {
|
|
1071
|
+
const value = fn()
|
|
1072
|
+
return value === undefined || value === null ? fallback : value
|
|
1073
|
+
} catch {
|
|
1074
|
+
return fallback
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
const spent = read(() => this.ledger.premiumSpent(), 0)
|
|
1088
1078
|
const budget = this.settings.premium.monthlyBudgetUsd
|
|
1079
|
+
// The plan-model pool is the escalation budget of record. Its cap comes
|
|
1080
|
+
// from the resolved plan catalog, not from premium.monthlyBudgetUsd, so
|
|
1081
|
+
// the gate tracks the allowance the account actually has.
|
|
1082
|
+
const planPoolMonthlyUsd = this.planPoolMonthlyUsd()
|
|
1083
|
+
const planPoolSpent = read(
|
|
1084
|
+
() => (typeof this.ledger.planPoolSpent === "function" ? this.ledger.planPoolSpent() : undefined),
|
|
1085
|
+
undefined as number | undefined,
|
|
1086
|
+
)
|
|
1089
1087
|
return {
|
|
1090
1088
|
mode: this.settings.premium.mode,
|
|
1091
1089
|
monthlyBudgetUsd: budget,
|
|
1092
1090
|
spentUsd: spent,
|
|
1093
1091
|
remainingUsd: Math.max(0, budget - spent),
|
|
1092
|
+
...(planPoolMonthlyUsd !== undefined && planPoolSpent !== undefined
|
|
1093
|
+
? { planPoolRemainingUsd: Math.max(0, planPoolMonthlyUsd - planPoolSpent) }
|
|
1094
|
+
: {}),
|
|
1094
1095
|
oneShotGranted: session?.premiumOneShot === true,
|
|
1095
1096
|
...(session?.premiumOneShotAt ? { oneShotGrantedAt: session.premiumOneShotAt } : {}),
|
|
1096
1097
|
}
|
|
1097
1098
|
}
|
|
1098
1099
|
|
|
1100
|
+
/**
|
|
1101
|
+
* The monthly cap of the OpenDesign plan-model pool for the configured plan,
|
|
1102
|
+
* when the model pool is the one escalation draws from. Undefined for any
|
|
1103
|
+
* other configuration, which leaves the gate on the legacy premium budget.
|
|
1104
|
+
*/
|
|
1105
|
+
private planPoolMonthlyUsd(): number | undefined {
|
|
1106
|
+
const provider = this.settings.providers.opendesign
|
|
1107
|
+
if (!provider?.enabled) return undefined
|
|
1108
|
+
const plan = resolvePlan("opendesign", provider.plan)
|
|
1109
|
+
if (!plan) return undefined
|
|
1110
|
+
return plan.pools.find((pool) => pool.id === OPENDESIGN_PLAN_MODEL_POOL)?.monthlyUsd
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1099
1113
|
private quotaSnapshots() {
|
|
1100
1114
|
return this.quota
|
|
1101
1115
|
? this.quota.read().map((snapshot) => {
|
|
@@ -1262,20 +1276,9 @@ export class RouterController {
|
|
|
1262
1276
|
const ref = session.lastModel
|
|
1263
1277
|
const model = ref ? this.registry.get(ref.providerID, ref.id) : undefined
|
|
1264
1278
|
const label = model?.name ?? ref?.id ?? "current model"
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
modelLabel: label,
|
|
1269
|
-
baseTitle: session.baseTitle || session.title,
|
|
1270
|
-
})
|
|
1271
|
-
session.indicatorTitle = blockedTitle
|
|
1272
|
-
session.title = blockedTitle
|
|
1273
|
-
this.ledger.patchSession(sessionID, { indicatorTitle: blockedTitle, title: blockedTitle })
|
|
1274
|
-
try {
|
|
1275
|
-
await this.ctx.session.update({ sessionID, title: blockedTitle })
|
|
1276
|
-
} catch {
|
|
1277
|
-
// The decision still carries the reason.
|
|
1278
|
-
}
|
|
1279
|
+
// Blocking a prompt must not rename the owner's chat. The refusal lives in
|
|
1280
|
+
// the decision record, the panel and the notice.
|
|
1281
|
+
this.settingsWarnings.push(`Blocked a prompt: ${reason}`)
|
|
1279
1282
|
void this.notifyChange(`${sessionID}:blocked:${reason}`, `BLOCKED · ${label} — ${reason}`)
|
|
1280
1283
|
}
|
|
1281
1284
|
|
|
@@ -1347,6 +1350,7 @@ export class RouterController {
|
|
|
1347
1350
|
void this.ensureContextTokens(sessionID)
|
|
1348
1351
|
|
|
1349
1352
|
if (runtime !== "automatic") {
|
|
1353
|
+
const models = await this.routingModels()
|
|
1350
1354
|
const task = classifyTask({
|
|
1351
1355
|
text,
|
|
1352
1356
|
files: this.classifierFiles(files),
|
|
@@ -1358,8 +1362,8 @@ export class RouterController {
|
|
|
1358
1362
|
settings,
|
|
1359
1363
|
mode: settings.mode,
|
|
1360
1364
|
runtime,
|
|
1361
|
-
models:
|
|
1362
|
-
intelligenceFloor: this.routingFloor(
|
|
1365
|
+
models: models,
|
|
1366
|
+
intelligenceFloor: this.routingFloor(models).floor,
|
|
1363
1367
|
current: session.lastModel,
|
|
1364
1368
|
session,
|
|
1365
1369
|
quota: this.quotaSnapshots(),
|
|
@@ -1374,6 +1378,7 @@ export class RouterController {
|
|
|
1374
1378
|
return result.decision
|
|
1375
1379
|
}
|
|
1376
1380
|
|
|
1381
|
+
const models = await this.routingModels()
|
|
1377
1382
|
const task = classifyTask({
|
|
1378
1383
|
text,
|
|
1379
1384
|
files: this.classifierFiles(files),
|
|
@@ -1390,8 +1395,8 @@ export class RouterController {
|
|
|
1390
1395
|
settings,
|
|
1391
1396
|
mode: effectiveMode,
|
|
1392
1397
|
runtime,
|
|
1393
|
-
models:
|
|
1394
|
-
intelligenceFloor: this.routingFloor(
|
|
1398
|
+
models: models,
|
|
1399
|
+
intelligenceFloor: this.routingFloor(models).floor,
|
|
1395
1400
|
current: session.lastModel,
|
|
1396
1401
|
session,
|
|
1397
1402
|
quota,
|
|
@@ -1556,45 +1561,81 @@ export class RouterController {
|
|
|
1556
1561
|
return finalDecision
|
|
1557
1562
|
}
|
|
1558
1563
|
|
|
1564
|
+
/**
|
|
1565
|
+
* How long routing may hold a prompt before the prompt is served without it.
|
|
1566
|
+
*
|
|
1567
|
+
* The number is a ceiling on waiting, not a performance target. Routing that
|
|
1568
|
+
* has not finished in this long is not going to improve the request in front
|
|
1569
|
+
* of the owner, and every extra second is a second the editor appears frozen.
|
|
1570
|
+
*/
|
|
1571
|
+
private routeBudgetMs(): number {
|
|
1572
|
+
const configured = Number((this.settings as any)?.routing?.routeBudgetMs)
|
|
1573
|
+
if (Number.isFinite(configured) && configured > 0) return Math.min(configured, 60_000)
|
|
1574
|
+
return 10_000
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1559
1577
|
private async registerHooks() {
|
|
1560
1578
|
this.registrations.push(
|
|
1561
|
-
await this.ctx.session.hook("prompt",
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
//
|
|
1579
|
-
//
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1579
|
+
await this.ctx.session.hook("prompt", containHook(
|
|
1580
|
+
async (event: any) => {
|
|
1581
|
+
const sessionID = event.sessionID
|
|
1582
|
+
if (!sessionID) return
|
|
1583
|
+
try {
|
|
1584
|
+
const probe: any = await this.ctx.session.get({ sessionID })
|
|
1585
|
+
if (!this.projectMatches(probe?.projectID, probe?.location?.directory)) return
|
|
1586
|
+
} catch {
|
|
1587
|
+
// If the location probe fails, the location-scoped hook can still run.
|
|
1588
|
+
}
|
|
1589
|
+
sharedCore.claimSession(sessionID, this.bridgeID)
|
|
1590
|
+
this.remember(this.promptSeen, sessionID)
|
|
1591
|
+
this.pendingHeals.delete(sessionID)
|
|
1592
|
+
if (this.inflight.has(sessionID)) return
|
|
1593
|
+
this.inflight.add(sessionID)
|
|
1594
|
+
try {
|
|
1595
|
+
// Routing runs against a deadline. If it cannot finish, the prompt
|
|
1596
|
+
// proceeds unrouted: an optimization that cannot finish is worth
|
|
1597
|
+
// nothing unless it gets out of the way.
|
|
1598
|
+
const decision = await withDeadline({
|
|
1599
|
+
work: () => this.routePrompt(sessionID, event.prompt?.text ?? "", event.prompt?.files),
|
|
1600
|
+
ms: this.routeBudgetMs(),
|
|
1601
|
+
onTimeout: () => {
|
|
1602
|
+
this.settingsWarnings.push(
|
|
1603
|
+
`Routing did not finish within ${this.routeBudgetMs()}ms; the prompt was served without a route.`,
|
|
1604
|
+
)
|
|
1605
|
+
},
|
|
1585
1606
|
})
|
|
1607
|
+
if (decision) {
|
|
1608
|
+
// Available to the retry hook, which has no prompt linkage of its
|
|
1609
|
+
// own: a deterministic rejection re-routes this same task.
|
|
1610
|
+
this.promptRoutes.set(sessionID, {
|
|
1611
|
+
messageID: typeof event.messageID === "string" ? event.messageID : undefined,
|
|
1612
|
+
task: decision.task,
|
|
1613
|
+
decision,
|
|
1614
|
+
at: Date.now(),
|
|
1615
|
+
})
|
|
1616
|
+
}
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
if (error instanceof RouterBlockedError || (error as any)?.name === "RouterBlockedError") throw error
|
|
1619
|
+
await this.handleRoutingFailure(sessionID, event.prompt?.text ?? "", event.prompt?.files, error)
|
|
1620
|
+
} finally {
|
|
1621
|
+
this.inflight.delete(sessionID)
|
|
1586
1622
|
}
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1623
|
+
},
|
|
1624
|
+
{
|
|
1625
|
+
label: "prompt routing",
|
|
1626
|
+
allowBlocked: true,
|
|
1627
|
+
onContained: (message) => {
|
|
1628
|
+
// A defect inside the router is reported, never escalated to a
|
|
1629
|
+
// refused prompt. The host keeps working; the owner can see why
|
|
1630
|
+
// routing was skipped.
|
|
1631
|
+
this.settingsWarnings.push(`Routing skipped: ${message}`)
|
|
1632
|
+
},
|
|
1633
|
+
},
|
|
1634
|
+
)),
|
|
1594
1635
|
)
|
|
1595
1636
|
|
|
1596
1637
|
this.registrations.push(
|
|
1597
|
-
await this.ctx.session.hook("model.request", (event: any) => {
|
|
1638
|
+
await this.ctx.session.hook("model.request", containHook((event: any) => {
|
|
1598
1639
|
if (sharedCore.ownerOf(event.sessionID) !== this.bridgeID) return
|
|
1599
1640
|
// Latch the request kind so the context hook (whose event carries no
|
|
1600
1641
|
// kind) can tell the agent loop apart from title and compaction runs.
|
|
@@ -1617,7 +1658,10 @@ export class RouterController {
|
|
|
1617
1658
|
if (this.settings.privacy.requireZdr && event.model?.providerID === "commandcode") {
|
|
1618
1659
|
event.headers["x-cmd-zdr"] = "1"
|
|
1619
1660
|
}
|
|
1620
|
-
}
|
|
1661
|
+
}, {
|
|
1662
|
+
label: "request bookkeeping",
|
|
1663
|
+
onContained: (message) => this.settingsWarnings.push(`Request bookkeeping skipped: ${message}`),
|
|
1664
|
+
})),
|
|
1621
1665
|
)
|
|
1622
1666
|
|
|
1623
1667
|
// The host emits `model.request` for every model call with the request's
|
|
@@ -1650,7 +1694,7 @@ export class RouterController {
|
|
|
1650
1694
|
)
|
|
1651
1695
|
|
|
1652
1696
|
this.registrations.push(
|
|
1653
|
-
await this.ctx.session.hook("retry", async (event: any) => {
|
|
1697
|
+
await this.ctx.session.hook("retry", containHook(async (event: any) => {
|
|
1654
1698
|
if (sharedCore.ownerOf(event.sessionID) !== this.bridgeID) return
|
|
1655
1699
|
const session = this.ledger.getSession(event.sessionID)
|
|
1656
1700
|
const assessment = classifyFailure(
|
|
@@ -1751,7 +1795,12 @@ export class RouterController {
|
|
|
1751
1795
|
} else if (assessment.class === "provider_outage" || assessment.class === "network") {
|
|
1752
1796
|
if (event.attempt >= 3) event.decision = { retry: false }
|
|
1753
1797
|
}
|
|
1754
|
-
}
|
|
1798
|
+
}, {
|
|
1799
|
+
label: "retry handling",
|
|
1800
|
+
// A contained retry failure leaves the host's own policy in place,
|
|
1801
|
+
// which is a working default rather than a blocked turn.
|
|
1802
|
+
onContained: (message) => this.settingsWarnings.push(`Retry handling skipped: ${message}`),
|
|
1803
|
+
})),
|
|
1755
1804
|
)
|
|
1756
1805
|
}
|
|
1757
1806
|
|
|
@@ -1862,13 +1911,14 @@ export class RouterController {
|
|
|
1862
1911
|
}
|
|
1863
1912
|
|
|
1864
1913
|
const session = this.sessionState(sessionID)
|
|
1914
|
+
const models = await this.routingModels()
|
|
1865
1915
|
const result = routeTask({
|
|
1866
1916
|
task: route.task,
|
|
1867
1917
|
settings: this.settings,
|
|
1868
1918
|
mode: session.parentID && this.settings.mode === "premium" ? "balanced" : this.settings.mode,
|
|
1869
1919
|
runtime: "automatic",
|
|
1870
|
-
models:
|
|
1871
|
-
intelligenceFloor: this.routingFloor(
|
|
1920
|
+
models: models,
|
|
1921
|
+
intelligenceFloor: this.routingFloor(models).floor,
|
|
1872
1922
|
current: rejected,
|
|
1873
1923
|
session,
|
|
1874
1924
|
quota: this.quotaSnapshots(),
|
|
@@ -2042,10 +2092,31 @@ export class RouterController {
|
|
|
2042
2092
|
}
|
|
2043
2093
|
|
|
2044
2094
|
private async modelResolvable(providerID: string, modelID: string): Promise<boolean> {
|
|
2045
|
-
// The host is the authority
|
|
2046
|
-
//
|
|
2047
|
-
//
|
|
2048
|
-
//
|
|
2095
|
+
// The host is the authority on which models it can serve, and there are two
|
|
2096
|
+
// ways to ask it. Use both, cheapest and most authoritative first.
|
|
2097
|
+
//
|
|
2098
|
+
// 1. The model list this plugin already fetched. It is the closest thing to
|
|
2099
|
+
// "what this machine can actually serve", but it is not exhaustive: a
|
|
2100
|
+
// provider read from a gateway (OpenDesign) is merged in separately, and
|
|
2101
|
+
// a provider that is not configured yet is absent rather than empty. So
|
|
2102
|
+
// the refusal is scoped to the case that is genuinely knowable: the host
|
|
2103
|
+
// lists this provider, and does not list this model. That is the state
|
|
2104
|
+
// that produced "Model unavailable: opencode-go/claude-haiku-5-5" on a
|
|
2105
|
+
// fresh install (scripts/isolated-e2e.sh, 2026-11-06).
|
|
2106
|
+
const known = unwrap<any>(this.modelList ?? [])
|
|
2107
|
+
if (Array.isArray(known) && known.length > 0) {
|
|
2108
|
+
const providerIsKnown = known.some((entry: any) => entry?.providerID === providerID)
|
|
2109
|
+
if (providerIsKnown) {
|
|
2110
|
+
const modelIsKnown = known.some(
|
|
2111
|
+
(entry: any) => entry?.providerID === providerID && entry?.id === modelID,
|
|
2112
|
+
)
|
|
2113
|
+
if (!modelIsKnown) return false
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
// 2. Ask the host directly. A MISSING API is not a "no": it means this host
|
|
2117
|
+
// cannot answer the question, so fall back to attempting the switch and
|
|
2118
|
+
// surfacing whatever happens. Only an API that answers and returns
|
|
2119
|
+
// nothing is treated as a real refusal.
|
|
2049
2120
|
const get = this.ctx?.model?.get
|
|
2050
2121
|
if (typeof get !== "function") return true
|
|
2051
2122
|
try {
|
|
@@ -2063,10 +2134,59 @@ export class RouterController {
|
|
|
2063
2134
|
* engine applies its own allow-list on top, so this is a convenience rather
|
|
2064
2135
|
* than the security boundary; the boundary is in `routeTask` and stays there
|
|
2065
2136
|
* even if a future caller passes the wrong list.
|
|
2137
|
+
*
|
|
2138
|
+
* MODELS THE HOST CANNOT SERVE ARE NOT CANDIDATES.
|
|
2139
|
+
*
|
|
2140
|
+
* The seed catalog describes providers in general; it does not know which of
|
|
2141
|
+
* them are configured on this machine. Offering the whole catalog meant the
|
|
2142
|
+
* router could choose a model OpenCode had never heard of and switch the
|
|
2143
|
+
* session onto it, after which the next step died with "Model unavailable".
|
|
2144
|
+
* Verified on a fresh install in scripts/isolated-e2e.sh (2026-11-06): the
|
|
2145
|
+
* host exposed only its own default model, and routing reached for
|
|
2146
|
+
* `opencode-go/claude-haiku-5-5`.
|
|
2147
|
+
*
|
|
2148
|
+
* The live list is authoritative once it has been read: it is OpenCode's own
|
|
2149
|
+
* catalog plus the OpenDesign gateway's entitled models. Until it has been
|
|
2150
|
+
* read it is empty, and an empty list must not filter anything out, or the
|
|
2151
|
+
* router would have no candidates during startup.
|
|
2066
2152
|
*/
|
|
2067
|
-
private routingModels() {
|
|
2153
|
+
private async routingModels(): Promise<ModelRecord[]> {
|
|
2068
2154
|
if (this.settings.offline?.enabled) return this.registry.modelsForOffline()
|
|
2069
|
-
|
|
2155
|
+
const approved = this.registry.approved()
|
|
2156
|
+
// WAIT FOR THE HOST'S MODEL LIST ON THE FIRST ROUTING DECISION.
|
|
2157
|
+
//
|
|
2158
|
+
// Discovery starts in the background during initialize(), so the first
|
|
2159
|
+
// prompt after startup used to route against an empty list -- the exact
|
|
2160
|
+
// window in which the router could pick a seed model this machine cannot
|
|
2161
|
+
// serve. Awaiting it is bounded by the routing deadline, and a discovery
|
|
2162
|
+
// that fails leaves the list empty, which falls back to the old behaviour.
|
|
2163
|
+
await this.ensureModelsLoaded()
|
|
2164
|
+
const live = unwrap<any>(this.modelList ?? [])
|
|
2165
|
+
if (!Array.isArray(live) || live.length === 0) return approved
|
|
2166
|
+
const servable = new Set(live.map((entry: any) => modelKey(entry?.providerID ?? "", entry?.id ?? "")))
|
|
2167
|
+
// No fallback to the unfiltered list. Returning the full catalog when the
|
|
2168
|
+
// intersection is empty is how an unservable model gets chosen anyway: on a
|
|
2169
|
+
// machine whose providers do not match the seed catalog, filtering produced
|
|
2170
|
+
// an empty list, the fallback reinstated every seed model, and the router
|
|
2171
|
+
// switched onto `opencode-go/claude-haiku-5-5` -- a model the host had never
|
|
2172
|
+
// heard of. Verified in scripts/isolated-e2e.sh on 2026-11-06.
|
|
2173
|
+
//
|
|
2174
|
+
// An empty list is the correct answer there. It means "this plugin has
|
|
2175
|
+
// nothing it may usefully route to on this machine", and the router then
|
|
2176
|
+
// leaves the session alone, which is what the host would have done had the
|
|
2177
|
+
// plugin not been installed.
|
|
2178
|
+
return approved.filter((model) => servable.has(model.key))
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
/** One in-flight model discovery shared by every caller. */
|
|
2182
|
+
private async ensureModelsLoaded(): Promise<void> {
|
|
2183
|
+
if (unwrap<any>(this.modelList ?? []).length > 0) return
|
|
2184
|
+
if (!this.modelLoadPromise) {
|
|
2185
|
+
this.modelLoadPromise = this.loadModels().finally(() => {
|
|
2186
|
+
this.modelLoadPromise = undefined
|
|
2187
|
+
})
|
|
2188
|
+
}
|
|
2189
|
+
await this.modelLoadPromise
|
|
2070
2190
|
}
|
|
2071
2191
|
|
|
2072
2192
|
/**
|
|
@@ -2468,6 +2588,13 @@ export class RouterController {
|
|
|
2468
2588
|
const tokens = tokensFrom(data.tokens)
|
|
2469
2589
|
const latency = this.stepStarted.get(sessionID) ? Date.now() - (this.stepStarted.get(sessionID) ?? Date.now()) : undefined
|
|
2470
2590
|
if (model) {
|
|
2591
|
+
// Plan-model-pool identity is resolved from the model id, so every
|
|
2592
|
+
// recorded step attributes its spend to the right allowance: plan
|
|
2593
|
+
// models draw the $120 escalation pool; Design Plan models and every
|
|
2594
|
+
// other provider's models draw their own.
|
|
2595
|
+
const modelRecord = this.registry.get(model.providerID, model.id)
|
|
2596
|
+
const isOpendesignPlanPool =
|
|
2597
|
+
model.providerID === "opendesign" && modelRecord !== undefined && !OPENDESIGN_DESIGN_PLAN_MODELS.has(model.id)
|
|
2471
2598
|
this.ledger.recordUsage({
|
|
2472
2599
|
sessionID,
|
|
2473
2600
|
model,
|
|
@@ -2475,7 +2602,8 @@ export class RouterController {
|
|
|
2475
2602
|
tokens,
|
|
2476
2603
|
at: typeof event.created === "number" ? event.created : Date.now(),
|
|
2477
2604
|
latencyMs: latency,
|
|
2478
|
-
premium:
|
|
2605
|
+
premium: modelRecord?.premium ?? false,
|
|
2606
|
+
planPool: isOpendesignPlanPool,
|
|
2479
2607
|
})
|
|
2480
2608
|
// A completed step is proof the provider's pre-request path worked,
|
|
2481
2609
|
// so any run of provider-scoped failures starts over.
|
|
@@ -2555,7 +2683,6 @@ export class RouterController {
|
|
|
2555
2683
|
setPaused: async (input: any) => {
|
|
2556
2684
|
this.runtime.paused = Boolean(input.paused)
|
|
2557
2685
|
this.persistRuntime()
|
|
2558
|
-
void this.refreshKnownTitles()
|
|
2559
2686
|
void this.notifyChange(
|
|
2560
2687
|
`global:paused:${Boolean(input.paused)}`,
|
|
2561
2688
|
input.paused ? "PAUSED · automatic routing is paused" : "AUTO · automatic routing resumed",
|
|
@@ -2590,7 +2717,6 @@ export class RouterController {
|
|
|
2590
2717
|
setDryRun: async (input: any) => {
|
|
2591
2718
|
this.runtime.dryRun = Boolean(input.dryRun)
|
|
2592
2719
|
this.persistRuntime()
|
|
2593
|
-
void this.refreshKnownTitles()
|
|
2594
2720
|
void this.notifyChange(
|
|
2595
2721
|
`global:dry-run:${Boolean(input.dryRun)}`,
|
|
2596
2722
|
input.dryRun ? "DRY RUN · decisions will be explained, not applied" : "AUTO · routing will apply decisions",
|
|
@@ -2764,10 +2890,10 @@ export class RouterController {
|
|
|
2764
2890
|
return summary
|
|
2765
2891
|
},
|
|
2766
2892
|
setTitleIndicator: async (input: any) => {
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2893
|
+
// The router no longer renames sessions. `enabled: true` is accepted for
|
|
2894
|
+
// old callers and ignored; `enabled: false` runs the one-time cleanup of
|
|
2895
|
+
// titles written by earlier versions.
|
|
2896
|
+
if (input?.enabled !== true) await this.restoreTitles()
|
|
2771
2897
|
const summary = this.statusFor()
|
|
2772
2898
|
void this.emit("status", { summary })
|
|
2773
2899
|
return summary
|
|
@@ -2885,7 +3011,6 @@ export class RouterController {
|
|
|
2885
3011
|
await command("router-pause", "Pause automatic routing globally", async ({ sessionID }: any) => {
|
|
2886
3012
|
this.runtime.paused = true
|
|
2887
3013
|
this.persistRuntime()
|
|
2888
|
-
void this.refreshKnownTitles()
|
|
2889
3014
|
void this.notifyChange("global:paused:true", "PAUSED · automatic routing is paused")
|
|
2890
3015
|
await notice(sessionID, "PAUSED · automatic routing is paused.")
|
|
2891
3016
|
}),
|
|
@@ -2895,7 +3020,6 @@ export class RouterController {
|
|
|
2895
3020
|
await command("router-resume", "Resume automatic routing globally", async ({ sessionID }: any) => {
|
|
2896
3021
|
this.runtime.paused = false
|
|
2897
3022
|
this.persistRuntime()
|
|
2898
|
-
void this.refreshKnownTitles()
|
|
2899
3023
|
void this.notifyChange("global:paused:false", "AUTO · automatic routing resumed")
|
|
2900
3024
|
await notice(sessionID, "AUTO · automatic routing resumed.")
|
|
2901
3025
|
}),
|
|
@@ -2922,7 +3046,6 @@ export class RouterController {
|
|
|
2922
3046
|
}
|
|
2923
3047
|
this.runtime.dryRun = value === "on"
|
|
2924
3048
|
this.persistRuntime()
|
|
2925
|
-
void this.refreshKnownTitles()
|
|
2926
3049
|
void this.notifyChange(
|
|
2927
3050
|
`global:dry-run:${value === "on"}`,
|
|
2928
3051
|
value === "on" ? "DRY RUN · decisions will be explained, not applied" : "AUTO · routing will apply decisions",
|
|
@@ -2932,17 +3055,18 @@ export class RouterController {
|
|
|
2932
3055
|
)
|
|
2933
3056
|
|
|
2934
3057
|
this.registrations.push(
|
|
2935
|
-
await command("router-title", "
|
|
3058
|
+
await command("router-title", "Strip router-written prefixes from existing session titles: /router-title off", async ({ sessionID, prompt }: any) => {
|
|
2936
3059
|
const value = String(prompt?.text ?? "").trim().split(/\s+/).pop()
|
|
2937
|
-
if (value
|
|
2938
|
-
await notice(sessionID, "
|
|
3060
|
+
if (value === "on") {
|
|
3061
|
+
await notice(sessionID, "The router no longer renames sessions. Titles stay yours; status lives in the panel and the header.")
|
|
2939
3062
|
return
|
|
2940
3063
|
}
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
3064
|
+
if (value !== "off") {
|
|
3065
|
+
await notice(sessionID, "Usage: /router-title off (strips prefixes this plugin wrote in older versions)")
|
|
3066
|
+
return
|
|
3067
|
+
}
|
|
3068
|
+
const restored = await this.restoreTitles()
|
|
3069
|
+
await notice(sessionID, `Cleaned ${restored} session title${restored === 1 ? "" : "s"}. Titles written before the separator was lost cannot be recovered.`)
|
|
2946
3070
|
}),
|
|
2947
3071
|
)
|
|
2948
3072
|
|