cartethyia 2.7.1 → 2.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard/assets/{BannedPage-D-J75hBM.js → BannedPage-XaUmiBrC.js} +1 -1
- package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-CVe6RaBH.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-Dd8ZSamp.js → CliToolsPage-0XUo4UMn.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-QoJ6foxo.js → CombosPage-CXGS_Ct-.js} +1 -1
- package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-DpNRay4C.js → ConsoleLogPage-DGnGNYnM.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-CKUFTcW4.js → CustomizationPage-DAG5J9EY.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-CzSLiDzr.js → HealthEventsModal-BKtAPYVF.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DubdQcvX.js → LoginPage-BdmRLnq2.js} +1 -1
- package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-DuWzAQFL.js → OverviewPage-CqMxibJB.js} +1 -1
- package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
- package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-Bwotu8UJ.js → ProvidersPage-B9uWu4Nz.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-D7hpWbg9.js → ProxyPage-BoWOhpDO.js} +1 -1
- package/dist/dashboard/assets/{ProxyPage-D7hpWbg9.js.gz → ProxyPage-BoWOhpDO.js.gz} +0 -0
- package/dist/dashboard/assets/{QuotaPage-ATpaxrbl.js → QuotaPage-7P2n--Fv.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-CETophpx.js → SettingsPage-CxZ1fTkN.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-BdgECjqq.js → SetupPage-Co12Zzaw.js} +1 -1
- package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-CAK-EN9z.js → SortableList-Dqf4zpS-.js} +1 -1
- package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-B7W2_RhT.js → StudioPage-Byj2lAEm.js} +1 -1
- package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-6T4EFDjW.js → UsagePage-CTkLAuRc.js} +1 -1
- package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BLBLxX0h.js → entry-ClCMCWWQ.js} +3 -3
- package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
- package/dist/dashboard/assets/{formatters-mpBPqAQ8.js → formatters-CWWZ-uCZ.js} +1 -1
- package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
- package/dist/dashboard/assets/{index-D6QzEC_n.js → index-DFVFrnWW.js} +2 -2
- package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
- package/dist/dashboard/assets/{select-Cj5Nw43g.js → select-DBNVLdUu.js} +1 -1
- package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +22 -22
- package/package.json +1 -1
- package/src/console/cli-tools/contracts.ts +10 -4
- package/src/network/outbound-fetch.ts +281 -226
- package/src/network/pool/agent.ts +7 -0
- package/src/providers/default-registry.ts +1 -0
- package/src/providers/integrations/ollamacloud-models.ts +50 -0
- package/src/providers/integrations/qoder/qoder-oauth.ts +12 -1
- package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
- package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
- package/src/providers/integrations/qoder/qoder.ts +1373 -1641
- package/src/providers/operations/account-error-classification.ts +1684 -1660
- package/src/providers/operations/account-health-service.ts +621 -621
- package/src/providers/operations/provider-catalog-service.ts +11 -1
- package/src/providers/provider-contracts.ts +7 -2
- package/src/security/cli-client-fingerprint.ts +9 -2
- package/src/transport/dispatch/empty-stream.ts +224 -224
- package/src/transport/failure-policy.ts +33 -0
- package/src/transport/routing/route-catalog.ts +34 -12
- package/src/transport/routing/router.ts +28 -0
- package/src/transport/surface/responses/encode.ts +2 -3
- package/src/transport/surface/responses/parse.ts +49 -7
- package/src/types/undici.d.ts +33 -0
- package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +0 -4
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
- package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
- package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
- package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
|
@@ -1,621 +1,621 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { healthEventBuffer } from "../../persistence/health-event-flush";
|
|
3
|
-
import { and, count, desc, eq, inArray, isNotNull, lte, sql } from "drizzle-orm";
|
|
4
|
-
import { affectedRows, pruneExpiredCooldowns } from "../../persistence/dialect";
|
|
5
|
-
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
6
|
-
import { healthEvents, providerAccounts } from "../../persistence/schema";
|
|
7
|
-
import {
|
|
8
|
-
COOLDOWN_RECOVERY_BATCH,
|
|
9
|
-
accountDeadlineMs,
|
|
10
|
-
classifyAccountError,
|
|
11
|
-
escalateFallbackCooldown,
|
|
12
|
-
latestDeadlineMs,
|
|
13
|
-
unsupportedModelCooldownMs,
|
|
14
|
-
} from "./account-error-classification";
|
|
15
|
-
import type { AccountErrorClassification, AccountFailureEvidence } from "./account-error-classification";
|
|
16
|
-
|
|
17
|
-
export { classifyAccountError, unsupportedModelCooldownMs } from "./account-error-classification";
|
|
18
|
-
export type {
|
|
19
|
-
AccountErrorCategory,
|
|
20
|
-
AccountErrorClassification,
|
|
21
|
-
AccountFailureEvidence,
|
|
22
|
-
AccountFailureOrigin,
|
|
23
|
-
AccountFailureScope,
|
|
24
|
-
} from "./account-error-classification";
|
|
25
|
-
|
|
26
|
-
export interface AccountHealthEventRecord {
|
|
27
|
-
readonly id: string;
|
|
28
|
-
readonly accountId: string;
|
|
29
|
-
readonly fromStatus: string | null;
|
|
30
|
-
readonly toStatus: string;
|
|
31
|
-
readonly reason: string | null;
|
|
32
|
-
readonly errorCategory: string | null;
|
|
33
|
-
readonly modelId: string | null;
|
|
34
|
-
readonly createdAt: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Persists one classified failure to the account row and appends a health event. */
|
|
38
|
-
export async function recordAccountFailure(
|
|
39
|
-
db: CartethyiaDatabase,
|
|
40
|
-
accountId: string,
|
|
41
|
-
error: unknown,
|
|
42
|
-
options?: AccountFailureEvidence,
|
|
43
|
-
): Promise<AccountErrorClassification | null> {
|
|
44
|
-
const classification = classifyAccountError(error, options);
|
|
45
|
-
/**
|
|
46
|
-
* Logged even when the classification does not mutate the account.
|
|
47
|
-
*
|
|
48
|
-
* `rate_limit_transient` seen on a stream frame carries no credential
|
|
49
|
-
* detail, so it classifies at provider scope and `mutatesAccount` is false
|
|
50
|
-
* — nothing is cooled and the account stays `active`. That part is correct:
|
|
51
|
-
* the upstream's own remedy ("switch to the other models") only works if the
|
|
52
|
-
* account keeps serving. But this returned without writing anything, so a
|
|
53
|
-
* rate-limited request showed up in the request log while the account's
|
|
54
|
-
* health log stayed empty — a symptom with no recorded cause.
|
|
55
|
-
*
|
|
56
|
-
* The row is the whole point of this branch: it records the failure without
|
|
57
|
-
* moving the account. Both status sides are the classification's own, so the
|
|
58
|
-
* dialog names the cause without claiming a transition that never happened.
|
|
59
|
-
*/
|
|
60
|
-
if (!classification.mutatesAccount) {
|
|
61
|
-
void writeHealthEvent(db, {
|
|
62
|
-
entityKind: "account",
|
|
63
|
-
accountId,
|
|
64
|
-
fromStatus: classification.status,
|
|
65
|
-
toStatus: classification.status,
|
|
66
|
-
reason: classification.reason,
|
|
67
|
-
errorCategory: classification.category,
|
|
68
|
-
modelId: options?.modelId ?? null,
|
|
69
|
-
createdAt: new Date(),
|
|
70
|
-
}).catch(() => undefined);
|
|
71
|
-
return classification;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const persist = async (client: CartethyiaDatabase): Promise<AccountErrorClassification | null> => {
|
|
75
|
-
// Projected, not a bare `select()`: this runs on *every* attempt outcome,
|
|
76
|
-
// and a full row drags `credential_ciphertext` (the largest thing stored
|
|
77
|
-
// per account) and `auth_state` over the wire to read a status and a
|
|
78
|
-
// counter. `RouteCatalogRepository` already projects for the same reason.
|
|
79
|
-
const accountRows = await client
|
|
80
|
-
.select({
|
|
81
|
-
status: providerAccounts.status,
|
|
82
|
-
staticToken: providerAccounts.staticToken,
|
|
83
|
-
consecutiveFailures: providerAccounts.consecutiveFailures,
|
|
84
|
-
modelCooldowns: providerAccounts.modelCooldowns,
|
|
85
|
-
// Read only for the unsupported-model ladder; NULL means "never
|
|
86
|
-
// counted", which is the common case and must stay cheap.
|
|
87
|
-
modelBlockHits: providerAccounts.modelBlockHits,
|
|
88
|
-
cooldownUntil: providerAccounts.cooldownUntil,
|
|
89
|
-
})
|
|
90
|
-
.from(providerAccounts)
|
|
91
|
-
.where(eq(providerAccounts.id, accountId))
|
|
92
|
-
.limit(1);
|
|
93
|
-
const account = accountRows[0];
|
|
94
|
-
if (!account || account.status === "disabled") return null;
|
|
95
|
-
|
|
96
|
-
// Re-classify with the account's own static-token flag. The caller's
|
|
97
|
-
// evidence is request-scoped and does not carry it, but an auth rejection
|
|
98
|
-
// against a static token must cool the account down rather than park it —
|
|
99
|
-
// there is no refresh to run, and the token may still be valid.
|
|
100
|
-
const effective = account.staticToken
|
|
101
|
-
? classifyAccountError(error, { ...options, staticToken: true })
|
|
102
|
-
: classification;
|
|
103
|
-
|
|
104
|
-
const fromStatus = account.status;
|
|
105
|
-
const now = new Date();
|
|
106
|
-
|
|
107
|
-
const modelId = options?.modelId;
|
|
108
|
-
// Every category here is a verdict about one (account, model) pair rather
|
|
109
|
-
// than about the credential, so all three back off that pair and leave the
|
|
110
|
-
// account routable for every other model. `model_unreachable` joins the two
|
|
111
|
-
// throttles on the same reasoning: an upstream that reports the model does
|
|
112
|
-
// not exist for this account will report it again on the next request.
|
|
113
|
-
const isThrottle =
|
|
114
|
-
effective.category === "rate_limit_transient" ||
|
|
115
|
-
effective.category === "model_capacity" ||
|
|
116
|
-
effective.category === "model_unreachable";
|
|
117
|
-
const shouldModelCooldown = modelId !== undefined && isThrottle;
|
|
118
|
-
// The account-wide streak, escalated for a provider-stated window. Read once
|
|
119
|
-
// so the model-scoped and account-wide paths below cannot disagree about it:
|
|
120
|
-
// this is the count *before* this failure, which the UPDATE below increments.
|
|
121
|
-
const streak = Math.max(0, account.consecutiveFailures ?? 0);
|
|
122
|
-
const escalatedCooldownMs = escalateFallbackCooldown(
|
|
123
|
-
effective.cooldownMs,
|
|
124
|
-
effective.providerStated === true,
|
|
125
|
-
streak,
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
// The unsupported-model ladder, computed before the branch: the classifier
|
|
129
|
-
// could only state the base window because the authoritative hit count
|
|
130
|
-
// lives on the row, and it is read here. `hits` is the new total for this
|
|
131
|
-
// pair, so the first report is 1 and gets the base window.
|
|
132
|
-
const blockHits = (account.modelBlockHits as Record<string, number> | null) ?? {};
|
|
133
|
-
const unsupportedHits =
|
|
134
|
-
effective.modelUnsupported === true && modelId !== undefined
|
|
135
|
-
? Math.max(1, (blockHits[modelId] ?? 0) + 1)
|
|
136
|
-
: undefined;
|
|
137
|
-
// An unsupported-model window is stated by *us*, not by the provider, so it
|
|
138
|
-
// would otherwise be multiplied a second time by `escalateFallbackCooldown`
|
|
139
|
-
// (which reads `consecutiveFailures`, an account-wide counter). The two
|
|
140
|
-
// ladders are different things: one counts how many times this pair was
|
|
141
|
-
// reported unsupported, the other how many failures this account produced.
|
|
142
|
-
const unsupportedCooldownMs =
|
|
143
|
-
unsupportedHits === undefined ? undefined : unsupportedModelCooldownMs(unsupportedHits);
|
|
144
|
-
|
|
145
|
-
if (shouldModelCooldown) {
|
|
146
|
-
const existing = (account.modelCooldowns as Record<string, string> | null) ?? {};
|
|
147
|
-
// Monotonic: never move an in-force window earlier (see `latestDeadlineMs`).
|
|
148
|
-
// A provider-stated deadline wins outright because it is already the later
|
|
149
|
-
// of the two whenever the provider really parked the pair longer.
|
|
150
|
-
const nextUntil = new Date(
|
|
151
|
-
latestDeadlineMs(
|
|
152
|
-
effective.retryAt?.getTime(),
|
|
153
|
-
Date.now() + (unsupportedCooldownMs ?? escalatedCooldownMs),
|
|
154
|
-
existing[modelId] ? Date.parse(existing[modelId]!) : undefined,
|
|
155
|
-
),
|
|
156
|
-
).toISOString();
|
|
157
|
-
const previousUntil = existing[modelId];
|
|
158
|
-
// A re-statement is not an event: what the audit row reports is the
|
|
159
|
-
// *entry* into a cooling episode, and the deadline itself is not a
|
|
160
|
-
// column — so a row can only ever say "this pair started cooling for
|
|
161
|
-
// this reason". The upstream answers the same "Try again in 11h" on
|
|
162
|
-
// every retry (and an unstated reset is recomputed from now each time),
|
|
163
|
-
// so writing per failure produced seven identical rows in twenty minutes
|
|
164
|
-
// and read as an account flapping when nothing had changed. The entry is
|
|
165
|
-
// still refreshed below — its error fields are what the dialog explains
|
|
166
|
-
// the cooldown with — while an entry that is still in force suppresses
|
|
167
|
-
// the duplicate row.
|
|
168
|
-
const alreadyCooling =
|
|
169
|
-
previousUntil !== undefined && new Date(previousUntil).getTime() > Date.now();
|
|
170
|
-
// `status`/`cooldownUntil` stay untouched: only this (account, model)
|
|
171
|
-
// pair backs off, and the account remains routable for every other model.
|
|
172
|
-
// The error fields ARE written, because they are what provider detail and
|
|
173
|
-
// the health dialog read to explain *why* a model is cooling — without
|
|
174
|
-
// them the row showed "1 model cooling" with no reason at all.
|
|
175
|
-
//
|
|
176
|
-
// The streak advances here too. Without it the most common throttle — a
|
|
177
|
-
// model-scoped rate limit — never escalated, because the counter was only
|
|
178
|
-
// incremented on the account-wide path below, and `consecutiveFailures`
|
|
179
|
-
// stays honest about what it always claimed to count: how many failures in
|
|
180
|
-
// a row this account has just produced.
|
|
181
|
-
await client
|
|
182
|
-
.update(providerAccounts)
|
|
183
|
-
.set({
|
|
184
|
-
modelCooldowns: { ...existing, [modelId]: nextUntil },
|
|
185
|
-
// Counted only for the unsupported-model ladder, and only when this
|
|
186
|
-
// failure is that verdict — so a rate limit or a capacity refusal
|
|
187
|
-
// never advances it. `model_cooldowns`' own shape is untouched, so
|
|
188
|
-
// routing and the console keep parsing it exactly as before.
|
|
189
|
-
...(unsupportedHits === undefined
|
|
190
|
-
? {}
|
|
191
|
-
: { modelBlockHits: { ...blockHits, [modelId]: unsupportedHits } }),
|
|
192
|
-
consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1`,
|
|
193
|
-
lastError: effective.reason,
|
|
194
|
-
lastErrorCategory: effective.category,
|
|
195
|
-
lastErrorAt: now,
|
|
196
|
-
})
|
|
197
|
-
.where(eq(providerAccounts.id, accountId));
|
|
198
|
-
if (!alreadyCooling) {
|
|
199
|
-
await writeHealthEvent(client, {
|
|
200
|
-
entityKind: "account",
|
|
201
|
-
accountId,
|
|
202
|
-
// The account's own status, on both sides: a model-scoped throttle
|
|
203
|
-
// never moved it, and writing the classification's status here
|
|
204
|
-
// claimed an `active → cooldown` transition that never happened — the
|
|
205
|
-
// dialog showed "Account status: ACTIVE" above a list of transitions
|
|
206
|
-
// to `cooldown`. The row still names the model and the reason, which
|
|
207
|
-
// is what makes it worth keeping.
|
|
208
|
-
fromStatus: fromStatus as "active" | "cooldown" | "disabled",
|
|
209
|
-
toStatus: fromStatus as "active" | "cooldown" | "disabled",
|
|
210
|
-
reason: effective.reason,
|
|
211
|
-
errorCategory: effective.category,
|
|
212
|
-
modelId: modelId ?? null,
|
|
213
|
-
createdAt: now,
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
// `status` and `cooldownUntil` are untouched on this path — only the
|
|
217
|
-
// (account, model) pair backed off — so the snapshot is still accurate
|
|
218
|
-
// and must not be dropped.
|
|
219
|
-
return { ...classification, routesVisible: false };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
await client
|
|
223
|
-
.update(providerAccounts)
|
|
224
|
-
.set({
|
|
225
|
-
status: effective.status,
|
|
226
|
-
consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1`,
|
|
227
|
-
lastError: effective.reason,
|
|
228
|
-
lastErrorCategory: effective.category,
|
|
229
|
-
lastErrorAt: now,
|
|
230
|
-
// Monotonic against the account's own in-force deadline, for the same
|
|
231
|
-
// reason as the model path: an unclassified 60-second failure must not
|
|
232
|
-
// release an account the provider parked for hours.
|
|
233
|
-
//
|
|
234
|
-
// A `0` duration contributes no candidate rather than stamping *now*
|
|
235
|
-
// (see `accountDeadlineMs`): the one state that asks for it is a
|
|
236
|
-
// terminal automatic disable, and an elapsed deadline there would hand
|
|
237
|
-
// the row straight back to the recovery sweep.
|
|
238
|
-
cooldownUntil: accountDeadlineMs([
|
|
239
|
-
effective.retryAt?.getTime(),
|
|
240
|
-
escalatedCooldownMs > 0 ? Date.now() + escalatedCooldownMs : undefined,
|
|
241
|
-
account.cooldownUntil?.getTime?.(),
|
|
242
|
-
]),
|
|
243
|
-
})
|
|
244
|
-
.where(eq(providerAccounts.id, accountId));
|
|
245
|
-
|
|
246
|
-
{
|
|
247
|
-
await writeHealthEvent(client, {
|
|
248
|
-
entityKind: "account",
|
|
249
|
-
accountId,
|
|
250
|
-
fromStatus: fromStatus as "active" | "cooldown" | "disabled",
|
|
251
|
-
toStatus: effective.status,
|
|
252
|
-
reason: effective.reason,
|
|
253
|
-
errorCategory: effective.category,
|
|
254
|
-
modelId: modelId ?? null,
|
|
255
|
-
createdAt: now,
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
// This path wrote `status` and `cooldownUntil`, both of which the snapshot
|
|
259
|
-
// projects, so routing has to see it.
|
|
260
|
-
return { ...classification, routesVisible: true };
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
try {
|
|
264
|
-
// Kept as a capability probe, not a correctness guard: this function is
|
|
265
|
-
// reachable with a partially-implemented database handle (the dispatch
|
|
266
|
-
// tests pass a narrow double), and a transaction is an optimization here,
|
|
267
|
-
// not a requirement — the same writes are correct without one.
|
|
268
|
-
if (typeof db.transaction === "function") {
|
|
269
|
-
return await db.transaction((tx) => persist(tx as CartethyiaDatabase));
|
|
270
|
-
}
|
|
271
|
-
return await persist(db);
|
|
272
|
-
} catch {
|
|
273
|
-
return null;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Clears failure state after a successful dispatch; true when the account changed. */
|
|
278
|
-
export async function recordAccountSuccess(
|
|
279
|
-
db: CartethyiaDatabase,
|
|
280
|
-
accountId: string,
|
|
281
|
-
): Promise<boolean> {
|
|
282
|
-
const mutate = async (client: CartethyiaDatabase): Promise<boolean> => {
|
|
283
|
-
// `recordAccountSuccess` runs on every successful dispatch, and for a
|
|
284
|
-
// healthy account `hadFailures` is false — the row was fetched purely to
|
|
285
|
-
// decide nothing. Projected so that no-op path does not pull the
|
|
286
|
-
// credential ciphertext across the wire.
|
|
287
|
-
const rows = await client
|
|
288
|
-
.select({
|
|
289
|
-
status: providerAccounts.status,
|
|
290
|
-
consecutiveFailures: providerAccounts.consecutiveFailures,
|
|
291
|
-
// Read only to decide whether there is anything to clear: the common
|
|
292
|
-
// case (no unsupported-model hit ever recorded) must not write.
|
|
293
|
-
modelBlockHits: providerAccounts.modelBlockHits,
|
|
294
|
-
})
|
|
295
|
-
.from(providerAccounts)
|
|
296
|
-
.where(eq(providerAccounts.id, accountId))
|
|
297
|
-
.limit(1);
|
|
298
|
-
const account = rows[0];
|
|
299
|
-
if (!account || account.status === "disabled") return false;
|
|
300
|
-
const fromStatus = account.status;
|
|
301
|
-
const hadFailures = (account.consecutiveFailures ?? 0) > 0 || account.status !== "active";
|
|
302
|
-
const now = new Date();
|
|
303
|
-
// The unsupported-model escalation restarts after a success: a model that
|
|
304
|
-
// just served is not "not supported", so the next report of it must start
|
|
305
|
-
// from the base window again rather than from wherever the ladder had
|
|
306
|
-
// reached. `modelCooldowns` is deliberately NOT cleared — an in-force
|
|
307
|
-
// window the provider stated survives, which is the existing behavior and
|
|
308
|
-
// the reference gateways' too.
|
|
309
|
-
const blockHits = account.modelBlockHits as Record<string, number> | null;
|
|
310
|
-
const hasBlockHits = blockHits !== null && Object.keys(blockHits).length > 0;
|
|
311
|
-
await client
|
|
312
|
-
.update(providerAccounts)
|
|
313
|
-
.set({
|
|
314
|
-
status: "active",
|
|
315
|
-
consecutiveFailures: 0,
|
|
316
|
-
cooldownUntil: null,
|
|
317
|
-
lastSuccessAt: now,
|
|
318
|
-
...(hadFailures ? { lastRecoveredAt: now } : {}),
|
|
319
|
-
...(hasBlockHits ? { modelBlockHits: {} } : {}),
|
|
320
|
-
})
|
|
321
|
-
.where(eq(providerAccounts.id, accountId));
|
|
322
|
-
if (hadFailures && typeof client.insert === "function") {
|
|
323
|
-
await client.insert(healthEvents).values({
|
|
324
|
-
id: randomUUID(),
|
|
325
|
-
entityKind: "account",
|
|
326
|
-
accountId,
|
|
327
|
-
fromStatus: fromStatus as "active" | "cooldown" | "disabled",
|
|
328
|
-
toStatus: "active",
|
|
329
|
-
reason: "Request dispatched successfully",
|
|
330
|
-
errorCategory: null,
|
|
331
|
-
createdAt: now,
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
return hadFailures;
|
|
335
|
-
};
|
|
336
|
-
try {
|
|
337
|
-
if (typeof db.transaction === "function") {
|
|
338
|
-
return await db.transaction((tx) => mutate(tx as CartethyiaDatabase));
|
|
339
|
-
}
|
|
340
|
-
return await mutate(db);
|
|
341
|
-
} catch {
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** Operator-initiated recovery: forces the account back to active and logs the transition. */
|
|
347
|
-
export async function recoverAccount(
|
|
348
|
-
db: CartethyiaDatabase,
|
|
349
|
-
accountId: string,
|
|
350
|
-
reason = "manual_operator_recovery",
|
|
351
|
-
): Promise<boolean> {
|
|
352
|
-
const recover = async (client: CartethyiaDatabase): Promise<boolean> => {
|
|
353
|
-
const accountRows = await client
|
|
354
|
-
.select()
|
|
355
|
-
.from(providerAccounts)
|
|
356
|
-
.where(eq(providerAccounts.id, accountId))
|
|
357
|
-
.limit(1);
|
|
358
|
-
const account = accountRows[0];
|
|
359
|
-
if (!account) return false;
|
|
360
|
-
const fromStatus = account.status;
|
|
361
|
-
const now = new Date();
|
|
362
|
-
await client
|
|
363
|
-
.update(providerAccounts)
|
|
364
|
-
.set({
|
|
365
|
-
status: "active",
|
|
366
|
-
consecutiveFailures: 0,
|
|
367
|
-
cooldownUntil: null,
|
|
368
|
-
lastRecoveredAt: now,
|
|
369
|
-
// Recovery is the operator asserting the account works now, so it must
|
|
370
|
-
// clear EVERY routing exclusion, not just the account-wide ones. A
|
|
371
|
-
// per-model cooldown entry excludes the (account, model) pair in
|
|
372
|
-
// `route-catalog.ts`, so leaving it behind kept the account blocked for
|
|
373
|
-
// that model through `/v1` even though the account read `active` and a
|
|
374
|
-
// direct probe (which addresses the account by id, bypassing routing
|
|
375
|
-
// eligibility) succeeded. Clearing the error fields for the same reason:
|
|
376
|
-
// a stale reason on a recovered row misreports the current state.
|
|
377
|
-
modelCooldowns: {},
|
|
378
|
-
lastError: null,
|
|
379
|
-
lastErrorCategory: null,
|
|
380
|
-
lastErrorAt: null,
|
|
381
|
-
})
|
|
382
|
-
.where(eq(providerAccounts.id, accountId));
|
|
383
|
-
{
|
|
384
|
-
await writeHealthEvent(client, {
|
|
385
|
-
entityKind: "account",
|
|
386
|
-
accountId,
|
|
387
|
-
fromStatus: fromStatus as "active" | "cooldown" | "disabled",
|
|
388
|
-
toStatus: "active",
|
|
389
|
-
reason,
|
|
390
|
-
errorCategory: null,
|
|
391
|
-
createdAt: now,
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
return true;
|
|
395
|
-
};
|
|
396
|
-
try {
|
|
397
|
-
if (typeof db.transaction === "function") {
|
|
398
|
-
return await db.transaction((tx) => recover(tx as CartethyiaDatabase));
|
|
399
|
-
}
|
|
400
|
-
return await recover(db);
|
|
401
|
-
} catch {
|
|
402
|
-
return false;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/** Recovers accounts whose cooldown elapsed and prunes expired per-model keys. */
|
|
407
|
-
export async function sweepExpiredCooldowns(db: CartethyiaDatabase): Promise<number> {
|
|
408
|
-
const recover = async (client: CartethyiaDatabase): Promise<number> => {
|
|
409
|
-
const now = new Date();
|
|
410
|
-
const nowIso = now.toISOString();
|
|
411
|
-
const expired = await client
|
|
412
|
-
.select({
|
|
413
|
-
id: providerAccounts.id,
|
|
414
|
-
status: providerAccounts.status,
|
|
415
|
-
})
|
|
416
|
-
.from(providerAccounts)
|
|
417
|
-
.where(
|
|
418
|
-
// An ordinary cooldown, recovered as soon as its window elapses.
|
|
419
|
-
and(
|
|
420
|
-
eq(providerAccounts.status, "cooldown"),
|
|
421
|
-
isNotNull(providerAccounts.cooldownUntil),
|
|
422
|
-
lte(providerAccounts.cooldownUntil, now),
|
|
423
|
-
),
|
|
424
|
-
)
|
|
425
|
-
// One pass recovers a bounded batch. The sweep runs every 30s, so a
|
|
426
|
-
// backlog larger than this drains over successive ticks instead of one
|
|
427
|
-
// statement covering every expired row — that statement holds its locks
|
|
428
|
-
// for as long as the backlog takes.
|
|
429
|
-
.limit(COOLDOWN_RECOVERY_BATCH);
|
|
430
|
-
if (expired.length > 0) {
|
|
431
|
-
await client
|
|
432
|
-
.update(providerAccounts)
|
|
433
|
-
.set({
|
|
434
|
-
status: "active",
|
|
435
|
-
consecutiveFailures: 0,
|
|
436
|
-
cooldownUntil: null,
|
|
437
|
-
lastRecoveredAt: now,
|
|
438
|
-
// Cleared with the state it described. A recovered row that kept
|
|
439
|
-
// `auth_invalidated` still rendered the console's "Re-login required"
|
|
440
|
-
// pill above an `active` status, so the account read healthy to
|
|
441
|
-
// routing and broken to the operator at the same time.
|
|
442
|
-
lastErrorCategory: null,
|
|
443
|
-
})
|
|
444
|
-
.where(inArray(providerAccounts.id, expired.map((account) => account.id)));
|
|
445
|
-
if (typeof client.insert === "function") {
|
|
446
|
-
for (let offset = 0; offset < expired.length; offset += 500) {
|
|
447
|
-
await client.insert(healthEvents).values(
|
|
448
|
-
expired.slice(offset, offset + 500).map((account) => ({
|
|
449
|
-
id: randomUUID(),
|
|
450
|
-
entityKind: "account" as const,
|
|
451
|
-
accountId: account.id,
|
|
452
|
-
fromStatus: account.status,
|
|
453
|
-
toStatus: "active" as const,
|
|
454
|
-
reason: "Cooldown period elapsed — auto-recovered",
|
|
455
|
-
errorCategory: null,
|
|
456
|
-
createdAt: now,
|
|
457
|
-
})),
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// Prune per-model cooldowns: remove entries whose ISO timestamp has passed.
|
|
464
|
-
//
|
|
465
|
-
// One set-based UPDATE rather than a SELECT followed by an UPDATE per row.
|
|
466
|
-
// The per-row form issued one statement per account holding any cooldown —
|
|
467
|
-
// on a deployment where many accounts are throttled at once that is the
|
|
468
|
-
// sweep's dominant cost, and it all runs inside this transaction, holding
|
|
469
|
-
// its locks for the whole pass. Recomputing the object in SQL does the same
|
|
470
|
-
// work in one statement.
|
|
471
|
-
//
|
|
472
|
-
// `jsonb_typeof(...) = 'object'` guards the shape: a malformed value must
|
|
473
|
-
// not abort the whole sweep. `IS DISTINCT FROM` keeps the update from
|
|
474
|
-
// rewriting rows it
|
|
475
|
-
// did not change, so the returned row count is the number of accounts whose
|
|
476
|
-
// cooldown set actually shrank — the same thing the loop counted.
|
|
477
|
-
const pruning = pruneExpiredCooldowns(sql`${providerAccounts.modelCooldowns}`, sql`${nowIso}`);
|
|
478
|
-
const pruned = await client
|
|
479
|
-
.update(providerAccounts)
|
|
480
|
-
.set({ modelCooldowns: pruning.value })
|
|
481
|
-
.where(pruning.changed);
|
|
482
|
-
return expired.length + affectedRows(pruned);
|
|
483
|
-
};
|
|
484
|
-
try {
|
|
485
|
-
if (typeof db.transaction === "function") {
|
|
486
|
-
return await db.transaction((tx) => recover(tx as CartethyiaDatabase));
|
|
487
|
-
}
|
|
488
|
-
return await recover(db);
|
|
489
|
-
} catch {
|
|
490
|
-
return 0;
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Single attempt health reporter: one home for the success/failure writes
|
|
496
|
-
* every dispatch attempt ends with. Success records recovery; failure
|
|
497
|
-
* records with caller-supplied evidence (origin/scope classification plus
|
|
498
|
-
* model id). A mutating write invalidates the route snapshot so the next
|
|
499
|
-
* plan sees fresh health. Never throws — health must not fail requests.
|
|
500
|
-
*/
|
|
501
|
-
export interface AttemptHealthReport {
|
|
502
|
-
readonly accountId: string | undefined;
|
|
503
|
-
readonly modelId?: string;
|
|
504
|
-
readonly error?: unknown;
|
|
505
|
-
readonly evidence?: AccountFailureEvidence;
|
|
506
|
-
readonly snapshotService?: { invalidate(): unknown };
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Writes one health audit row, off the request path when a buffer is installed.
|
|
511
|
-
*
|
|
512
|
-
* `health_events` is append-only and read by nobody in the request path, so it
|
|
513
|
-
* does not need to land before the request finishes — unlike the
|
|
514
|
-
* `provider_accounts` UPDATE beside it, which routing reads and which therefore
|
|
515
|
-
* stays synchronous and transactional.
|
|
516
|
-
*/
|
|
517
|
-
async function writeHealthEvent(
|
|
518
|
-
client: CartethyiaDatabase,
|
|
519
|
-
row: {
|
|
520
|
-
readonly entityKind: "account";
|
|
521
|
-
readonly accountId: string;
|
|
522
|
-
readonly fromStatus: string;
|
|
523
|
-
readonly toStatus: string;
|
|
524
|
-
readonly reason: string;
|
|
525
|
-
readonly errorCategory: string | null;
|
|
526
|
-
readonly modelId?: string | null;
|
|
527
|
-
readonly createdAt: Date;
|
|
528
|
-
},
|
|
529
|
-
): Promise<void> {
|
|
530
|
-
const buffer = healthEventBuffer();
|
|
531
|
-
if (buffer !== undefined) {
|
|
532
|
-
buffer.enqueue({ ...row, modelId: row.modelId ?? null });
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
if (typeof client.insert !== "function") return;
|
|
536
|
-
await client.insert(healthEvents).values({ id: randomUUID(), ...row } as never);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
export async function reportAttemptOutcome(
|
|
540
|
-
db: CartethyiaDatabase,
|
|
541
|
-
report: AttemptHealthReport,
|
|
542
|
-
): Promise<void> {
|
|
543
|
-
if (!report.accountId) return;
|
|
544
|
-
if (report.error === undefined) {
|
|
545
|
-
const recovered = await recordAccountSuccess(db, report.accountId).catch(() => false);
|
|
546
|
-
if (recovered) await report.snapshotService?.invalidate();
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
const classification = await recordAccountFailure(db, report.accountId, report.error, {
|
|
550
|
-
...report.evidence,
|
|
551
|
-
...(report.modelId === undefined ? {} : { modelId: report.modelId }),
|
|
552
|
-
}).catch(() => null);
|
|
553
|
-
// Gated on `routesVisible`, not on `mutatesAccount`: a failure that only
|
|
554
|
-
// cooled one (account, model) pair, or only advanced the streak, changed
|
|
555
|
-
// nothing routing reads. Invalidating for those rebuilt an 11-SELECT snapshot
|
|
556
|
-
// on the next request, and under a failure wave each rebuild fed the next
|
|
557
|
-
// invalidation.
|
|
558
|
-
if (classification?.routesVisible) await report.snapshotService?.invalidate();
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
export async function listAccountHealthEvents(
|
|
562
|
-
db: CartethyiaDatabase,
|
|
563
|
-
accountId: string,
|
|
564
|
-
limit = 50,
|
|
565
|
-
): Promise<readonly AccountHealthEventRecord[]> {
|
|
566
|
-
try {
|
|
567
|
-
const rows = await db
|
|
568
|
-
.select()
|
|
569
|
-
.from(healthEvents)
|
|
570
|
-
.where(and(eq(healthEvents.entityKind, "account"), eq(healthEvents.accountId, accountId)))
|
|
571
|
-
.orderBy(desc(healthEvents.createdAt))
|
|
572
|
-
.limit(limit);
|
|
573
|
-
|
|
574
|
-
return rows.map((r) => ({
|
|
575
|
-
id: r.id,
|
|
576
|
-
accountId: r.accountId as string,
|
|
577
|
-
fromStatus: r.fromStatus,
|
|
578
|
-
toStatus: r.toStatus,
|
|
579
|
-
modelId: r.modelId ?? null,
|
|
580
|
-
reason: r.reason,
|
|
581
|
-
errorCategory: r.errorCategory,
|
|
582
|
-
createdAt: r.createdAt.toISOString(),
|
|
583
|
-
}));
|
|
584
|
-
} catch {
|
|
585
|
-
return [];
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/** Every stored row across accounts and pools — the size the purge offers to delete. */
|
|
590
|
-
export async function countAllHealthEvents(db: CartethyiaDatabase): Promise<number> {
|
|
591
|
-
try {
|
|
592
|
-
const rows = await db.select({ total: count() }).from(healthEvents);
|
|
593
|
-
return rows[0]?.total ?? 0;
|
|
594
|
-
} catch {
|
|
595
|
-
return 0;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* Deletes stored health & error rows.
|
|
601
|
-
*
|
|
602
|
-
* Unlike the read above, a failed delete surfaces: an operator who purged
|
|
603
|
-
* believes the trail is gone, so "it failed" must not read as "it succeeded
|
|
604
|
-
* with zero rows". Callers own the flush-buffer barrier (`drain`) so a queued
|
|
605
|
-
* write cannot land after these rows are gone.
|
|
606
|
-
*/
|
|
607
|
-
export async function deleteAccountHealthEvents(
|
|
608
|
-
db: CartethyiaDatabase,
|
|
609
|
-
accountId: string,
|
|
610
|
-
): Promise<number> {
|
|
611
|
-
const rows = await db
|
|
612
|
-
.delete(healthEvents)
|
|
613
|
-
.where(and(eq(healthEvents.entityKind, "account"), eq(healthEvents.accountId, accountId)))
|
|
614
|
-
.returning({ id: healthEvents.id });
|
|
615
|
-
return rows.length;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
export async function deleteAllHealthEvents(db: CartethyiaDatabase): Promise<number> {
|
|
619
|
-
const rows = await db.delete(healthEvents).returning({ id: healthEvents.id });
|
|
620
|
-
return rows.length;
|
|
621
|
-
}
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { healthEventBuffer } from "../../persistence/health-event-flush";
|
|
3
|
+
import { and, count, desc, eq, inArray, isNotNull, lte, sql } from "drizzle-orm";
|
|
4
|
+
import { affectedRows, pruneExpiredCooldowns } from "../../persistence/dialect";
|
|
5
|
+
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
6
|
+
import { healthEvents, providerAccounts } from "../../persistence/schema";
|
|
7
|
+
import {
|
|
8
|
+
COOLDOWN_RECOVERY_BATCH,
|
|
9
|
+
accountDeadlineMs,
|
|
10
|
+
classifyAccountError,
|
|
11
|
+
escalateFallbackCooldown,
|
|
12
|
+
latestDeadlineMs,
|
|
13
|
+
unsupportedModelCooldownMs,
|
|
14
|
+
} from "./account-error-classification";
|
|
15
|
+
import type { AccountErrorClassification, AccountFailureEvidence } from "./account-error-classification";
|
|
16
|
+
|
|
17
|
+
export { classifyAccountError, unsupportedModelCooldownMs } from "./account-error-classification";
|
|
18
|
+
export type {
|
|
19
|
+
AccountErrorCategory,
|
|
20
|
+
AccountErrorClassification,
|
|
21
|
+
AccountFailureEvidence,
|
|
22
|
+
AccountFailureOrigin,
|
|
23
|
+
AccountFailureScope,
|
|
24
|
+
} from "./account-error-classification";
|
|
25
|
+
|
|
26
|
+
export interface AccountHealthEventRecord {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly accountId: string;
|
|
29
|
+
readonly fromStatus: string | null;
|
|
30
|
+
readonly toStatus: string;
|
|
31
|
+
readonly reason: string | null;
|
|
32
|
+
readonly errorCategory: string | null;
|
|
33
|
+
readonly modelId: string | null;
|
|
34
|
+
readonly createdAt: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Persists one classified failure to the account row and appends a health event. */
|
|
38
|
+
export async function recordAccountFailure(
|
|
39
|
+
db: CartethyiaDatabase,
|
|
40
|
+
accountId: string,
|
|
41
|
+
error: unknown,
|
|
42
|
+
options?: AccountFailureEvidence,
|
|
43
|
+
): Promise<AccountErrorClassification | null> {
|
|
44
|
+
const classification = classifyAccountError(error, options);
|
|
45
|
+
/**
|
|
46
|
+
* Logged even when the classification does not mutate the account.
|
|
47
|
+
*
|
|
48
|
+
* `rate_limit_transient` seen on a stream frame carries no credential
|
|
49
|
+
* detail, so it classifies at provider scope and `mutatesAccount` is false
|
|
50
|
+
* — nothing is cooled and the account stays `active`. That part is correct:
|
|
51
|
+
* the upstream's own remedy ("switch to the other models") only works if the
|
|
52
|
+
* account keeps serving. But this returned without writing anything, so a
|
|
53
|
+
* rate-limited request showed up in the request log while the account's
|
|
54
|
+
* health log stayed empty — a symptom with no recorded cause.
|
|
55
|
+
*
|
|
56
|
+
* The row is the whole point of this branch: it records the failure without
|
|
57
|
+
* moving the account. Both status sides are the classification's own, so the
|
|
58
|
+
* dialog names the cause without claiming a transition that never happened.
|
|
59
|
+
*/
|
|
60
|
+
if (!classification.mutatesAccount) {
|
|
61
|
+
void writeHealthEvent(db, {
|
|
62
|
+
entityKind: "account",
|
|
63
|
+
accountId,
|
|
64
|
+
fromStatus: classification.status,
|
|
65
|
+
toStatus: classification.status,
|
|
66
|
+
reason: classification.reason,
|
|
67
|
+
errorCategory: classification.category,
|
|
68
|
+
modelId: options?.modelId ?? null,
|
|
69
|
+
createdAt: new Date(),
|
|
70
|
+
}).catch(() => undefined);
|
|
71
|
+
return classification;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const persist = async (client: CartethyiaDatabase): Promise<AccountErrorClassification | null> => {
|
|
75
|
+
// Projected, not a bare `select()`: this runs on *every* attempt outcome,
|
|
76
|
+
// and a full row drags `credential_ciphertext` (the largest thing stored
|
|
77
|
+
// per account) and `auth_state` over the wire to read a status and a
|
|
78
|
+
// counter. `RouteCatalogRepository` already projects for the same reason.
|
|
79
|
+
const accountRows = await client
|
|
80
|
+
.select({
|
|
81
|
+
status: providerAccounts.status,
|
|
82
|
+
staticToken: providerAccounts.staticToken,
|
|
83
|
+
consecutiveFailures: providerAccounts.consecutiveFailures,
|
|
84
|
+
modelCooldowns: providerAccounts.modelCooldowns,
|
|
85
|
+
// Read only for the unsupported-model ladder; NULL means "never
|
|
86
|
+
// counted", which is the common case and must stay cheap.
|
|
87
|
+
modelBlockHits: providerAccounts.modelBlockHits,
|
|
88
|
+
cooldownUntil: providerAccounts.cooldownUntil,
|
|
89
|
+
})
|
|
90
|
+
.from(providerAccounts)
|
|
91
|
+
.where(eq(providerAccounts.id, accountId))
|
|
92
|
+
.limit(1);
|
|
93
|
+
const account = accountRows[0];
|
|
94
|
+
if (!account || account.status === "disabled") return null;
|
|
95
|
+
|
|
96
|
+
// Re-classify with the account's own static-token flag. The caller's
|
|
97
|
+
// evidence is request-scoped and does not carry it, but an auth rejection
|
|
98
|
+
// against a static token must cool the account down rather than park it —
|
|
99
|
+
// there is no refresh to run, and the token may still be valid.
|
|
100
|
+
const effective = account.staticToken
|
|
101
|
+
? classifyAccountError(error, { ...options, staticToken: true })
|
|
102
|
+
: classification;
|
|
103
|
+
|
|
104
|
+
const fromStatus = account.status;
|
|
105
|
+
const now = new Date();
|
|
106
|
+
|
|
107
|
+
const modelId = options?.modelId;
|
|
108
|
+
// Every category here is a verdict about one (account, model) pair rather
|
|
109
|
+
// than about the credential, so all three back off that pair and leave the
|
|
110
|
+
// account routable for every other model. `model_unreachable` joins the two
|
|
111
|
+
// throttles on the same reasoning: an upstream that reports the model does
|
|
112
|
+
// not exist for this account will report it again on the next request.
|
|
113
|
+
const isThrottle =
|
|
114
|
+
effective.category === "rate_limit_transient" ||
|
|
115
|
+
effective.category === "model_capacity" ||
|
|
116
|
+
effective.category === "model_unreachable";
|
|
117
|
+
const shouldModelCooldown = modelId !== undefined && isThrottle;
|
|
118
|
+
// The account-wide streak, escalated for a provider-stated window. Read once
|
|
119
|
+
// so the model-scoped and account-wide paths below cannot disagree about it:
|
|
120
|
+
// this is the count *before* this failure, which the UPDATE below increments.
|
|
121
|
+
const streak = Math.max(0, account.consecutiveFailures ?? 0);
|
|
122
|
+
const escalatedCooldownMs = escalateFallbackCooldown(
|
|
123
|
+
effective.cooldownMs,
|
|
124
|
+
effective.providerStated === true,
|
|
125
|
+
streak,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// The unsupported-model ladder, computed before the branch: the classifier
|
|
129
|
+
// could only state the base window because the authoritative hit count
|
|
130
|
+
// lives on the row, and it is read here. `hits` is the new total for this
|
|
131
|
+
// pair, so the first report is 1 and gets the base window.
|
|
132
|
+
const blockHits = (account.modelBlockHits as Record<string, number> | null) ?? {};
|
|
133
|
+
const unsupportedHits =
|
|
134
|
+
effective.modelUnsupported === true && modelId !== undefined
|
|
135
|
+
? Math.max(1, (blockHits[modelId] ?? 0) + 1)
|
|
136
|
+
: undefined;
|
|
137
|
+
// An unsupported-model window is stated by *us*, not by the provider, so it
|
|
138
|
+
// would otherwise be multiplied a second time by `escalateFallbackCooldown`
|
|
139
|
+
// (which reads `consecutiveFailures`, an account-wide counter). The two
|
|
140
|
+
// ladders are different things: one counts how many times this pair was
|
|
141
|
+
// reported unsupported, the other how many failures this account produced.
|
|
142
|
+
const unsupportedCooldownMs =
|
|
143
|
+
unsupportedHits === undefined ? undefined : unsupportedModelCooldownMs(unsupportedHits);
|
|
144
|
+
|
|
145
|
+
if (shouldModelCooldown) {
|
|
146
|
+
const existing = (account.modelCooldowns as Record<string, string> | null) ?? {};
|
|
147
|
+
// Monotonic: never move an in-force window earlier (see `latestDeadlineMs`).
|
|
148
|
+
// A provider-stated deadline wins outright because it is already the later
|
|
149
|
+
// of the two whenever the provider really parked the pair longer.
|
|
150
|
+
const nextUntil = new Date(
|
|
151
|
+
latestDeadlineMs(
|
|
152
|
+
effective.retryAt?.getTime(),
|
|
153
|
+
Date.now() + (unsupportedCooldownMs ?? escalatedCooldownMs),
|
|
154
|
+
existing[modelId] ? Date.parse(existing[modelId]!) : undefined,
|
|
155
|
+
),
|
|
156
|
+
).toISOString();
|
|
157
|
+
const previousUntil = existing[modelId];
|
|
158
|
+
// A re-statement is not an event: what the audit row reports is the
|
|
159
|
+
// *entry* into a cooling episode, and the deadline itself is not a
|
|
160
|
+
// column — so a row can only ever say "this pair started cooling for
|
|
161
|
+
// this reason". The upstream answers the same "Try again in 11h" on
|
|
162
|
+
// every retry (and an unstated reset is recomputed from now each time),
|
|
163
|
+
// so writing per failure produced seven identical rows in twenty minutes
|
|
164
|
+
// and read as an account flapping when nothing had changed. The entry is
|
|
165
|
+
// still refreshed below — its error fields are what the dialog explains
|
|
166
|
+
// the cooldown with — while an entry that is still in force suppresses
|
|
167
|
+
// the duplicate row.
|
|
168
|
+
const alreadyCooling =
|
|
169
|
+
previousUntil !== undefined && new Date(previousUntil).getTime() > Date.now();
|
|
170
|
+
// `status`/`cooldownUntil` stay untouched: only this (account, model)
|
|
171
|
+
// pair backs off, and the account remains routable for every other model.
|
|
172
|
+
// The error fields ARE written, because they are what provider detail and
|
|
173
|
+
// the health dialog read to explain *why* a model is cooling — without
|
|
174
|
+
// them the row showed "1 model cooling" with no reason at all.
|
|
175
|
+
//
|
|
176
|
+
// The streak advances here too. Without it the most common throttle — a
|
|
177
|
+
// model-scoped rate limit — never escalated, because the counter was only
|
|
178
|
+
// incremented on the account-wide path below, and `consecutiveFailures`
|
|
179
|
+
// stays honest about what it always claimed to count: how many failures in
|
|
180
|
+
// a row this account has just produced.
|
|
181
|
+
await client
|
|
182
|
+
.update(providerAccounts)
|
|
183
|
+
.set({
|
|
184
|
+
modelCooldowns: { ...existing, [modelId]: nextUntil },
|
|
185
|
+
// Counted only for the unsupported-model ladder, and only when this
|
|
186
|
+
// failure is that verdict — so a rate limit or a capacity refusal
|
|
187
|
+
// never advances it. `model_cooldowns`' own shape is untouched, so
|
|
188
|
+
// routing and the console keep parsing it exactly as before.
|
|
189
|
+
...(unsupportedHits === undefined
|
|
190
|
+
? {}
|
|
191
|
+
: { modelBlockHits: { ...blockHits, [modelId]: unsupportedHits } }),
|
|
192
|
+
consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1`,
|
|
193
|
+
lastError: effective.reason,
|
|
194
|
+
lastErrorCategory: effective.category,
|
|
195
|
+
lastErrorAt: now,
|
|
196
|
+
})
|
|
197
|
+
.where(eq(providerAccounts.id, accountId));
|
|
198
|
+
if (!alreadyCooling) {
|
|
199
|
+
await writeHealthEvent(client, {
|
|
200
|
+
entityKind: "account",
|
|
201
|
+
accountId,
|
|
202
|
+
// The account's own status, on both sides: a model-scoped throttle
|
|
203
|
+
// never moved it, and writing the classification's status here
|
|
204
|
+
// claimed an `active → cooldown` transition that never happened — the
|
|
205
|
+
// dialog showed "Account status: ACTIVE" above a list of transitions
|
|
206
|
+
// to `cooldown`. The row still names the model and the reason, which
|
|
207
|
+
// is what makes it worth keeping.
|
|
208
|
+
fromStatus: fromStatus as "active" | "cooldown" | "disabled",
|
|
209
|
+
toStatus: fromStatus as "active" | "cooldown" | "disabled",
|
|
210
|
+
reason: effective.reason,
|
|
211
|
+
errorCategory: effective.category,
|
|
212
|
+
modelId: modelId ?? null,
|
|
213
|
+
createdAt: now,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// `status` and `cooldownUntil` are untouched on this path — only the
|
|
217
|
+
// (account, model) pair backed off — so the snapshot is still accurate
|
|
218
|
+
// and must not be dropped.
|
|
219
|
+
return { ...classification, routesVisible: false };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await client
|
|
223
|
+
.update(providerAccounts)
|
|
224
|
+
.set({
|
|
225
|
+
status: effective.status,
|
|
226
|
+
consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1`,
|
|
227
|
+
lastError: effective.reason,
|
|
228
|
+
lastErrorCategory: effective.category,
|
|
229
|
+
lastErrorAt: now,
|
|
230
|
+
// Monotonic against the account's own in-force deadline, for the same
|
|
231
|
+
// reason as the model path: an unclassified 60-second failure must not
|
|
232
|
+
// release an account the provider parked for hours.
|
|
233
|
+
//
|
|
234
|
+
// A `0` duration contributes no candidate rather than stamping *now*
|
|
235
|
+
// (see `accountDeadlineMs`): the one state that asks for it is a
|
|
236
|
+
// terminal automatic disable, and an elapsed deadline there would hand
|
|
237
|
+
// the row straight back to the recovery sweep.
|
|
238
|
+
cooldownUntil: accountDeadlineMs([
|
|
239
|
+
effective.retryAt?.getTime(),
|
|
240
|
+
escalatedCooldownMs > 0 ? Date.now() + escalatedCooldownMs : undefined,
|
|
241
|
+
account.cooldownUntil?.getTime?.(),
|
|
242
|
+
]),
|
|
243
|
+
})
|
|
244
|
+
.where(eq(providerAccounts.id, accountId));
|
|
245
|
+
|
|
246
|
+
{
|
|
247
|
+
await writeHealthEvent(client, {
|
|
248
|
+
entityKind: "account",
|
|
249
|
+
accountId,
|
|
250
|
+
fromStatus: fromStatus as "active" | "cooldown" | "disabled",
|
|
251
|
+
toStatus: effective.status,
|
|
252
|
+
reason: effective.reason,
|
|
253
|
+
errorCategory: effective.category,
|
|
254
|
+
modelId: modelId ?? null,
|
|
255
|
+
createdAt: now,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
// This path wrote `status` and `cooldownUntil`, both of which the snapshot
|
|
259
|
+
// projects, so routing has to see it.
|
|
260
|
+
return { ...classification, routesVisible: true };
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
// Kept as a capability probe, not a correctness guard: this function is
|
|
265
|
+
// reachable with a partially-implemented database handle (the dispatch
|
|
266
|
+
// tests pass a narrow double), and a transaction is an optimization here,
|
|
267
|
+
// not a requirement — the same writes are correct without one.
|
|
268
|
+
if (typeof db.transaction === "function") {
|
|
269
|
+
return await db.transaction((tx) => persist(tx as CartethyiaDatabase));
|
|
270
|
+
}
|
|
271
|
+
return await persist(db);
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Clears failure state after a successful dispatch; true when the account changed. */
|
|
278
|
+
export async function recordAccountSuccess(
|
|
279
|
+
db: CartethyiaDatabase,
|
|
280
|
+
accountId: string,
|
|
281
|
+
): Promise<boolean> {
|
|
282
|
+
const mutate = async (client: CartethyiaDatabase): Promise<boolean> => {
|
|
283
|
+
// `recordAccountSuccess` runs on every successful dispatch, and for a
|
|
284
|
+
// healthy account `hadFailures` is false — the row was fetched purely to
|
|
285
|
+
// decide nothing. Projected so that no-op path does not pull the
|
|
286
|
+
// credential ciphertext across the wire.
|
|
287
|
+
const rows = await client
|
|
288
|
+
.select({
|
|
289
|
+
status: providerAccounts.status,
|
|
290
|
+
consecutiveFailures: providerAccounts.consecutiveFailures,
|
|
291
|
+
// Read only to decide whether there is anything to clear: the common
|
|
292
|
+
// case (no unsupported-model hit ever recorded) must not write.
|
|
293
|
+
modelBlockHits: providerAccounts.modelBlockHits,
|
|
294
|
+
})
|
|
295
|
+
.from(providerAccounts)
|
|
296
|
+
.where(eq(providerAccounts.id, accountId))
|
|
297
|
+
.limit(1);
|
|
298
|
+
const account = rows[0];
|
|
299
|
+
if (!account || account.status === "disabled") return false;
|
|
300
|
+
const fromStatus = account.status;
|
|
301
|
+
const hadFailures = (account.consecutiveFailures ?? 0) > 0 || account.status !== "active";
|
|
302
|
+
const now = new Date();
|
|
303
|
+
// The unsupported-model escalation restarts after a success: a model that
|
|
304
|
+
// just served is not "not supported", so the next report of it must start
|
|
305
|
+
// from the base window again rather than from wherever the ladder had
|
|
306
|
+
// reached. `modelCooldowns` is deliberately NOT cleared — an in-force
|
|
307
|
+
// window the provider stated survives, which is the existing behavior and
|
|
308
|
+
// the reference gateways' too.
|
|
309
|
+
const blockHits = account.modelBlockHits as Record<string, number> | null;
|
|
310
|
+
const hasBlockHits = blockHits !== null && Object.keys(blockHits).length > 0;
|
|
311
|
+
await client
|
|
312
|
+
.update(providerAccounts)
|
|
313
|
+
.set({
|
|
314
|
+
status: "active",
|
|
315
|
+
consecutiveFailures: 0,
|
|
316
|
+
cooldownUntil: null,
|
|
317
|
+
lastSuccessAt: now,
|
|
318
|
+
...(hadFailures ? { lastRecoveredAt: now } : {}),
|
|
319
|
+
...(hasBlockHits ? { modelBlockHits: {} } : {}),
|
|
320
|
+
})
|
|
321
|
+
.where(eq(providerAccounts.id, accountId));
|
|
322
|
+
if (hadFailures && typeof client.insert === "function") {
|
|
323
|
+
await client.insert(healthEvents).values({
|
|
324
|
+
id: randomUUID(),
|
|
325
|
+
entityKind: "account",
|
|
326
|
+
accountId,
|
|
327
|
+
fromStatus: fromStatus as "active" | "cooldown" | "disabled",
|
|
328
|
+
toStatus: "active",
|
|
329
|
+
reason: "Request dispatched successfully",
|
|
330
|
+
errorCategory: null,
|
|
331
|
+
createdAt: now,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return hadFailures;
|
|
335
|
+
};
|
|
336
|
+
try {
|
|
337
|
+
if (typeof db.transaction === "function") {
|
|
338
|
+
return await db.transaction((tx) => mutate(tx as CartethyiaDatabase));
|
|
339
|
+
}
|
|
340
|
+
return await mutate(db);
|
|
341
|
+
} catch {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Operator-initiated recovery: forces the account back to active and logs the transition. */
|
|
347
|
+
export async function recoverAccount(
|
|
348
|
+
db: CartethyiaDatabase,
|
|
349
|
+
accountId: string,
|
|
350
|
+
reason = "manual_operator_recovery",
|
|
351
|
+
): Promise<boolean> {
|
|
352
|
+
const recover = async (client: CartethyiaDatabase): Promise<boolean> => {
|
|
353
|
+
const accountRows = await client
|
|
354
|
+
.select()
|
|
355
|
+
.from(providerAccounts)
|
|
356
|
+
.where(eq(providerAccounts.id, accountId))
|
|
357
|
+
.limit(1);
|
|
358
|
+
const account = accountRows[0];
|
|
359
|
+
if (!account) return false;
|
|
360
|
+
const fromStatus = account.status;
|
|
361
|
+
const now = new Date();
|
|
362
|
+
await client
|
|
363
|
+
.update(providerAccounts)
|
|
364
|
+
.set({
|
|
365
|
+
status: "active",
|
|
366
|
+
consecutiveFailures: 0,
|
|
367
|
+
cooldownUntil: null,
|
|
368
|
+
lastRecoveredAt: now,
|
|
369
|
+
// Recovery is the operator asserting the account works now, so it must
|
|
370
|
+
// clear EVERY routing exclusion, not just the account-wide ones. A
|
|
371
|
+
// per-model cooldown entry excludes the (account, model) pair in
|
|
372
|
+
// `route-catalog.ts`, so leaving it behind kept the account blocked for
|
|
373
|
+
// that model through `/v1` even though the account read `active` and a
|
|
374
|
+
// direct probe (which addresses the account by id, bypassing routing
|
|
375
|
+
// eligibility) succeeded. Clearing the error fields for the same reason:
|
|
376
|
+
// a stale reason on a recovered row misreports the current state.
|
|
377
|
+
modelCooldowns: {},
|
|
378
|
+
lastError: null,
|
|
379
|
+
lastErrorCategory: null,
|
|
380
|
+
lastErrorAt: null,
|
|
381
|
+
})
|
|
382
|
+
.where(eq(providerAccounts.id, accountId));
|
|
383
|
+
{
|
|
384
|
+
await writeHealthEvent(client, {
|
|
385
|
+
entityKind: "account",
|
|
386
|
+
accountId,
|
|
387
|
+
fromStatus: fromStatus as "active" | "cooldown" | "disabled",
|
|
388
|
+
toStatus: "active",
|
|
389
|
+
reason,
|
|
390
|
+
errorCategory: null,
|
|
391
|
+
createdAt: now,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return true;
|
|
395
|
+
};
|
|
396
|
+
try {
|
|
397
|
+
if (typeof db.transaction === "function") {
|
|
398
|
+
return await db.transaction((tx) => recover(tx as CartethyiaDatabase));
|
|
399
|
+
}
|
|
400
|
+
return await recover(db);
|
|
401
|
+
} catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Recovers accounts whose cooldown elapsed and prunes expired per-model keys. */
|
|
407
|
+
export async function sweepExpiredCooldowns(db: CartethyiaDatabase): Promise<number> {
|
|
408
|
+
const recover = async (client: CartethyiaDatabase): Promise<number> => {
|
|
409
|
+
const now = new Date();
|
|
410
|
+
const nowIso = now.toISOString();
|
|
411
|
+
const expired = await client
|
|
412
|
+
.select({
|
|
413
|
+
id: providerAccounts.id,
|
|
414
|
+
status: providerAccounts.status,
|
|
415
|
+
})
|
|
416
|
+
.from(providerAccounts)
|
|
417
|
+
.where(
|
|
418
|
+
// An ordinary cooldown, recovered as soon as its window elapses.
|
|
419
|
+
and(
|
|
420
|
+
eq(providerAccounts.status, "cooldown"),
|
|
421
|
+
isNotNull(providerAccounts.cooldownUntil),
|
|
422
|
+
lte(providerAccounts.cooldownUntil, now),
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
// One pass recovers a bounded batch. The sweep runs every 30s, so a
|
|
426
|
+
// backlog larger than this drains over successive ticks instead of one
|
|
427
|
+
// statement covering every expired row — that statement holds its locks
|
|
428
|
+
// for as long as the backlog takes.
|
|
429
|
+
.limit(COOLDOWN_RECOVERY_BATCH);
|
|
430
|
+
if (expired.length > 0) {
|
|
431
|
+
await client
|
|
432
|
+
.update(providerAccounts)
|
|
433
|
+
.set({
|
|
434
|
+
status: "active",
|
|
435
|
+
consecutiveFailures: 0,
|
|
436
|
+
cooldownUntil: null,
|
|
437
|
+
lastRecoveredAt: now,
|
|
438
|
+
// Cleared with the state it described. A recovered row that kept
|
|
439
|
+
// `auth_invalidated` still rendered the console's "Re-login required"
|
|
440
|
+
// pill above an `active` status, so the account read healthy to
|
|
441
|
+
// routing and broken to the operator at the same time.
|
|
442
|
+
lastErrorCategory: null,
|
|
443
|
+
})
|
|
444
|
+
.where(inArray(providerAccounts.id, expired.map((account) => account.id)));
|
|
445
|
+
if (typeof client.insert === "function") {
|
|
446
|
+
for (let offset = 0; offset < expired.length; offset += 500) {
|
|
447
|
+
await client.insert(healthEvents).values(
|
|
448
|
+
expired.slice(offset, offset + 500).map((account) => ({
|
|
449
|
+
id: randomUUID(),
|
|
450
|
+
entityKind: "account" as const,
|
|
451
|
+
accountId: account.id,
|
|
452
|
+
fromStatus: account.status,
|
|
453
|
+
toStatus: "active" as const,
|
|
454
|
+
reason: "Cooldown period elapsed — auto-recovered",
|
|
455
|
+
errorCategory: null,
|
|
456
|
+
createdAt: now,
|
|
457
|
+
})),
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Prune per-model cooldowns: remove entries whose ISO timestamp has passed.
|
|
464
|
+
//
|
|
465
|
+
// One set-based UPDATE rather than a SELECT followed by an UPDATE per row.
|
|
466
|
+
// The per-row form issued one statement per account holding any cooldown —
|
|
467
|
+
// on a deployment where many accounts are throttled at once that is the
|
|
468
|
+
// sweep's dominant cost, and it all runs inside this transaction, holding
|
|
469
|
+
// its locks for the whole pass. Recomputing the object in SQL does the same
|
|
470
|
+
// work in one statement.
|
|
471
|
+
//
|
|
472
|
+
// `jsonb_typeof(...) = 'object'` guards the shape: a malformed value must
|
|
473
|
+
// not abort the whole sweep. `IS DISTINCT FROM` keeps the update from
|
|
474
|
+
// rewriting rows it
|
|
475
|
+
// did not change, so the returned row count is the number of accounts whose
|
|
476
|
+
// cooldown set actually shrank — the same thing the loop counted.
|
|
477
|
+
const pruning = pruneExpiredCooldowns(sql`${providerAccounts.modelCooldowns}`, sql`${nowIso}`);
|
|
478
|
+
const pruned = await client
|
|
479
|
+
.update(providerAccounts)
|
|
480
|
+
.set({ modelCooldowns: pruning.value })
|
|
481
|
+
.where(pruning.changed);
|
|
482
|
+
return expired.length + affectedRows(pruned);
|
|
483
|
+
};
|
|
484
|
+
try {
|
|
485
|
+
if (typeof db.transaction === "function") {
|
|
486
|
+
return await db.transaction((tx) => recover(tx as CartethyiaDatabase));
|
|
487
|
+
}
|
|
488
|
+
return await recover(db);
|
|
489
|
+
} catch {
|
|
490
|
+
return 0;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Single attempt health reporter: one home for the success/failure writes
|
|
496
|
+
* every dispatch attempt ends with. Success records recovery; failure
|
|
497
|
+
* records with caller-supplied evidence (origin/scope classification plus
|
|
498
|
+
* model id). A mutating write invalidates the route snapshot so the next
|
|
499
|
+
* plan sees fresh health. Never throws — health must not fail requests.
|
|
500
|
+
*/
|
|
501
|
+
export interface AttemptHealthReport {
|
|
502
|
+
readonly accountId: string | undefined;
|
|
503
|
+
readonly modelId?: string;
|
|
504
|
+
readonly error?: unknown;
|
|
505
|
+
readonly evidence?: AccountFailureEvidence;
|
|
506
|
+
readonly snapshotService?: { invalidate(): unknown };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Writes one health audit row, off the request path when a buffer is installed.
|
|
511
|
+
*
|
|
512
|
+
* `health_events` is append-only and read by nobody in the request path, so it
|
|
513
|
+
* does not need to land before the request finishes — unlike the
|
|
514
|
+
* `provider_accounts` UPDATE beside it, which routing reads and which therefore
|
|
515
|
+
* stays synchronous and transactional.
|
|
516
|
+
*/
|
|
517
|
+
async function writeHealthEvent(
|
|
518
|
+
client: CartethyiaDatabase,
|
|
519
|
+
row: {
|
|
520
|
+
readonly entityKind: "account";
|
|
521
|
+
readonly accountId: string;
|
|
522
|
+
readonly fromStatus: string;
|
|
523
|
+
readonly toStatus: string;
|
|
524
|
+
readonly reason: string;
|
|
525
|
+
readonly errorCategory: string | null;
|
|
526
|
+
readonly modelId?: string | null;
|
|
527
|
+
readonly createdAt: Date;
|
|
528
|
+
},
|
|
529
|
+
): Promise<void> {
|
|
530
|
+
const buffer = healthEventBuffer();
|
|
531
|
+
if (buffer !== undefined) {
|
|
532
|
+
buffer.enqueue({ ...row, modelId: row.modelId ?? null });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (typeof client.insert !== "function") return;
|
|
536
|
+
await client.insert(healthEvents).values({ id: randomUUID(), ...row } as never);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export async function reportAttemptOutcome(
|
|
540
|
+
db: CartethyiaDatabase,
|
|
541
|
+
report: AttemptHealthReport,
|
|
542
|
+
): Promise<void> {
|
|
543
|
+
if (!report.accountId) return;
|
|
544
|
+
if (report.error === undefined) {
|
|
545
|
+
const recovered = await recordAccountSuccess(db, report.accountId).catch(() => false);
|
|
546
|
+
if (recovered) await report.snapshotService?.invalidate();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const classification = await recordAccountFailure(db, report.accountId, report.error, {
|
|
550
|
+
...report.evidence,
|
|
551
|
+
...(report.modelId === undefined ? {} : { modelId: report.modelId }),
|
|
552
|
+
}).catch(() => null);
|
|
553
|
+
// Gated on `routesVisible`, not on `mutatesAccount`: a failure that only
|
|
554
|
+
// cooled one (account, model) pair, or only advanced the streak, changed
|
|
555
|
+
// nothing routing reads. Invalidating for those rebuilt an 11-SELECT snapshot
|
|
556
|
+
// on the next request, and under a failure wave each rebuild fed the next
|
|
557
|
+
// invalidation.
|
|
558
|
+
if (classification?.routesVisible) await report.snapshotService?.invalidate();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export async function listAccountHealthEvents(
|
|
562
|
+
db: CartethyiaDatabase,
|
|
563
|
+
accountId: string,
|
|
564
|
+
limit = 50,
|
|
565
|
+
): Promise<readonly AccountHealthEventRecord[]> {
|
|
566
|
+
try {
|
|
567
|
+
const rows = await db
|
|
568
|
+
.select()
|
|
569
|
+
.from(healthEvents)
|
|
570
|
+
.where(and(eq(healthEvents.entityKind, "account"), eq(healthEvents.accountId, accountId)))
|
|
571
|
+
.orderBy(desc(healthEvents.createdAt))
|
|
572
|
+
.limit(limit);
|
|
573
|
+
|
|
574
|
+
return rows.map((r) => ({
|
|
575
|
+
id: r.id,
|
|
576
|
+
accountId: r.accountId as string,
|
|
577
|
+
fromStatus: r.fromStatus,
|
|
578
|
+
toStatus: r.toStatus,
|
|
579
|
+
modelId: r.modelId ?? null,
|
|
580
|
+
reason: r.reason,
|
|
581
|
+
errorCategory: r.errorCategory,
|
|
582
|
+
createdAt: r.createdAt.toISOString(),
|
|
583
|
+
}));
|
|
584
|
+
} catch {
|
|
585
|
+
return [];
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Every stored row across accounts and pools — the size the purge offers to delete. */
|
|
590
|
+
export async function countAllHealthEvents(db: CartethyiaDatabase): Promise<number> {
|
|
591
|
+
try {
|
|
592
|
+
const rows = await db.select({ total: count() }).from(healthEvents);
|
|
593
|
+
return rows[0]?.total ?? 0;
|
|
594
|
+
} catch {
|
|
595
|
+
return 0;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Deletes stored health & error rows.
|
|
601
|
+
*
|
|
602
|
+
* Unlike the read above, a failed delete surfaces: an operator who purged
|
|
603
|
+
* believes the trail is gone, so "it failed" must not read as "it succeeded
|
|
604
|
+
* with zero rows". Callers own the flush-buffer barrier (`drain`) so a queued
|
|
605
|
+
* write cannot land after these rows are gone.
|
|
606
|
+
*/
|
|
607
|
+
export async function deleteAccountHealthEvents(
|
|
608
|
+
db: CartethyiaDatabase,
|
|
609
|
+
accountId: string,
|
|
610
|
+
): Promise<number> {
|
|
611
|
+
const rows = await db
|
|
612
|
+
.delete(healthEvents)
|
|
613
|
+
.where(and(eq(healthEvents.entityKind, "account"), eq(healthEvents.accountId, accountId)))
|
|
614
|
+
.returning({ id: healthEvents.id });
|
|
615
|
+
return rows.length;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export async function deleteAllHealthEvents(db: CartethyiaDatabase): Promise<number> {
|
|
619
|
+
const rows = await db.delete(healthEvents).returning({ id: healthEvents.id });
|
|
620
|
+
return rows.length;
|
|
621
|
+
}
|