@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Context-management strategies.
3
+ *
4
+ * These are deliberately separate mechanisms, because they solve different
5
+ * problems and have very different costs:
6
+ *
7
+ * - **Provider prompt caching** happens on the provider's side. The router
8
+ * never controls it; it only avoids destroying it by needlessly switching
9
+ * models. There is nothing to configure and it must never be disabled.
10
+ * - **Context budgeting** is arithmetic done before a request: does the
11
+ * transcript fit the model window the provider actually accepts?
12
+ * - **Compaction** is destructive. It replaces the transcript with a summary
13
+ * and permanently loses detail. On the old $10 plan it was the only way to
14
+ * keep a long session on the cheapest provider, so the router compacted
15
+ * eagerly.
16
+ *
17
+ * Only the third is a strategy. The three modes below differ almost entirely
18
+ * in *when*, and *whether*, compaction happens.
19
+ */
20
+
21
+ import type { ContextManagementMode, ContextManagementSettings } from "../types.js"
22
+
23
+ /** A margin added on top of the measured transcript before judging fit. */
24
+ export const CONTEXT_ELIGIBILITY_MARGIN_TOKENS = 20_000
25
+
26
+ /**
27
+ * The smallest share of a window that the reply margin may claim.
28
+ *
29
+ * A flat 20,000-token margin is right for a 1M window and impossible for an
30
+ * 8,192-token local one: the margin alone would exceed the window, so every
31
+ * request would be rejected before it started. The margin therefore shrinks
32
+ * with small windows and never grows beyond the flat value.
33
+ *
34
+ * The floor keeps a usable share of the window for the reply. At 8,192 tokens
35
+ * this reserves 1,024 and leaves 6,758 of transcript after the safety fraction,
36
+ * which fits a component, its styles and a focused instruction.
37
+ */
38
+ const MINIMUM_REPLY_RESERVE_FRACTION = 0.125
39
+
40
+ /**
41
+ * The reply margin that actually applies to a given window.
42
+ *
43
+ * Exported so the routing gate and the compaction strategy cannot drift apart:
44
+ * both call this with the same window and get the same answer.
45
+ */
46
+ export const replyMarginFor = (windowTokens: number): number => {
47
+ if (!Number.isFinite(windowTokens) || windowTokens <= 0) return CONTEXT_ELIGIBILITY_MARGIN_TOKENS
48
+ return Math.min(CONTEXT_ELIGIBILITY_MARGIN_TOKENS, Math.floor(windowTokens * MINIMUM_REPLY_RESERVE_FRACTION))
49
+ }
50
+
51
+ /** The fraction of a model's window a request must stay under. */
52
+ export const CONTEXT_SAFE_WINDOW_FRACTION = 0.95
53
+
54
+ export interface CompactionDecision {
55
+ readonly compact: boolean
56
+ /** Why this decision was reached, in plain language for the owner. */
57
+ readonly reason: string
58
+ /** The transcript size that triggered the decision, when one did. */
59
+ readonly triggerTokens?: number
60
+ /** The size compaction is trying to get under, when relevant. */
61
+ readonly targetTokens?: number
62
+ /** True when the host's verified limit, not a router preference, drove it. */
63
+ readonly hardLimit: boolean
64
+ }
65
+
66
+ const no: (reason: string) => CompactionDecision = (reason) => ({ compact: false, reason, hardLimit: false })
67
+
68
+ export interface CompactionContext {
69
+ readonly mode: ContextManagementMode
70
+ readonly settings: ContextManagementSettings
71
+ readonly contextTokens: number
72
+ /**
73
+ * The largest window the session can actually use on the provider it is on.
74
+ * This is the provider's accepted limit, never a theoretical maximum.
75
+ */
76
+ readonly effectiveWindow: number
77
+ /**
78
+ * The window of the cheapest qualified route. On the budget-optimized mode
79
+ * this is smaller than `effectiveWindow`, and staying inside it is what
80
+ * keeps a session on the cheapest provider.
81
+ */
82
+ readonly cheapestWindow?: number
83
+ }
84
+
85
+ /**
86
+ * Decide whether to compact.
87
+ *
88
+ * All three modes keep a real safety floor: a request that would exceed the
89
+ * provider's verified window must be handled before it is sent, because the
90
+ * alternative is a failed turn or silent truncation by the provider. They
91
+ * differ in whether *any* earlier compaction happens.
92
+ */
93
+ export const decideCompaction = (input: CompactionContext): CompactionDecision => {
94
+ const { mode, settings, contextTokens, effectiveWindow } = input
95
+ if (!settings.enabled) {
96
+ return { compact: false, reason: "Context management is disabled; native context handling only.", hardLimit: false }
97
+ }
98
+ if (contextTokens <= 0) return no("No measured context size yet.")
99
+ if (effectiveWindow <= 0) return no("The model's accepted context window is unknown; no router-driven compaction.")
100
+
101
+ const required = contextTokens + replyMarginFor(effectiveWindow)
102
+ const safeLimit = effectiveWindow * CONTEXT_SAFE_WINDOW_FRACTION
103
+ const overflow = required > safeLimit
104
+
105
+ if (mode === "native") {
106
+ // Native mode still refuses to knowingly send an over-window request.
107
+ // The host's own compaction is the preferred recovery; the router only
108
+ // steps in when the request genuinely cannot fit.
109
+ if (!overflow) return no("Native mode: the transcript fits the verified window.")
110
+ if (settings.allowEmergencyCompaction === false) {
111
+ return {
112
+ compact: false,
113
+ reason: "Native mode: transcript is at the verified limit, but emergency compaction is disabled.",
114
+ triggerTokens: contextTokens,
115
+ targetTokens: Math.floor(safeLimit),
116
+ hardLimit: true,
117
+ }
118
+ }
119
+ return {
120
+ compact: true,
121
+ reason: "Native mode emergency compaction: the transcript would exceed the model's verified window.",
122
+ triggerTokens: contextTokens,
123
+ targetTokens: Math.floor(safeLimit),
124
+ hardLimit: true,
125
+ }
126
+ }
127
+
128
+ if (mode === "conservative") {
129
+ if (!overflow) return no("Conservative mode: the transcript fits the verified window; history preserved.")
130
+ return {
131
+ compact: true,
132
+ reason: "Conservative mode: compacting only because the transcript would exceed the verified window.",
133
+ triggerTokens: contextTokens,
134
+ targetTokens: Math.floor(safeLimit),
135
+ hardLimit: true,
136
+ }
137
+ }
138
+
139
+ // budget-optimized: the legacy behavior, preserved deliberately.
140
+ const target = input.cheapestWindow && input.cheapestWindow > 0 ? input.cheapestWindow : effectiveWindow
141
+ const targetLimit = target * CONTEXT_SAFE_WINDOW_FRACTION
142
+ const threshold = Math.min(
143
+ targetLimit,
144
+ settings.compactAtTokens > 0 ? settings.compactAtTokens : Number.POSITIVE_INFINITY,
145
+ )
146
+ if (required <= threshold) return no("Budget-optimized mode: inside the configured compact threshold.")
147
+
148
+ const hardLimit = required > safeLimit
149
+ return {
150
+ compact: true,
151
+ reason: hardLimit
152
+ ? "Budget-optimized mode: compacting to stay inside the verified window of the cheapest qualified route."
153
+ : "Budget-optimized mode: compacting early to keep this session on the cheapest qualified route.",
154
+ triggerTokens: contextTokens,
155
+ targetTokens: Math.floor(threshold),
156
+ hardLimit,
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The practical window for a model.
162
+ *
163
+ * A model's theoretical maximum and the window a gateway will actually accept
164
+ * are different numbers. The adapter reports the accepted value into
165
+ * `ModelRecord.context`, and this helper exists so the distinction is explicit
166
+ * wherever a limit is used rather than being re-derived at each call site.
167
+ */
168
+ export const practicalWindow = (model: { context: number; maxOutput: number }): number => {
169
+ if (model.context <= 0) return 0
170
+ // Output budget is part of the same window on providers that reserve it
171
+ // inside the context length. Subtracting it would double-count when the
172
+ // provider already reports a net input budget, so only a sanity floor is
173
+ // applied here; the authoritative figure is the reported window.
174
+ return model.context
175
+ }
176
+
177
+ export const describeMode = (mode: ContextManagementMode): string => {
178
+ switch (mode) {
179
+ case "native":
180
+ return "Native: no router-driven compaction unless the transcript would exceed the verified window."
181
+ case "conservative":
182
+ return "Conservative: history preserved; compaction only to prevent a real overflow."
183
+ case "budget-optimized":
184
+ return "Budget-optimized: compacts early to keep sessions on the cheapest qualified route."
185
+ default:
186
+ return "Unknown mode."
187
+ }
188
+ }
@@ -0,0 +1,39 @@
1
+ import type { ContextHealth, ContextHealthSettings, SessionState } from "./types.js"
2
+ import { pct } from "./util.js"
3
+
4
+ export const computeContextHealth = (
5
+ session: SessionState | undefined,
6
+ settings: ContextHealthSettings,
7
+ ): ContextHealth => {
8
+ const tokens = session?.contextTokens ?? 0
9
+ const cacheRead = session?.cacheReadTokens ?? 0
10
+ const cacheSharePct = tokens > 0 ? pct(cacheRead, tokens) : 0
11
+ if (!settings.enabled) {
12
+ return { tokens, cacheRead, cacheSharePct, state: "healthy", message: "Context-health monitoring is disabled." }
13
+ }
14
+ if (tokens >= settings.recommendAtTokens) {
15
+ return {
16
+ tokens,
17
+ cacheRead,
18
+ cacheSharePct,
19
+ state: "recommend_new_session",
20
+ message: `This session has about ${tokens.toLocaleString()} context tokens. A fresh session with a short handoff will usually cost less per turn. The router will not compact or replace this conversation.`,
21
+ }
22
+ }
23
+ if (tokens >= settings.warnAtTokens) {
24
+ return {
25
+ tokens,
26
+ cacheRead,
27
+ cacheSharePct,
28
+ state: "watch",
29
+ message: `Context is about ${tokens.toLocaleString()} tokens. Cache reads are cheaper than fresh input but are not free. Consider a new session for unrelated work.`,
30
+ }
31
+ }
32
+ return {
33
+ tokens,
34
+ cacheRead,
35
+ cacheSharePct,
36
+ state: "healthy",
37
+ message: `${tokens.toLocaleString()} context tokens; ${cacheSharePct.toFixed(0)}% arrived as cache reads. Cache reads are cheaper than fresh input but are not free.`,
38
+ }
39
+ }
@@ -0,0 +1,398 @@
1
+ import type { FailureClass, SessionState } from "./types.js"
2
+
3
+ export interface FailureInput {
4
+ readonly type?: string
5
+ readonly message: string
6
+ readonly status?: number
7
+ }
8
+
9
+ export interface FailureAssessment {
10
+ readonly class: FailureClass
11
+ readonly retryable: boolean
12
+ readonly capabilityFailure: boolean
13
+ readonly escalate: boolean
14
+ readonly cooldownMs: number
15
+ readonly reason: string
16
+ /**
17
+ * True when the failure is the provider's fault and should count against
18
+ * the model's reliability record. A user-initiated interrupt is not.
19
+ */
20
+ readonly countAgainstModel: boolean
21
+ /**
22
+ * True when the fault is in the provider's own pre-request path (billing
23
+ * scope, plan entitlement) rather than in the serving model. Such a fault
24
+ * belongs to the provider as a whole and must not be charged to a model.
25
+ */
26
+ readonly providerScoped: boolean
27
+ /**
28
+ * True when the provider's gateway refused the work because it is saturated.
29
+ *
30
+ * Kept separate from `providerScoped` because the two need different
31
+ * handling downstream. A billing-scope fault happens before a model is even
32
+ * chosen, so cooling that model would be arbitrary blame. A saturated
33
+ * gateway is a reason to step off the endpoint that just refused, so the
34
+ * serving model is held back too.
35
+ */
36
+ readonly saturation?: boolean
37
+ }
38
+
39
+ const includes = (value: string, terms: ReadonlyArray<string>) => terms.some((term) => value.includes(term))
40
+
41
+ export const classifyFailure = (input: FailureInput, session?: SessionState): FailureAssessment => {
42
+ const message = input.message.toLowerCase()
43
+ const type = (input.type ?? "").toLowerCase()
44
+ const status = input.status
45
+
46
+ // A user or agent interrupt is not a provider fault and must never count
47
+ // against a model's reliability.
48
+ if (
49
+ type.includes("abort") ||
50
+ type.includes("interrupt") ||
51
+ includes(message, ["step interrupted", "request aborted", "operation canceled", "operation cancelled", "user aborted"])
52
+ ) {
53
+ return {
54
+ class: "interrupted",
55
+ retryable: false,
56
+ capabilityFailure: false,
57
+ escalate: false,
58
+ cooldownMs: 0,
59
+ reason: "The step was interrupted; this is not evidence of a provider fault.",
60
+ countAgainstModel: false,
61
+ providerScoped: false,
62
+ }
63
+ }
64
+
65
+
66
+ /**
67
+ * The account is out of allowance.
68
+ *
69
+ * This is the compensation for every provider whose quota cannot be read in
70
+ * advance: OpenDesign publishes no usage endpoint for an inference key, so the
71
+ * router's own figure is a lower bound and it will not notice a spent
72
+ * allowance until the provider says no. The first refusal is therefore the
73
+ * signal, and it has to be used properly — retrying cannot conjure credit, and
74
+ * hammering the same exhausted provider wastes a turn each time.
75
+ *
76
+ * Checked before the generic 4xx paths because gateways disagree on the status
77
+ * code: 402 is the honest one, but a 400 or 403 with "insufficient credits" is
78
+ * common, and treating that as `invalid_request` would blame the user's prompt
79
+ * for an empty wallet.
80
+ */
81
+ if (
82
+ // 402 is the unambiguous signal. A 5xx is never this: a server fault in the
83
+ // billing path is an outage, not an empty account, and OpenDesign returns
84
+ // exactly that as `workspace_billing_scope_unavailable`.
85
+ status === 402 ||
86
+ ((status === undefined || status < 500) &&
87
+ includes(message, [
88
+ "insufficient credit",
89
+ "insufficient_credit",
90
+ "insufficient balance",
91
+ "insufficient_quota",
92
+ "no credit left",
93
+ "out of credit",
94
+ "credit balance is",
95
+ "credit limit reached",
96
+ "quota exceeded",
97
+ "quota_exceeded",
98
+ "allowance exhausted",
99
+ "allowance is exhausted",
100
+ "exceeded your current quota",
101
+ ]))
102
+ ) {
103
+ return {
104
+ class: "quota_exhausted",
105
+ retryable: false,
106
+ capabilityFailure: false,
107
+ escalate: true,
108
+ cooldownMs: 0,
109
+ reason: "The provider reports no allowance left; retrying the same model cannot help.",
110
+ // The account is empty, not the model. Charging this to the model would
111
+ // make a perfectly good model look unreliable and quietly drop it.
112
+ countAgainstModel: false,
113
+ providerScoped: true,
114
+ }
115
+ }
116
+
117
+ if (
118
+ includes(message, [
119
+ "context length",
120
+ "context_length_exceeded",
121
+ "maximum context",
122
+ "too many tokens",
123
+ "prompt is too long",
124
+ "exceeds the context",
125
+ "context window",
126
+ "input is too long",
127
+ ])
128
+ ) {
129
+ return {
130
+ class: "context_overflow",
131
+ retryable: false,
132
+ capabilityFailure: false,
133
+ escalate: false,
134
+ cooldownMs: 0,
135
+ reason: "Context overflow is a session-size problem, not an intelligence failure. Compact or start a handoff session.",
136
+ countAgainstModel: false,
137
+ providerScoped: false,
138
+ }
139
+ }
140
+
141
+ if (status === 429 || includes(message, ["rate limit", "rate_limit", "too many requests"]) || type.includes("rate_limit") || type.includes("ratelimit")) {
142
+ return {
143
+ class: "rate_limit",
144
+ retryable: true,
145
+ capabilityFailure: false,
146
+ escalate: false,
147
+ cooldownMs: 2 * 60_000,
148
+ reason: "Provider rate limit; wait or use another provider. A stronger model would consume the same limit.",
149
+ countAgainstModel: true,
150
+ providerScoped: false,
151
+ }
152
+ }
153
+
154
+ if (status === 401 || status === 403 || includes(message, ["unauthorized", "invalid api key", "authentication", "forbidden", "permission denied"]) || type.includes("auth")) {
155
+ return {
156
+ class: "auth",
157
+ retryable: false,
158
+ capabilityFailure: false,
159
+ escalate: false,
160
+ cooldownMs: 15 * 60_000,
161
+ reason: "Authentication or authorization failed; escalating models cannot fix a credential problem.",
162
+ countAgainstModel: true,
163
+ providerScoped: false,
164
+ }
165
+ }
166
+
167
+ if (
168
+ includes(message, ["image", "media", "unsupported content", "invalid image", "vision", "content type"]) &&
169
+ includes(message, ["unsupported", "invalid", "not support", "reject", "format"])
170
+ ) {
171
+ return {
172
+ class: "image_rejected",
173
+ retryable: false,
174
+ capabilityFailure: false,
175
+ escalate: false,
176
+ cooldownMs: 10 * 60_000,
177
+ reason: "The endpoint rejected the image input; route visual work only to a verified vision model.",
178
+ countAgainstModel: true,
179
+ providerScoped: false,
180
+ }
181
+ }
182
+
183
+ // A deterministic request-shape rejection: the provider refused the prompt
184
+ // before generating anything (an HTTP 400-class failure carries no billing,
185
+ // rate or capacity meaning). Retry cannot change the outcome; the request
186
+ // itself is the problem, so the router repairs or fails over instead of
187
+ // retrying. The type-based arm requires a sub-500 status (or none, for a
188
+ // locally built rejection): a gateway that fails the same type with a 5xx
189
+ // is an outage, and stays with the outage rules below.
190
+ const roleDiscriminationFailure =
191
+ includes(message, [
192
+ "invalid_request_error",
193
+ "expected 'user' | 'assistant'",
194
+ 'expected "user" | "assistant"',
195
+ "invalid discriminator value",
196
+ ]) || /messages\.\d+\.role/i.test(input.message)
197
+ const promptRejected =
198
+ roleDiscriminationFailure ||
199
+ ((type.includes("invalid-request") || type.includes("invalid_request")) &&
200
+ (input.status === undefined || input.status < 500))
201
+
202
+ if (promptRejected) {
203
+ return {
204
+ class: "prompt_rejected",
205
+ retryable: false,
206
+ capabilityFailure: false,
207
+ escalate: false,
208
+ cooldownMs: 5 * 60_000,
209
+ reason:
210
+ "The provider rejected the request shape before generating anything, so retrying cannot help. The router repairs the prompt or fails the turn over to the next best choice instead.",
211
+ // The model is healthy; the prompt shape was the problem. With prompt
212
+ // repair this class should not fire at all, and when it does the
213
+ // session's prompt shape, not the model, is what changed.
214
+ countAgainstModel: false,
215
+ providerScoped: false,
216
+ }
217
+ }
218
+
219
+ // A stream that starts and then dies reports HTTP 200 with a provider
220
+ // error inside the stream body, so the status code alone never identifies
221
+ // it. OpenCode surfaces these as a `provider.*` error type, most often
222
+ // `provider.internal` (an engine fault) or `provider.invalid-output` (a
223
+ // stream that ended without a finish reason).
224
+ const providerType = type.startsWith("provider.") || type.includes("invalidprovideroutput")
225
+ const streamFailure =
226
+ providerType ||
227
+ includes(message, [
228
+ "streaming response failed",
229
+ "enginecore encountered an issue",
230
+ "server_error",
231
+ "provider.internal",
232
+ "invalid-output",
233
+ "invalidprovideroutput",
234
+ "internal server error",
235
+ "upstream error",
236
+ "upstream failure",
237
+ "incomplete stream",
238
+ "stream closed",
239
+ "stream ended unexpectedly",
240
+ "stream ended without finish_reason",
241
+ "without finish_reason",
242
+ "connection closed",
243
+ "unexpected eof",
244
+ ]) ||
245
+ (/\[\s*5\d\d\s*\]/.test(message) && includes(message, ["stream", "response", "engine"]))
246
+
247
+ // A billing-scope resolution failure happens in the provider's own billing
248
+ // path before any model is selected. It would hit every model on that
249
+ // provider identically, so charging it to whichever model happened to be
250
+ // serving misattributes a provider-wide fault to one model and penalises
251
+ // a model that did nothing wrong.
252
+ const providerScopedBillingFault =
253
+ includes(message, [
254
+ "workspace_billing_scope_unavailable",
255
+ "billing_scope_unavailable",
256
+ "resolve billing scope",
257
+ "billing scope unavailable",
258
+ // OpenDesign evaluates the account's plan membership before it serves
259
+ // any request. Failing that is also a provider-side fault.
260
+ "membership entitlement",
261
+ "evaluate membership",
262
+ ]) || type.includes("billing_scope_unavailable")
263
+
264
+ // Capacity exhaustion resembles the billing-scope case above in one respect
265
+ // and differs in another, so it gets its own flag rather than reusing that
266
+ // one. Like the billing fault it happens at the gateway before a model does
267
+ // any work, so it is nothing to hold against the model's ability. Unlike the
268
+ // billing fault, the endpoint really did refuse this request, so it must be
269
+ // stepped away from at once.
270
+ //
271
+ // This matters because the alternative is worse than useless. When the
272
+ // gateway is saturated the retry hook can only wait and try again (the host
273
+ // exposes no way to change model mid-turn), so a fault that never reaches
274
+ // the provider means every retry and every following prompt lands on a
275
+ // gateway that is still refusing work.
276
+ //
277
+ // Deliberately narrow. Generic "overloaded", 5xx and stream failures stay
278
+ // model-scoped: those can be a single bad route, and holding an entire
279
+ // healthy provider back over one flaky model would be its own kind of error.
280
+ const upstreamSaturation =
281
+ includes(message, [
282
+ // The gateway's own advice names the provider as the problem.
283
+ "alternative llm api service providers",
284
+ "service is too busy",
285
+ // Capacity refusals raised by the admission layer.
286
+ "ttft_admission_refused",
287
+ "admission refused",
288
+ "at capacity",
289
+ ]) || (type.includes("upstream") && includes(message, ["busy", "capacity"]))
290
+
291
+ if (
292
+ status === 502 ||
293
+ status === 503 ||
294
+ status === 504 ||
295
+ streamFailure ||
296
+ includes(message, [
297
+ "overloaded",
298
+ "temporarily unavailable",
299
+ "upstream",
300
+ "service unavailable",
301
+ "provider outage",
302
+ "bad gateway",
303
+ "ttft_admission_refused",
304
+ "admission refused",
305
+ "at capacity",
306
+ ])
307
+ ) {
308
+ return {
309
+ class: "provider_outage",
310
+ retryable: true,
311
+ capabilityFailure: false,
312
+ escalate: false,
313
+ cooldownMs: 5 * 60_000,
314
+ reason: providerScopedBillingFault
315
+ ? "The provider's own pre-request path failed before a model was selected. This is provider-scoped, not a fault of the serving model, so it is not counted against the model's reliability."
316
+ : upstreamSaturation
317
+ ? "The provider's gateway is saturated. Every model behind it is affected, so the provider is counted as failing, and the endpoint that actually refused is held back too."
318
+ : "Provider capacity or outage; retry shortly or fail over. A stronger model would hit the same capacity limit, so no escalation is warranted.",
319
+ countAgainstModel: !providerScopedBillingFault,
320
+ providerScoped: providerScopedBillingFault,
321
+ ...(upstreamSaturation && !providerScopedBillingFault ? { saturation: true } : {}),
322
+ }
323
+ }
324
+
325
+ if (includes(message, ["timeout", "timed out", "econnreset", "econnrefused", "fetch failed", "socket", "network", "dns"])) {
326
+ return {
327
+ class: "network",
328
+ retryable: true,
329
+ capabilityFailure: false,
330
+ escalate: false,
331
+ cooldownMs: 60_000,
332
+ reason: "Network failure; retry with backoff. This is not evidence that a stronger model is needed.",
333
+ countAgainstModel: true,
334
+ providerScoped: false,
335
+ }
336
+ }
337
+
338
+ if (includes(message, ["tool", "shell command", "command failed", "exit code", "enoent", "permission denied"]) && type.includes("tool")) {
339
+ return {
340
+ class: "tool",
341
+ retryable: false,
342
+ capabilityFailure: false,
343
+ escalate: false,
344
+ cooldownMs: 0,
345
+ reason: "A tool or command failed; fix the tool, dependency, or permission issue rather than escalating the model.",
346
+ countAgainstModel: false,
347
+ providerScoped: false,
348
+ }
349
+ }
350
+
351
+ if (includes(message, ["invalid request", "bad request", "unsupported parameter", "malformed"])) {
352
+ return {
353
+ class: "invalid_request",
354
+ retryable: false,
355
+ capabilityFailure: false,
356
+ escalate: false,
357
+ cooldownMs: 5 * 60_000,
358
+ reason: "The request shape was invalid; fix the request or provider compatibility instead of escalating.",
359
+ countAgainstModel: true,
360
+ providerScoped: false,
361
+ }
362
+ }
363
+
364
+ const capabilityFailures = session?.failures.filter((failure) => failure.class === "capability").length ?? 0
365
+ if (
366
+ includes(message, ["cannot complete", "unable to complete", "gave up", "i don't know how", "not able to", "incomplete", "failed to implement"]) ||
367
+ (type.includes("model") && capabilityFailures >= 1)
368
+ ) {
369
+ return {
370
+ class: "capability",
371
+ retryable: false,
372
+ capabilityFailure: true,
373
+ escalate: capabilityFailures < 1,
374
+ cooldownMs: 0,
375
+ reason: "Possible capability failure; escalate one tier only after evidence, not after a single ambiguous error.",
376
+ countAgainstModel: true,
377
+ providerScoped: false,
378
+ }
379
+ }
380
+
381
+ return {
382
+ class: "unknown",
383
+ retryable: false,
384
+ capabilityFailure: false,
385
+ escalate: false,
386
+ cooldownMs: 30_000,
387
+ reason: "Unclassified failure; do not escalate automatically without evidence.",
388
+ countAgainstModel: false,
389
+ providerScoped: false,
390
+ }
391
+ }
392
+
393
+ export const failureCooldown = (assessment: FailureAssessment) => assessment.cooldownMs
394
+
395
+ export const shouldAvoidProvider = (assessment: FailureAssessment) =>
396
+ assessment.class === "auth" || assessment.class === "rate_limit" || assessment.class === "provider_outage" || assessment.class === "network"
397
+
398
+ export const shouldEscalate = (assessment: FailureAssessment) => assessment.escalate && assessment.capabilityFailure