@qadanm/opencode-smart-router 0.0.0-stage → 0.4.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/CHANGELOG.md +118 -0
- package/CONTRIBUTING.md +96 -0
- package/LICENSE +21 -0
- package/README.md +341 -2
- package/SECURITY.md +174 -0
- package/config.example.json +108 -0
- package/dist/allowance.js +487 -0
- package/dist/gui-experimental.cjs +1 -0
- package/dist/index.js +11111 -0
- package/dist/npm/index.js +40 -0
- package/dist/tui.js +587 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +120 -0
- package/docs/LIMITATIONS.md +213 -0
- package/docs/MODELS.md +124 -0
- package/docs/OPENDESIGN-INTEGRATION.md +635 -0
- package/docs/QA.md +142 -0
- package/docs/SHARED-CORE.md +72 -0
- package/install +75 -0
- package/package.json +92 -4
- package/scripts/install.sh +324 -0
- package/scripts/uninstall.sh +91 -0
- package/src/allowance.ts +135 -0
- package/src/core/classify/classifier.ts +305 -0
- package/src/core/config-writer.ts +167 -0
- package/src/core/config.ts +1030 -0
- package/src/core/context/strategy.ts +188 -0
- package/src/core/context-health.ts +39 -0
- package/src/core/failures.ts +398 -0
- package/src/core/history/opencode.ts +173 -0
- package/src/core/models/protocol.ts +22 -0
- package/src/core/models/registry.ts +203 -0
- package/src/core/models/seed.ts +771 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +481 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +578 -0
- package/src/core/providers/enablement.ts +143 -0
- package/src/core/providers/opendesign-credits.ts +206 -0
- package/src/core/providers/opendesign-models.ts +628 -0
- package/src/core/providers/opendesign.ts +474 -0
- package/src/core/quota/calibration.ts +166 -0
- package/src/core/quota/commandcode.ts +471 -0
- package/src/core/quota/credentials.ts +490 -0
- package/src/core/quota/ledger.ts +1273 -0
- package/src/core/quota/opencode-go.ts +148 -0
- package/src/core/quota/opendesign.ts +1043 -0
- package/src/core/quota/reconcile.ts +155 -0
- package/src/core/quota/report.ts +261 -0
- package/src/core/quota/service.ts +273 -0
- package/src/core/reprompt.ts +48 -0
- package/src/core/routing/engine.ts +919 -0
- package/src/core/types.ts +825 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1670 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +48 -0
- package/src/rpc.ts +204 -0
- package/src/server/controller.ts +3281 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +671 -0
- package/src/tui/index.tsx +168 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CandidateEvaluation,
|
|
3
|
+
CostTier,
|
|
4
|
+
Mode,
|
|
5
|
+
ModelQuotaSnapshot,
|
|
6
|
+
ModelRecord,
|
|
7
|
+
ModelRef,
|
|
8
|
+
PeakPricing,
|
|
9
|
+
PremiumState,
|
|
10
|
+
ProviderQuotaSnapshot,
|
|
11
|
+
RouterSettings,
|
|
12
|
+
RoutingDecision,
|
|
13
|
+
SessionState,
|
|
14
|
+
TaskClass,
|
|
15
|
+
TaskKind,
|
|
16
|
+
} from "../types.js"
|
|
17
|
+
import { keyOfRef, modelKey, nowId, safeError, thousands, usd } from "../util.js"
|
|
18
|
+
import {
|
|
19
|
+
decidePremium,
|
|
20
|
+
evaluateEligibility,
|
|
21
|
+
modelPressure,
|
|
22
|
+
providerPressure,
|
|
23
|
+
quotaMarginalPriceFactor,
|
|
24
|
+
type PremiumDecision,
|
|
25
|
+
} from "../policy/eligibility.js"
|
|
26
|
+
import { mayUseProvider, type InferenceMode } from "../offline/mode.js"
|
|
27
|
+
|
|
28
|
+
export interface EngineInput {
|
|
29
|
+
readonly task: TaskClass
|
|
30
|
+
readonly settings: RouterSettings
|
|
31
|
+
readonly mode: Mode
|
|
32
|
+
readonly runtime: "automatic" | "manual" | "paused"
|
|
33
|
+
readonly models: ReadonlyArray<ModelRecord>
|
|
34
|
+
readonly current?: ModelRef
|
|
35
|
+
readonly session?: SessionState
|
|
36
|
+
readonly quota: ReadonlyArray<ProviderQuotaSnapshot>
|
|
37
|
+
readonly premium: PremiumState
|
|
38
|
+
readonly contextTokens: number
|
|
39
|
+
readonly cachedTokens: number
|
|
40
|
+
readonly dryRun: boolean
|
|
41
|
+
readonly now?: number
|
|
42
|
+
readonly lastError?: string
|
|
43
|
+
readonly modelHistory?: Readonly<Record<string, { requests: number; failures: number }>>
|
|
44
|
+
/** Model key to the epoch millisecond when its failure cooldown ends. */
|
|
45
|
+
readonly cooldowns?: Readonly<Record<string, number>>
|
|
46
|
+
/** Provider id to the epoch millisecond when its outage backoff ends. */
|
|
47
|
+
readonly providerCooldowns?: Readonly<Record<string, number>>
|
|
48
|
+
/**
|
|
49
|
+
* Measured generation speed per model, in output tokens per second. Only
|
|
50
|
+
* consulted once a model has enough samples to be meaningful.
|
|
51
|
+
*/
|
|
52
|
+
readonly speed?: Readonly<Record<string, { tokensPerSecond: number; samples: number }>>
|
|
53
|
+
/**
|
|
54
|
+
* The intelligence floor actually in force, when it differs from the
|
|
55
|
+
* configured one.
|
|
56
|
+
*
|
|
57
|
+
* Calibration derives the floor from recorded spend and the controller
|
|
58
|
+
* resolves it before routing. Passing it in keeps the eligibility gate and
|
|
59
|
+
* the success estimate reading the same number; defaulting to the configured
|
|
60
|
+
* value preserves every existing caller and every existing test.
|
|
61
|
+
*/
|
|
62
|
+
readonly intelligenceFloor?: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface EngineResult {
|
|
66
|
+
readonly decision: RoutingDecision
|
|
67
|
+
readonly chosenModel?: ModelRecord
|
|
68
|
+
readonly premiumDecision: PremiumDecision
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Evaluated {
|
|
72
|
+
model: ModelRecord
|
|
73
|
+
eligibility: ReturnType<typeof evaluateEligibility>
|
|
74
|
+
estimate: CostEstimate
|
|
75
|
+
probability: number
|
|
76
|
+
pressure: number
|
|
77
|
+
candidate: CandidateEvaluation
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const outputPerStep: Record<TaskKind, number> = {
|
|
81
|
+
deterministic: 200,
|
|
82
|
+
mechanical: 300,
|
|
83
|
+
docs: 800,
|
|
84
|
+
test: 1000,
|
|
85
|
+
debug: 1100,
|
|
86
|
+
feature: 1300,
|
|
87
|
+
refactor: 1500,
|
|
88
|
+
security: 1800,
|
|
89
|
+
architecture: 2000,
|
|
90
|
+
design: 2400,
|
|
91
|
+
unknown: 1000,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const stepsFor: Record<TaskKind, number> = {
|
|
95
|
+
deterministic: 1,
|
|
96
|
+
mechanical: 1,
|
|
97
|
+
docs: 1,
|
|
98
|
+
test: 2,
|
|
99
|
+
debug: 3,
|
|
100
|
+
feature: 3,
|
|
101
|
+
refactor: 3,
|
|
102
|
+
security: 3,
|
|
103
|
+
architecture: 3,
|
|
104
|
+
design: 3,
|
|
105
|
+
unknown: 2,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The share of the context a staying model reads from prompt cache.
|
|
110
|
+
*
|
|
111
|
+
* The remainder is re-sent as fresh input. This is a modelling assumption, not a
|
|
112
|
+
* measured cache-hit rate, and it drives both the ranked estimate and the switch
|
|
113
|
+
* penalty that is reported alongside it, so the two can never disagree about how
|
|
114
|
+
* much a switch really costs.
|
|
115
|
+
*/
|
|
116
|
+
const CACHE_REUSE_SHARE = 0.85
|
|
117
|
+
|
|
118
|
+
const selectCostTier = (model: ModelRecord, contextTokens: number): CostTier => {
|
|
119
|
+
const sorted = [...model.cost].sort((a, b) => (a.tier?.size ?? 0) - (b.tier?.size ?? 0))
|
|
120
|
+
let selected = sorted[0] ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }
|
|
121
|
+
for (const tier of sorted) {
|
|
122
|
+
if (tier.tier?.type === "context" && contextTokens >= tier.tier.size) selected = tier
|
|
123
|
+
}
|
|
124
|
+
return selected
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface CostEstimate {
|
|
128
|
+
readonly usd: number
|
|
129
|
+
readonly input: number
|
|
130
|
+
readonly output: number
|
|
131
|
+
readonly cacheRead: number
|
|
132
|
+
readonly cacheWrite: number
|
|
133
|
+
readonly switchPenaltyUsd: number
|
|
134
|
+
readonly costTier: CostTier
|
|
135
|
+
/** 2 during a documented peak window, otherwise 1. */
|
|
136
|
+
readonly priceMultiplier: number
|
|
137
|
+
/** Per-model credit multiplier. One for every plain USD provider. */
|
|
138
|
+
readonly creditMultiplier: number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const isPeakPricingActive = (now: number, peak: PeakPricing): boolean => {
|
|
142
|
+
const date = new Date(now)
|
|
143
|
+
const day = date.getUTCDay()
|
|
144
|
+
if (peak.weekdaysOnly && (day === 0 || day === 6)) return false
|
|
145
|
+
const hour = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600
|
|
146
|
+
return peak.windowsUtc.some((window) => hour >= window.startHour && hour < window.endHour)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Estimate the total request cost of a task across its whole agent loop rather
|
|
151
|
+
* than a single call. The estimate includes later steps and treats a model
|
|
152
|
+
* switch as a cache loss. Documented peak pricing is applied using the supplied
|
|
153
|
+
* time; omitting `now` uses the current time.
|
|
154
|
+
*/
|
|
155
|
+
export const estimateTaskCost = (input: {
|
|
156
|
+
model: ModelRecord
|
|
157
|
+
task: TaskClass
|
|
158
|
+
contextTokens: number
|
|
159
|
+
currentModelKey?: string
|
|
160
|
+
now?: number
|
|
161
|
+
}): CostEstimate => {
|
|
162
|
+
const { model, task } = input
|
|
163
|
+
const context = Math.max(input.contextTokens, task.scope.promptTokens)
|
|
164
|
+
const sameModel = input.currentModelKey === modelKey(model.providerID, model.modelID)
|
|
165
|
+
const steps = stepsFor[task.kind]
|
|
166
|
+
const tier = selectCostTier(model, context)
|
|
167
|
+
const peakMultiplier =
|
|
168
|
+
model.peakPricing && isPeakPricingActive(input.now ?? Date.now(), model.peakPricing) && model.peakPricing.multiplier > 0
|
|
169
|
+
? model.peakPricing.multiplier
|
|
170
|
+
: 1
|
|
171
|
+
const outputPer = Math.min(outputPerStep[task.kind] * (task.tier === 3 ? 1.5 : 1), model.maxOutput || outputPerStep[task.kind])
|
|
172
|
+
const output = outputPer * steps
|
|
173
|
+
// Staying on a model reuses the prompt cache. Switching loses it, so the whole
|
|
174
|
+
// context is re-sent as fresh input instead of being read from cache. Both
|
|
175
|
+
// halves of that trade depend on the same cached share, which is why it is
|
|
176
|
+
// named once and used by the estimate and by the reported penalty alike.
|
|
177
|
+
const cachedShare = sameModel ? CACHE_REUSE_SHARE : 0
|
|
178
|
+
const freshInput = task.scope.promptTokens + context * (1 - cachedShare) + context * 0.05 * (steps - 1)
|
|
179
|
+
const cacheRead = context * cachedShare + context * 0.95 * (steps - 1)
|
|
180
|
+
// A live catalog entry or a hand-written definition can omit cache pricing.
|
|
181
|
+
// Price it as zero rather than letting the router throw and fail open.
|
|
182
|
+
const inputPrice = (Number.isFinite(tier.input) ? tier.input : 0) * peakMultiplier
|
|
183
|
+
const outputPrice = (Number.isFinite(tier.output) ? tier.output : 0) * peakMultiplier
|
|
184
|
+
const cacheReadPrice = (Number.isFinite(tier.cache?.read) ? (tier.cache?.read ?? 0) : 0) * peakMultiplier
|
|
185
|
+
const cost =
|
|
186
|
+
(freshInput * inputPrice) / 1_000_000 +
|
|
187
|
+
(output * outputPrice) / 1_000_000 +
|
|
188
|
+
(cacheRead * cacheReadPrice) / 1_000_000
|
|
189
|
+
// The extra cost a switch actually adds, which is the cached share of the
|
|
190
|
+
// context moving from cache-read price to full input price. Reporting the whole
|
|
191
|
+
// context here charged the reader 17.6% more than the estimate in force.
|
|
192
|
+
const switchPenaltyUsd = sameModel
|
|
193
|
+
? 0
|
|
194
|
+
: Math.max(0, (context * CACHE_REUSE_SHARE * (inputPrice - cacheReadPrice)) / 1_000_000)
|
|
195
|
+
// OpenDesign converts the dollar value of usage into Design Plan credits
|
|
196
|
+
// with a per-model multiplier. The result is still an estimate of credits,
|
|
197
|
+
// never an observed provider charge.
|
|
198
|
+
const creditMultiplier = model.creditMultiplier && model.creditMultiplier > 0 ? model.creditMultiplier : 1
|
|
199
|
+
return {
|
|
200
|
+
usd: Math.max(0, cost) * creditMultiplier,
|
|
201
|
+
input: freshInput,
|
|
202
|
+
output,
|
|
203
|
+
cacheRead,
|
|
204
|
+
cacheWrite: 0,
|
|
205
|
+
switchPenaltyUsd: switchPenaltyUsd * creditMultiplier,
|
|
206
|
+
costTier: tier,
|
|
207
|
+
priceMultiplier: peakMultiplier,
|
|
208
|
+
creditMultiplier,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const observedSuccess = (model: ModelRecord, session: EngineInput["session"], _key: string): number => {
|
|
213
|
+
// Only capability failures should reduce a model's expected success rate.
|
|
214
|
+
// Provider, network, auth, tool, and context-overflow errors are handled by
|
|
215
|
+
// their own recovery paths.
|
|
216
|
+
if (!session) return 0.95
|
|
217
|
+
const capabilityFailures = session.failures.filter((failure) => failure.class === "capability").length
|
|
218
|
+
return Math.max(0.55, 0.95 - capabilityFailures * 0.1)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export const estimateSuccess = (input: {
|
|
222
|
+
model: ModelRecord
|
|
223
|
+
task: TaskClass
|
|
224
|
+
contextTokens: number
|
|
225
|
+
session?: SessionState
|
|
226
|
+
currentModelKey?: string
|
|
227
|
+
history?: { requests: number; failures: number }
|
|
228
|
+
/**
|
|
229
|
+
* The floor in force for this request.
|
|
230
|
+
*
|
|
231
|
+
* This used to be a literal `39.5`, which meant raising or lowering the floor
|
|
232
|
+
* moved the eligibility gate but left this quality penalty behind at the old
|
|
233
|
+
* number. Two gates reading the same policy from different sources is exactly
|
|
234
|
+
* the drift the context-margin work removed, so the value is passed in now.
|
|
235
|
+
*/
|
|
236
|
+
intelligenceFloor?: number
|
|
237
|
+
}): number => {
|
|
238
|
+
const { model, task } = input
|
|
239
|
+
let probability: number
|
|
240
|
+
if (task.tier <= 1) {
|
|
241
|
+
probability = model.tier === 1 ? 0.93 : 0.97
|
|
242
|
+
} else if (task.tier === 2) {
|
|
243
|
+
probability = model.tier === 2 ? 0.94 : 0.97
|
|
244
|
+
} else {
|
|
245
|
+
probability = model.tier === 3 ? 0.93 : 0.7
|
|
246
|
+
}
|
|
247
|
+
if (model.training) probability -= 0.05
|
|
248
|
+
if (task.designIntensive) {
|
|
249
|
+
const threshold = task.sophisticatedDesign ? 75 : 60
|
|
250
|
+
if (model.design < threshold) probability *= 0.72
|
|
251
|
+
}
|
|
252
|
+
const floor = input.intelligenceFloor ?? 39.5
|
|
253
|
+
if (task.tier >= 2 && model.intelligence < floor) probability *= 0.7
|
|
254
|
+
if (model.context > 0 && input.contextTokens > model.context * 0.8) probability *= 0.9
|
|
255
|
+
if (model.context > 0 && input.contextTokens > model.context * 0.95) probability *= 0.6
|
|
256
|
+
probability *= observedSuccess(model, input.session, modelKey(model.providerID, model.modelID)) / 0.95
|
|
257
|
+
if (input.history && input.history.requests >= 3) {
|
|
258
|
+
// Reliability is weighted more heavily than before. A model that drops
|
|
259
|
+
// streams is not worth a lower token price, because every dropped turn
|
|
260
|
+
// costs a full retry of the accumulated context.
|
|
261
|
+
const total = input.history.requests + input.history.failures
|
|
262
|
+
const successRate = total > 0 ? Math.max(0, input.history.requests) / total : 1
|
|
263
|
+
probability *= 0.6 + 0.4 * successRate
|
|
264
|
+
}
|
|
265
|
+
return Math.max(0.2, Math.min(0.99, probability))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const premiumState = (input: EngineInput): PremiumState => input.premium
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Speed penalty for a candidate, as a multiplier on its effective cost.
|
|
272
|
+
*
|
|
273
|
+
* Speed is measured as output tokens per second, which is the only figure
|
|
274
|
+
* that separates a slow provider from a model that simply reasons for longer:
|
|
275
|
+
* a model that emits 3,000 reasoning tokens before answering is not "slow" in
|
|
276
|
+
* the same sense as one that takes four times as long to emit the same
|
|
277
|
+
* answer.
|
|
278
|
+
*
|
|
279
|
+
* With no samples the penalty is neutral. Once a model has been measured it
|
|
280
|
+
* is compared against the fastest eligible route rather than against an
|
|
281
|
+
* absolute number, so the penalty adapts as providers change.
|
|
282
|
+
*/
|
|
283
|
+
const speedPenalty = (
|
|
284
|
+
input: EngineInput,
|
|
285
|
+
key: string,
|
|
286
|
+
fastestTokensPerSecond: number,
|
|
287
|
+
): number => {
|
|
288
|
+
if (!input.settings.speed.enabled) return 1
|
|
289
|
+
const measure = input.speed?.[key]
|
|
290
|
+
if (!measure || measure.samples < input.settings.speed.minSamples) return 1
|
|
291
|
+
if (measure.tokensPerSecond <= 0 || fastestTokensPerSecond <= 0) return 1
|
|
292
|
+
const ratio = measure.tokensPerSecond / fastestTokensPerSecond
|
|
293
|
+
// A route at the fastest observed speed is unpenalised. Below that the
|
|
294
|
+
// configured weight scales the shortfall, so a route half as fast costs
|
|
295
|
+
// more than one at parity but never becomes ineligible.
|
|
296
|
+
const shortfall = Math.max(0, 1 - ratio)
|
|
297
|
+
const weight = input.settings.speed.weightPct / 100
|
|
298
|
+
const penalty = 1 + shortfall * weight * 2
|
|
299
|
+
// A route far slower than the fastest is penalised harder, because the
|
|
300
|
+
// owner's stated preference puts responsiveness ahead of shaving pennies.
|
|
301
|
+
const multiple = ratio > 0 ? 1 / ratio : input.settings.speed.slowRouteMultiple
|
|
302
|
+
if (multiple >= input.settings.speed.slowRouteMultiple) {
|
|
303
|
+
return penalty * (1 + weight)
|
|
304
|
+
}
|
|
305
|
+
return penalty
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const providerFor = (quota: ReadonlyArray<ProviderQuotaSnapshot>, providerID: string) =>
|
|
309
|
+
quota.find((snapshot) => snapshot.providerID === providerID)
|
|
310
|
+
|
|
311
|
+
const modelQuotaFor = (snapshot: ProviderQuotaSnapshot | undefined, model: ModelRecord): ModelQuotaSnapshot | undefined =>
|
|
312
|
+
snapshot?.models[modelKey(model.providerID, model.modelID)]
|
|
313
|
+
|
|
314
|
+
const toRef = (model: ModelRecord): ModelRef => ({ providerID: model.providerID, id: model.modelID })
|
|
315
|
+
|
|
316
|
+
const candidateFor = (
|
|
317
|
+
input: EngineInput,
|
|
318
|
+
model: ModelRecord,
|
|
319
|
+
eligibility: { eligible: boolean; reason: string; warnings: string[] },
|
|
320
|
+
estimate: CostEstimate,
|
|
321
|
+
probability: number,
|
|
322
|
+
pressure: number,
|
|
323
|
+
currentModelKey: string | undefined,
|
|
324
|
+
speedFactor = 1,
|
|
325
|
+
marginalPriceFactor = 1,
|
|
326
|
+
): CandidateEvaluation => {
|
|
327
|
+
// Expiring prepaid allowance is priced at the probability it will be wasted,
|
|
328
|
+
// not treated as fresh money. This is applied to the price rather than folded
|
|
329
|
+
// into `pressure` because pressure is a multiplier on an already-ranked cost:
|
|
330
|
+
// no pressure figure can close a large price gap, so quota urgency expressed
|
|
331
|
+
// that way could never change a decision.
|
|
332
|
+
const expectedTotal =
|
|
333
|
+
(estimate.usd * marginalPriceFactor * (1 + (1 - probability) * 2)) * pressure * speedFactor
|
|
334
|
+
return {
|
|
335
|
+
model: { providerID: model.providerID, modelID: model.modelID },
|
|
336
|
+
name: model.name,
|
|
337
|
+
tier: model.tier,
|
|
338
|
+
eligible: eligibility.eligible,
|
|
339
|
+
reason: eligibility.reason,
|
|
340
|
+
estimatedUsd: estimate.usd,
|
|
341
|
+
effectiveCost: expectedTotal,
|
|
342
|
+
successProbability: probability,
|
|
343
|
+
switchPenaltyUsd: estimate.switchPenaltyUsd,
|
|
344
|
+
quotaPressure: pressure,
|
|
345
|
+
retained: keyOfRef(input.current) === modelKey(model.providerID, model.modelID),
|
|
346
|
+
creditMultiplier: estimate.creditMultiplier,
|
|
347
|
+
speedFactor,
|
|
348
|
+
marginalPriceFactor,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Deterministic routing engine. It never calls a model to classify or decide.
|
|
354
|
+
* Every candidate is evaluated against hard requirements first, then ranked by
|
|
355
|
+
* expected total quota cost, with a cache-aware affinity threshold.
|
|
356
|
+
*/
|
|
357
|
+
export const routeTask = (input: EngineInput): EngineResult => {
|
|
358
|
+
const now = input.now ?? Date.now()
|
|
359
|
+
/**
|
|
360
|
+
* Does ANY provider in this decision carry a real reading?
|
|
361
|
+
*
|
|
362
|
+
* Used to tell "this provider is fine" from "nobody has been probed yet".
|
|
363
|
+
* Treating the second as the first is what let a decision route into an
|
|
364
|
+
* exhausted allowance during the ~1.5s before the quota service starts.
|
|
365
|
+
*
|
|
366
|
+
* A snapshot counts as known when it has a source other than "unknown": that
|
|
367
|
+
* is the provider telling us something, whether provider-reported or modelled.
|
|
368
|
+
* Staleness does not disqualify it; see the sticky-exhausted rule.
|
|
369
|
+
*/
|
|
370
|
+
const quotaIsKnown = (input.quota ?? []).some(
|
|
371
|
+
(snapshot) =>
|
|
372
|
+
snapshot.monthly.source !== "unknown" ||
|
|
373
|
+
snapshot.weekly.source !== "unknown" ||
|
|
374
|
+
snapshot.rolling.source !== "unknown",
|
|
375
|
+
)
|
|
376
|
+
// One value for the whole decision. The eligibility gate and the success
|
|
377
|
+
// estimate must never disagree about the floor in force.
|
|
378
|
+
const floor = input.intelligenceFloor ?? input.settings.routing.intelligenceFloor
|
|
379
|
+
const warnings: string[] = []
|
|
380
|
+
const reasons: string[] = []
|
|
381
|
+
const currentKey = keyOfRef(input.current)
|
|
382
|
+
const premiumDecision = decidePremium({
|
|
383
|
+
settings: input.settings,
|
|
384
|
+
mode: input.mode,
|
|
385
|
+
task: input.task,
|
|
386
|
+
session: input.session,
|
|
387
|
+
premium: input.premium,
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Offline mode narrows the candidate set to the local provider before any
|
|
392
|
+
* other evaluation runs.
|
|
393
|
+
*
|
|
394
|
+
* This is deliberately the first thing that happens and the only place the
|
|
395
|
+
* restriction is applied. Filtering here means quotas, escalation, premium
|
|
396
|
+
* approval and the cost model all operate on a list that cannot contain a
|
|
397
|
+
* hosted provider, so no later branch can reintroduce one. A refusal is
|
|
398
|
+
* explicit rather than a silent empty result: an offline session with no
|
|
399
|
+
* usable local model must say so.
|
|
400
|
+
*/
|
|
401
|
+
const inferenceMode: InferenceMode = input.settings.offline?.enabled ? "offline" : "online"
|
|
402
|
+
let candidateModels = input.models
|
|
403
|
+
if (inferenceMode === "offline") {
|
|
404
|
+
candidateModels = input.models.filter((model) => mayUseProvider(model.providerID, inferenceMode))
|
|
405
|
+
const refused = input.models.filter((model) => !mayUseProvider(model.providerID, inferenceMode))
|
|
406
|
+
if (refused.length > 0) {
|
|
407
|
+
const providers = [...new Set(refused.map((model) => model.providerID))].sort()
|
|
408
|
+
warnings.push(
|
|
409
|
+
`Offline mode excluded ${refused.length} hosted model${refused.length === 1 ? "" : "s"} from ${providers.join(", ")}.`,
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
if (candidateModels.length === 0) {
|
|
413
|
+
const decision: RoutingDecision = {
|
|
414
|
+
id: nowId("dec"),
|
|
415
|
+
at: now,
|
|
416
|
+
sessionID: input.session?.sessionID ?? "unknown",
|
|
417
|
+
mode: input.mode,
|
|
418
|
+
dryRun: input.dryRun,
|
|
419
|
+
runtime: input.runtime,
|
|
420
|
+
task: input.task,
|
|
421
|
+
current: input.current,
|
|
422
|
+
switched: false,
|
|
423
|
+
switchReason:
|
|
424
|
+
"Offline mode has no local model available. No hosted provider will be used.",
|
|
425
|
+
reasons: ["Offline mode is enabled and no offline-capable model is in the registry."],
|
|
426
|
+
warnings: [
|
|
427
|
+
...warnings,
|
|
428
|
+
"Offline mode fails closed: configure a local model, or run `opencode-online` to restore hosted routing.",
|
|
429
|
+
],
|
|
430
|
+
candidates: [],
|
|
431
|
+
premium: premiumState(input),
|
|
432
|
+
quota: input.quota,
|
|
433
|
+
contextTokens: input.contextTokens,
|
|
434
|
+
cachedTokens: input.cachedTokens,
|
|
435
|
+
estimatedUsd: 0,
|
|
436
|
+
needsPremiumApproval: false,
|
|
437
|
+
manualOverride: false,
|
|
438
|
+
}
|
|
439
|
+
return { decision, premiumDecision }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (input.runtime === "paused") {
|
|
443
|
+
const decision: RoutingDecision = {
|
|
444
|
+
id: nowId("dec"),
|
|
445
|
+
at: now,
|
|
446
|
+
sessionID: input.session?.sessionID ?? "unknown",
|
|
447
|
+
mode: input.mode,
|
|
448
|
+
dryRun: input.dryRun,
|
|
449
|
+
runtime: input.runtime,
|
|
450
|
+
task: input.task,
|
|
451
|
+
current: input.current,
|
|
452
|
+
switched: false,
|
|
453
|
+
switchReason: "Routing is paused; the current model is unchanged.",
|
|
454
|
+
reasons: ["Pause switch is active."],
|
|
455
|
+
warnings: [],
|
|
456
|
+
candidates: [],
|
|
457
|
+
premium: premiumState(input),
|
|
458
|
+
quota: input.quota,
|
|
459
|
+
contextTokens: input.contextTokens,
|
|
460
|
+
cachedTokens: input.cachedTokens,
|
|
461
|
+
estimatedUsd: 0,
|
|
462
|
+
needsPremiumApproval: false,
|
|
463
|
+
manualOverride: false,
|
|
464
|
+
}
|
|
465
|
+
return { decision, premiumDecision }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (input.runtime === "manual") {
|
|
469
|
+
const decision: RoutingDecision = {
|
|
470
|
+
id: nowId("dec"),
|
|
471
|
+
at: now,
|
|
472
|
+
sessionID: input.session?.sessionID ?? "unknown",
|
|
473
|
+
mode: input.mode,
|
|
474
|
+
dryRun: input.dryRun,
|
|
475
|
+
runtime: input.runtime,
|
|
476
|
+
task: input.task,
|
|
477
|
+
current: input.current,
|
|
478
|
+
switched: false,
|
|
479
|
+
switchReason: "Manual model selection is active for this session; the router did not override it.",
|
|
480
|
+
reasons: ["Manual override respected."],
|
|
481
|
+
warnings: [],
|
|
482
|
+
candidates: [],
|
|
483
|
+
premium: premiumState(input),
|
|
484
|
+
quota: input.quota,
|
|
485
|
+
contextTokens: input.contextTokens,
|
|
486
|
+
cachedTokens: input.cachedTokens,
|
|
487
|
+
estimatedUsd: 0,
|
|
488
|
+
needsPremiumApproval: false,
|
|
489
|
+
manualOverride: true,
|
|
490
|
+
}
|
|
491
|
+
return { decision, premiumDecision }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Models that were rejected purely because the transcript would not fit.
|
|
496
|
+
*
|
|
497
|
+
* This is recorded rather than silently skipped because it is the one
|
|
498
|
+
* rejection a user is most likely to misread. A large session quietly stops
|
|
499
|
+
* being routed to the configured default, and without this note the only
|
|
500
|
+
* visible signal is that some other model is suddenly in use.
|
|
501
|
+
*/
|
|
502
|
+
const contextRejected: Array<{ model: ModelRecord; required: number; window: number; usd: number; tier: number }> = []
|
|
503
|
+
|
|
504
|
+
const evaluate = (designRelaxation: 0 | 1 | 2) => {
|
|
505
|
+
const taskForEvaluation =
|
|
506
|
+
designRelaxation === 1 ? { ...input.task, sophisticatedDesign: false } : input.task
|
|
507
|
+
// The fastest measured eligible model sets the reference point for the
|
|
508
|
+
// speed penalty, so the comparison is always against what this account
|
|
509
|
+
// can actually achieve right now.
|
|
510
|
+
let fastestTokensPerSecond = 0
|
|
511
|
+
for (const model of candidateModels) {
|
|
512
|
+
const measure = input.speed?.[modelKey(model.providerID, model.modelID)]
|
|
513
|
+
if (!measure || measure.samples < input.settings.speed.minSamples) continue
|
|
514
|
+
if (measure.tokensPerSecond > fastestTokensPerSecond) fastestTokensPerSecond = measure.tokensPerSecond
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Pass one evaluates every candidate without the escalation-pool guard.
|
|
519
|
+
* The guard is only applied once an ordinary-allowance route is known to
|
|
520
|
+
* be available: otherwise a task that genuinely needs escalation would be
|
|
521
|
+
* blocked outright instead of being escalated.
|
|
522
|
+
*/
|
|
523
|
+
const run = (protectEscalation: boolean) => {
|
|
524
|
+
const out: Evaluated[] = []
|
|
525
|
+
for (const model of candidateModels) {
|
|
526
|
+
const provider = providerFor(input.quota, model.providerID)
|
|
527
|
+
const modelQuota = modelQuotaFor(provider, model)
|
|
528
|
+
const key = modelKey(model.providerID, model.modelID)
|
|
529
|
+
// A model that just dropped a stream is held out for a cooling period
|
|
530
|
+
// instead of being re-selected on the next prompt.
|
|
531
|
+
const cooldownUntil = input.cooldowns?.[key]
|
|
532
|
+
if (typeof cooldownUntil === "number" && cooldownUntil > now) {
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
// A provider whose own pre-request path has failed several attempts in
|
|
536
|
+
// a row is not serving anyone. Step back from every model it offers
|
|
537
|
+
// and let another provider take the work. If no provider is left the
|
|
538
|
+
// router fails closed and keeps the current model, so this can never
|
|
539
|
+
// block a prompt outright.
|
|
540
|
+
const providerCooldownUntil = input.providerCooldowns?.[model.providerID]
|
|
541
|
+
if (typeof providerCooldownUntil === "number" && providerCooldownUntil > now) {
|
|
542
|
+
continue
|
|
543
|
+
}
|
|
544
|
+
// The estimate is computed first so the subscription-only guard can
|
|
545
|
+
// check the projected request cost against what is left of the
|
|
546
|
+
// subscription allowance.
|
|
547
|
+
const estimate = estimateTaskCost({
|
|
548
|
+
model,
|
|
549
|
+
task: input.task,
|
|
550
|
+
contextTokens: input.contextTokens,
|
|
551
|
+
currentModelKey: currentKey,
|
|
552
|
+
now,
|
|
553
|
+
})
|
|
554
|
+
const eligibility = evaluateEligibility({
|
|
555
|
+
intelligenceFloor: floor,
|
|
556
|
+
model,
|
|
557
|
+
task: taskForEvaluation,
|
|
558
|
+
settings: input.settings,
|
|
559
|
+
mode: input.mode,
|
|
560
|
+
contextTokens: input.contextTokens,
|
|
561
|
+
providerQuota: provider,
|
|
562
|
+
quotaIsKnown,
|
|
563
|
+
modelQuota,
|
|
564
|
+
premium: premiumDecision,
|
|
565
|
+
currentModelKey: currentKey,
|
|
566
|
+
allowDesignRelaxation: designRelaxation === 2,
|
|
567
|
+
projectedUsd: estimate.usd,
|
|
568
|
+
escalationPoolProtected: protectEscalation,
|
|
569
|
+
})
|
|
570
|
+
if (!eligibility.eligible) {
|
|
571
|
+
// Record only a context-capacity rejection. It is deterministic,
|
|
572
|
+
// actionable ("start a new session"), and otherwise invisible.
|
|
573
|
+
if (eligibility.rejectedFor === "context" && eligibility.contextRequiredTokens !== undefined && eligibility.contextWindowTokens !== undefined) {
|
|
574
|
+
contextRejected.push({
|
|
575
|
+
model,
|
|
576
|
+
required: eligibility.contextRequiredTokens,
|
|
577
|
+
window: eligibility.contextWindowTokens,
|
|
578
|
+
usd: estimate.usd,
|
|
579
|
+
tier: model.tier,
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
continue
|
|
583
|
+
}
|
|
584
|
+
const eligibleReason =
|
|
585
|
+
designRelaxation === 1 && input.task.sophisticatedDesign
|
|
586
|
+
? `Standard design route: design ${model.design} is below the sophisticated ${input.settings.routing.sophisticatedDesignFloor} threshold`
|
|
587
|
+
: eligibility.reason
|
|
588
|
+
const displayedEligibility = eligibleReason === eligibility.reason ? eligibility : { ...eligibility, reason: eligibleReason }
|
|
589
|
+
const probability = estimateSuccess({
|
|
590
|
+
model,
|
|
591
|
+
task: input.task,
|
|
592
|
+
contextTokens: input.contextTokens,
|
|
593
|
+
session: input.session,
|
|
594
|
+
currentModelKey: currentKey,
|
|
595
|
+
history: input.modelHistory?.[modelKey(model.providerID, model.modelID)],
|
|
596
|
+
intelligenceFloor: floor,
|
|
597
|
+
})
|
|
598
|
+
const pressure = providerPressure(provider, now) * modelPressure(modelQuota)
|
|
599
|
+
const speedFactor = speedPenalty(input, modelKey(model.providerID, model.modelID), fastestTokensPerSecond)
|
|
600
|
+
const marginalPriceFactor = quotaMarginalPriceFactor(provider, now)
|
|
601
|
+
const candidate = candidateFor(
|
|
602
|
+
input,
|
|
603
|
+
model,
|
|
604
|
+
displayedEligibility,
|
|
605
|
+
estimate,
|
|
606
|
+
probability,
|
|
607
|
+
pressure,
|
|
608
|
+
currentKey,
|
|
609
|
+
speedFactor,
|
|
610
|
+
marginalPriceFactor,
|
|
611
|
+
)
|
|
612
|
+
out.push({ model, eligibility: displayedEligibility, estimate, probability, pressure, candidate })
|
|
613
|
+
}
|
|
614
|
+
return out.sort((a, b) => a.candidate.effectiveCost - b.candidate.effectiveCost)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const unprotected = run(false)
|
|
618
|
+
if (!input.settings.routing.protectEscalationPool) return unprotected
|
|
619
|
+
// Only protect the escalation pool when an ordinary-allowance route can
|
|
620
|
+
// actually serve the task. A task that needs escalation must still be
|
|
621
|
+
// able to escalate.
|
|
622
|
+
const ordinaryAvailable = unprotected.some((entry) => !entry.model.escalationOnly && !entry.model.premium)
|
|
623
|
+
if (!ordinaryAvailable) return unprotected
|
|
624
|
+
return run(true)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let evaluated = evaluate(0)
|
|
628
|
+
if (evaluated.length === 0 && input.task.designIntensive) {
|
|
629
|
+
// First fall back to the standard design floor, which keeps a qualified
|
|
630
|
+
// design model instead of jumping straight to the cheapest model overall.
|
|
631
|
+
evaluated = evaluate(1)
|
|
632
|
+
if (evaluated.length > 0) {
|
|
633
|
+
warnings.push("No model met the sophisticated design threshold; using the best qualified standard design route.")
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (evaluated.length === 0 && input.task.designIntensive) {
|
|
637
|
+
evaluated = evaluate(2)
|
|
638
|
+
if (evaluated.length > 0) {
|
|
639
|
+
warnings.push("No model met the standard design floor; using the best available design-capable fallback.")
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Premium tasks without approval still need a deliberately chosen standard
|
|
644
|
+
// fallback rather than silently keeping an unqualified cheap model.
|
|
645
|
+
let premiumFallback = false
|
|
646
|
+
if (evaluated.length === 0 && input.task.tier === 3) {
|
|
647
|
+
const relaxedPremium: PremiumDecision = { allowed: false, requiresApproval: premiumDecision.requiresApproval, reason: premiumDecision.reason }
|
|
648
|
+
const fallbackModels = candidateModels
|
|
649
|
+
.filter((model) => !model.premium)
|
|
650
|
+
.map((model) => {
|
|
651
|
+
const provider = providerFor(input.quota, model.providerID)
|
|
652
|
+
const modelQuota = modelQuotaFor(provider, model)
|
|
653
|
+
const key = modelKey(model.providerID, model.modelID)
|
|
654
|
+
const cooldownUntil = input.cooldowns?.[key]
|
|
655
|
+
if (typeof cooldownUntil === "number" && cooldownUntil > now) return undefined
|
|
656
|
+
const estimate = estimateTaskCost({
|
|
657
|
+
model,
|
|
658
|
+
task: input.task,
|
|
659
|
+
contextTokens: input.contextTokens,
|
|
660
|
+
currentModelKey: currentKey,
|
|
661
|
+
now,
|
|
662
|
+
})
|
|
663
|
+
const eligibility = evaluateEligibility({
|
|
664
|
+
intelligenceFloor: floor,
|
|
665
|
+
model,
|
|
666
|
+
task: { ...input.task, designIntensive: false, sophisticatedDesign: false },
|
|
667
|
+
settings: input.settings,
|
|
668
|
+
mode: input.mode,
|
|
669
|
+
contextTokens: input.contextTokens,
|
|
670
|
+
providerQuota: provider,
|
|
671
|
+
quotaIsKnown,
|
|
672
|
+
modelQuota,
|
|
673
|
+
premium: relaxedPremium,
|
|
674
|
+
currentModelKey: currentKey,
|
|
675
|
+
allowDesignRelaxation: true,
|
|
676
|
+
projectedUsd: estimate.usd,
|
|
677
|
+
})
|
|
678
|
+
if (!eligibility.eligible) return undefined
|
|
679
|
+
const probability = estimateSuccess({
|
|
680
|
+
model,
|
|
681
|
+
task: input.task,
|
|
682
|
+
contextTokens: input.contextTokens,
|
|
683
|
+
session: input.session,
|
|
684
|
+
currentModelKey: currentKey,
|
|
685
|
+
history: input.modelHistory?.[modelKey(model.providerID, model.modelID)],
|
|
686
|
+
intelligenceFloor: floor,
|
|
687
|
+
})
|
|
688
|
+
const pressure = providerPressure(provider, now) * modelPressure(modelQuota)
|
|
689
|
+
const marginalPriceFactor = quotaMarginalPriceFactor(provider, now)
|
|
690
|
+
return {
|
|
691
|
+
model,
|
|
692
|
+
eligibility,
|
|
693
|
+
estimate,
|
|
694
|
+
probability,
|
|
695
|
+
pressure,
|
|
696
|
+
candidate: candidateFor(
|
|
697
|
+
input,
|
|
698
|
+
model,
|
|
699
|
+
eligibility,
|
|
700
|
+
estimate,
|
|
701
|
+
probability,
|
|
702
|
+
pressure,
|
|
703
|
+
currentKey,
|
|
704
|
+
1,
|
|
705
|
+
marginalPriceFactor,
|
|
706
|
+
),
|
|
707
|
+
}
|
|
708
|
+
})
|
|
709
|
+
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
|
|
710
|
+
.sort((a, b) => a.candidate.effectiveCost - b.candidate.effectiveCost)
|
|
711
|
+
if (fallbackModels.length > 0) {
|
|
712
|
+
evaluated = fallbackModels
|
|
713
|
+
premiumFallback = true
|
|
714
|
+
warnings.push("Premium is unavailable; selected the best qualified standard fallback.")
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (evaluated.length === 0) {
|
|
719
|
+
warnings.push("No approved model meets every hard requirement; the current model was left unchanged.")
|
|
720
|
+
const decision: RoutingDecision = {
|
|
721
|
+
id: nowId("dec"),
|
|
722
|
+
at: now,
|
|
723
|
+
sessionID: input.session?.sessionID ?? "unknown",
|
|
724
|
+
mode: input.mode,
|
|
725
|
+
dryRun: input.dryRun,
|
|
726
|
+
runtime: input.runtime,
|
|
727
|
+
task: input.task,
|
|
728
|
+
current: input.current,
|
|
729
|
+
switched: false,
|
|
730
|
+
switchReason: "No eligible route was found; the router failed closed and kept the current model.",
|
|
731
|
+
reasons: [...input.task.reasons, ...warnings],
|
|
732
|
+
warnings,
|
|
733
|
+
candidates: [],
|
|
734
|
+
premium: premiumState(input),
|
|
735
|
+
quota: input.quota,
|
|
736
|
+
contextTokens: input.contextTokens,
|
|
737
|
+
cachedTokens: input.cachedTokens,
|
|
738
|
+
estimatedUsd: 0,
|
|
739
|
+
needsPremiumApproval: premiumDecision.requiresApproval,
|
|
740
|
+
manualOverride: false,
|
|
741
|
+
...(input.lastError ? { error: safeError(input.lastError) } : {}),
|
|
742
|
+
}
|
|
743
|
+
return { decision, premiumDecision }
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const best = evaluated[0]!
|
|
747
|
+
const currentEval = currentKey
|
|
748
|
+
? evaluated.find((entry) => modelKey(entry.model.providerID, entry.model.modelID) === currentKey)
|
|
749
|
+
: undefined
|
|
750
|
+
const largeContext = input.contextTokens >= input.settings.routing.largeContextTokens
|
|
751
|
+
const hysteresis = largeContext
|
|
752
|
+
? input.settings.routing.largeContextHysteresisPct
|
|
753
|
+
: input.settings.routing.switchHysteresisPct
|
|
754
|
+
|
|
755
|
+
let chosen = best
|
|
756
|
+
let keptForAffinity = false
|
|
757
|
+
if (currentEval && currentEval.model.tier >= input.task.tier && currentEval.candidate.eligible) {
|
|
758
|
+
const savings = currentEval.candidate.effectiveCost - best.candidate.effectiveCost
|
|
759
|
+
const threshold = currentEval.candidate.effectiveCost * (hysteresis / 100)
|
|
760
|
+
if (best.model !== currentEval.model && savings < threshold) {
|
|
761
|
+
chosen = currentEval
|
|
762
|
+
keptForAffinity = true
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const chosenRef = toRef(chosen.model)
|
|
767
|
+
const sameAsCurrent = currentKey === modelKey(chosen.model.providerID, chosen.model.modelID)
|
|
768
|
+
const wouldSwitch = !sameAsCurrent
|
|
769
|
+
const needsPremiumApproval =
|
|
770
|
+
premiumDecision.requiresApproval &&
|
|
771
|
+
(input.task.tier === 3 || input.task.constraints.requestPremium) &&
|
|
772
|
+
input.settings.premium.mode !== "off" &&
|
|
773
|
+
input.mode !== "efficiency"
|
|
774
|
+
|
|
775
|
+
if (input.task.tier === 3 && !premiumDecision.allowed && input.mode !== "efficiency" && input.settings.premium.mode !== "off") {
|
|
776
|
+
const bestPremium = candidateModels
|
|
777
|
+
.filter((model) => model.premium && model.approved)
|
|
778
|
+
.map((model) => {
|
|
779
|
+
const provider = providerFor(input.quota, model.providerID)
|
|
780
|
+
const modelQuota = modelQuotaFor(provider, model)
|
|
781
|
+
const eligibility = evaluateEligibility({
|
|
782
|
+
intelligenceFloor: floor,
|
|
783
|
+
model,
|
|
784
|
+
task: input.task,
|
|
785
|
+
settings: input.settings,
|
|
786
|
+
mode: input.mode,
|
|
787
|
+
contextTokens: input.contextTokens,
|
|
788
|
+
providerQuota: provider,
|
|
789
|
+
modelQuota,
|
|
790
|
+
premium: { allowed: true, requiresApproval: true, reason: "Premium suggestion" },
|
|
791
|
+
currentModelKey: currentKey,
|
|
792
|
+
allowDesignRelaxation: false,
|
|
793
|
+
})
|
|
794
|
+
if (!eligibility.eligible) return undefined
|
|
795
|
+
const estimate = estimateTaskCost({
|
|
796
|
+
model,
|
|
797
|
+
task: input.task,
|
|
798
|
+
contextTokens: input.contextTokens,
|
|
799
|
+
currentModelKey: currentKey,
|
|
800
|
+
now,
|
|
801
|
+
})
|
|
802
|
+
return { model, estimate }
|
|
803
|
+
})
|
|
804
|
+
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
|
|
805
|
+
.sort((a, b) => {
|
|
806
|
+
if (input.task.designIntensive || input.task.sophisticatedDesign) {
|
|
807
|
+
if (b.model.design !== a.model.design) return b.model.design - a.model.design
|
|
808
|
+
} else if (b.model.intelligence !== a.model.intelligence) {
|
|
809
|
+
return b.model.intelligence - a.model.intelligence
|
|
810
|
+
}
|
|
811
|
+
return a.estimate.usd - b.estimate.usd
|
|
812
|
+
})[0]
|
|
813
|
+
if (bestPremium) {
|
|
814
|
+
warnings.push(
|
|
815
|
+
`Premium route suggested: ${bestPremium.model.name} (${usd(bestPremium.estimate.usd)} estimated). Approve once with /router-approve or set a premium budget.`,
|
|
816
|
+
)
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (premiumFallback) {
|
|
821
|
+
warnings.push("This task is premium-tier, but automatic premium escalation is unavailable.")
|
|
822
|
+
}
|
|
823
|
+
if (largeContext && wouldSwitch) {
|
|
824
|
+
warnings.push("Large context detected; switching increases fresh-input cost because prompt cache is model-specific.")
|
|
825
|
+
}
|
|
826
|
+
// Explain a context-driven switch in terms the user can act on. Without this,
|
|
827
|
+
// a large session silently stops using the configured default and the only
|
|
828
|
+
// visible signal is that some other model is suddenly in use.
|
|
829
|
+
//
|
|
830
|
+
// Two conditions keep this from becoming noise. The model must be at least
|
|
831
|
+
// as capable as what was chosen, and cheaper, so it would have won had it
|
|
832
|
+
// fit. Comparing against the chosen model rather than the task tier matters:
|
|
833
|
+
// a premium task served by a standard fallback should still report that a
|
|
834
|
+
// cheaper standard model was displaced by size. And if its display name
|
|
835
|
+
// matches the chosen model's, the provider is named too, because "DeepSeek
|
|
836
|
+
// was skipped" while DeepSeek is running reads as a bug.
|
|
837
|
+
const viableOnMerit = contextRejected.filter(
|
|
838
|
+
(entry) =>
|
|
839
|
+
(entry.model.providerID !== chosen.model.providerID || entry.model.modelID !== chosen.model.modelID) &&
|
|
840
|
+
entry.tier >= chosen.model.tier &&
|
|
841
|
+
entry.usd < chosen.candidate.estimatedUsd,
|
|
842
|
+
)
|
|
843
|
+
const displaced =
|
|
844
|
+
viableOnMerit.find(
|
|
845
|
+
(entry) =>
|
|
846
|
+
input.current?.providerID === entry.model.providerID && input.current?.id === entry.model.modelID,
|
|
847
|
+
) ?? [...viableOnMerit].sort((a, b) => a.usd - b.usd)[0]
|
|
848
|
+
if (displaced) {
|
|
849
|
+
const sameName = displaced.model.name === chosen.model.name
|
|
850
|
+
const label = sameName ? `${displaced.model.name} on ${displaced.model.providerID}` : displaced.model.name
|
|
851
|
+
warnings.push(
|
|
852
|
+
`${label} was skipped for this turn: the transcript needs about ${thousands(displaced.required)} tokens of context and that model's window is ${thousands(displaced.window)}. Start a new session to route to it again.`,
|
|
853
|
+
)
|
|
854
|
+
}
|
|
855
|
+
if (chosen.model.premium) {
|
|
856
|
+
reasons.push(`Premium route approved: ${chosen.model.name}.`)
|
|
857
|
+
}
|
|
858
|
+
if (keptForAffinity) {
|
|
859
|
+
reasons.push(`Kept ${currentEval!.model.name} for cache affinity; the estimated savings were below the ${hysteresis}% switch threshold.`)
|
|
860
|
+
} else if (wouldSwitch) {
|
|
861
|
+
reasons.push(
|
|
862
|
+
`Selected ${chosen.model.name} as the least-cost qualified route after expected retries and quota pressure (${usd(chosen.candidate.estimatedUsd)} estimated raw cost, ${usd(chosen.candidate.effectiveCost)} expected).`,
|
|
863
|
+
)
|
|
864
|
+
} else {
|
|
865
|
+
reasons.push(`Already on ${chosen.model.name}, the least-cost qualified route.`)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const quotaNotes = input.quota
|
|
869
|
+
.filter((snapshot) => snapshot.source === "provider")
|
|
870
|
+
.map(
|
|
871
|
+
(snapshot) =>
|
|
872
|
+
`${snapshot.label}: ${snapshot.rolling.usedPct.toFixed(0)}% 5h, ${snapshot.weekly.usedPct.toFixed(0)}% weekly, ${snapshot.monthly.usedPct.toFixed(0)}% monthly`,
|
|
873
|
+
)
|
|
874
|
+
reasons.push(...quotaNotes)
|
|
875
|
+
|
|
876
|
+
const candidates = evaluated
|
|
877
|
+
.slice(0, 12)
|
|
878
|
+
.map((entry) => ({
|
|
879
|
+
...entry.candidate,
|
|
880
|
+
retained: keptForAffinity ? entry.model === currentEval?.model : entry.model === chosen.model,
|
|
881
|
+
}))
|
|
882
|
+
if (currentEval && !candidates.some((candidate) => candidate.model.providerID === currentEval.model.providerID && candidate.model.modelID === currentEval.model.modelID)) {
|
|
883
|
+
candidates.push({ ...currentEval.candidate, retained: keptForAffinity })
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const switchReason = !wouldSwitch
|
|
887
|
+
? keptForAffinity
|
|
888
|
+
? "Kept the current model for cache affinity."
|
|
889
|
+
: "The current model already is the least-cost qualified route."
|
|
890
|
+
: input.dryRun
|
|
891
|
+
? `Dry run: would switch to ${chosen.model.name}.`
|
|
892
|
+
: `Switch to ${chosen.model.name}.`
|
|
893
|
+
|
|
894
|
+
const decision: RoutingDecision = {
|
|
895
|
+
id: nowId("dec"),
|
|
896
|
+
at: now,
|
|
897
|
+
sessionID: input.session?.sessionID ?? "unknown",
|
|
898
|
+
mode: input.mode,
|
|
899
|
+
dryRun: input.dryRun,
|
|
900
|
+
runtime: input.runtime,
|
|
901
|
+
task: input.task,
|
|
902
|
+
current: input.current,
|
|
903
|
+
chosen: chosenRef,
|
|
904
|
+
switched: wouldSwitch && !input.dryRun,
|
|
905
|
+
switchReason,
|
|
906
|
+
reasons: [...input.task.reasons, ...reasons],
|
|
907
|
+
warnings,
|
|
908
|
+
candidates,
|
|
909
|
+
premium: premiumState(input),
|
|
910
|
+
quota: input.quota,
|
|
911
|
+
contextTokens: input.contextTokens,
|
|
912
|
+
cachedTokens: input.cachedTokens,
|
|
913
|
+
estimatedUsd: chosen.candidate.estimatedUsd,
|
|
914
|
+
needsPremiumApproval,
|
|
915
|
+
manualOverride: false,
|
|
916
|
+
...(input.lastError ? { error: safeError(input.lastError) } : {}),
|
|
917
|
+
}
|
|
918
|
+
return { decision, chosenModel: chosen.model, premiumDecision }
|
|
919
|
+
}
|