pi-freeflow 1.23.3 → 1.26.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.
@@ -1,492 +1,507 @@
1
- /**
2
- * Upstream degradation health for pi-freeflow.
3
- *
4
- * Tracks OpenCode Zen free-tier gating (HTTP 403 FreeTierError: the free
5
- * tier can only be used from within OpenCode) in a small persisted state
6
- * machine. While gated, fresh Zen sessions fail over to a healthy Kilo
7
- * model on the same wire API (chat and responses both); proven sessions
8
- * keep their upstream and a periodic canary probes for recovery.
9
- */
10
-
11
- import { createHash, randomUUID } from "node:crypto";
12
- import fs from "node:fs";
13
- import path from "node:path";
14
- import { getAliveCatalog } from "./catalog.ts";
15
- import { resolveUpstreamHealthPath } from "./config.ts";
16
- import { log } from "./logger.ts";
17
- import { getStatusUi } from "./relay-state.ts";
18
-
19
- export type UpstreamName = "zen" | "kilo";
20
-
21
- export interface UpstreamHealthSnapshot {
22
- gated: boolean;
23
- consecutiveFreeTier403: number;
24
- firstGatedAt: number;
25
- lastTransitionAt: number;
26
- lastCanaryAt: number;
27
- }
28
-
29
- export const GATE_ENTER_AFTER = 2;
30
- export const FALLBACK_KILO_MODEL = "stepfun/step-3.7-flash:free";
31
-
32
- export type ZenRoute = "passthrough" | "failover" | "canary";
33
-
34
- /** Free-tier gate marker: the free tier only works from inside OpenCode. */
35
- const GATE_CODE_MARKER = "FreeTierError";
36
- const GATE_MESSAGE_MARKER = "only be used from within OpenCode";
37
-
38
- /** User-visible hint attached to fast-failed 403 gate bodies. */
39
- const FREE_TIER_HINT =
40
- "OpenCode's free tier is only available inside OpenCode right now, " +
41
- "so a new session can't start here. Try again later or continue with a fallback model.";
42
-
43
- const GATE_ENTER_MESSAGE =
44
- "OpenCode's free tier is only available inside OpenCode right now — " +
45
- "new sessions here will use a fallback model until it recovers.";
46
- const GATE_CLEAR_MESSAGE =
47
- "OpenCode's free tier is available again — new sessions here are using it.";
48
-
49
- /** Established session keys live a day; the table is capped and pruned on save. */
50
- const SESSION_TTL_MS = 24 * 60 * 60 * 1_000;
51
- const MAX_SESSION_KEYS = 1_000;
52
- /** Non-transition saves are throttled; gate flips and canaries always persist. */
53
- const SAVE_THROTTLE_MS = 1_000;
54
- /** While gated, at most one unproven canary per window probes for recovery. */
55
- const CANARY_INTERVAL_MS = 5 * 60 * 1_000;
56
- /** Gate flip notifications are throttled per direction. */
57
- const GATE_NOTIFY_THROTTLE_MS = 60_000;
58
- /** Fast-fail hint notifications have their own slower throttle. */
59
- const HINT_NOTIFY_THROTTLE_MS = 10 * 60 * 1_000;
60
-
61
- interface GateState {
62
- gated: boolean;
63
- consecutiveFreeTier403: number;
64
- firstGatedAt: number;
65
- lastTransitionAt: number;
66
- lastCanaryAt: number;
67
- }
68
-
69
- function cleanGate(): GateState {
70
- return {
71
- gated: false,
72
- consecutiveFreeTier403: 0,
73
- firstGatedAt: 0,
74
- lastTransitionAt: 0,
75
- lastCanaryAt: 0,
76
- };
77
- }
78
-
79
- const gates = new Map<UpstreamName, GateState>();
80
- const sessions = new Map<string, number>();
81
- /**
82
- * Gate-rejection memory: fingerprints of sessions Zen just refused with a
83
- * free-tier gate 403. A retried or resumed session that matches is failed
84
- * over immediately instead of replaying the refusal — this is what heals
85
- * resumed conversations (multi-turn replays, stale keys) without touching
86
- * working traffic. Memory-only (a daemon restart costs one 403 re-prime)
87
- * and dropped wholesale when the gate clears.
88
- */
89
- const rejected = new Map<string, number>();
90
- const REJECTED_TTL_MS = 60 * 60 * 1_000;
91
- const MAX_REJECTED = 1_000;
92
- let loaded = false;
93
- let lastSaveAt = 0;
94
- let lastGateNotifyAt = 0;
95
- let lastGateNotifyKey = "";
96
- let lastHintNotifyAt = 0;
97
-
98
- function gateOf(upstream: UpstreamName): GateState {
99
- let g = gates.get(upstream);
100
- if (!g) {
101
- g = cleanGate();
102
- gates.set(upstream, g);
103
- }
104
- return g;
105
- }
106
-
107
- function isEstablished(key: string): boolean {
108
- const expiresAt = sessions.get(key);
109
- if (expiresAt === undefined) return false;
110
- if (expiresAt <= Date.now()) {
111
- sessions.delete(key);
112
- return false;
113
- }
114
- return true;
115
- }
116
-
117
- function ensureLoaded(): void {
118
- if (loaded) return;
119
- loaded = true;
120
- let raw: string;
121
- try {
122
- raw = fs.readFileSync(resolveUpstreamHealthPath(), "utf8");
123
- } catch {
124
- return;
125
- }
126
- try {
127
- const data = JSON.parse(raw) as {
128
- sessions?: Record<string, number>;
129
- gates?: Record<string, Partial<GateState>>;
130
- };
131
- const now = Date.now();
132
- if (data.sessions && typeof data.sessions === "object") {
133
- for (const [k, v] of Object.entries(data.sessions)) {
134
- if (typeof k === "string" && k.length > 0 && typeof v === "number" && v > now) {
135
- sessions.set(k, v);
136
- }
137
- }
138
- }
139
- for (const name of ["zen", "kilo"] as UpstreamName[]) {
140
- const stored = data.gates?.[name];
141
- if (stored && typeof stored === "object") {
142
- gates.set(name, {
143
- gated: stored.gated === true,
144
- consecutiveFreeTier403:
145
- typeof stored.consecutiveFreeTier403 === "number" &&
146
- Number.isFinite(stored.consecutiveFreeTier403)
147
- ? Math.max(0, Math.floor(stored.consecutiveFreeTier403))
148
- : 0,
149
- firstGatedAt: typeof stored.firstGatedAt === "number" ? stored.firstGatedAt : 0,
150
- lastTransitionAt:
151
- typeof stored.lastTransitionAt === "number" ? stored.lastTransitionAt : 0,
152
- lastCanaryAt: typeof stored.lastCanaryAt === "number" ? stored.lastCanaryAt : 0,
153
- });
154
- }
155
- }
156
- } catch {
157
- gates.clear();
158
- sessions.clear();
159
- }
160
- }
161
-
162
- function persist(force: boolean): void {
163
- const now = Date.now();
164
- if (!force && now - lastSaveAt < SAVE_THROTTLE_MS) return;
165
- for (const [k, v] of sessions) {
166
- if (v <= now) sessions.delete(k);
167
- }
168
- while (sessions.size > MAX_SESSION_KEYS) {
169
- const oldest = sessions.keys().next();
170
- if (oldest.done) break;
171
- sessions.delete(oldest.value);
172
- }
173
- try {
174
- const file = resolveUpstreamHealthPath();
175
- const dir = path.dirname(file);
176
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
177
- const body = JSON.stringify(
178
- {
179
- version: 1,
180
- sessions: Object.fromEntries(sessions),
181
- gates: {
182
- zen: gates.get("zen") ?? cleanGate(),
183
- kilo: gates.get("kilo") ?? cleanGate(),
184
- },
185
- },
186
- null,
187
- 2,
188
- );
189
- const tmp = `${file}.${randomUUID()}.tmp`;
190
- fs.writeFileSync(tmp, body, "utf8");
191
- try {
192
- fs.renameSync(tmp, file);
193
- } catch {
194
- try {
195
- fs.rmSync(tmp, { force: true });
196
- } catch { }
197
- fs.writeFileSync(file, body, "utf8");
198
- }
199
- lastSaveAt = Date.now();
200
- } catch (e) {
201
- log("warn", "Could not persist upstream health", { error: String(e) });
202
- }
203
- }
204
-
205
- export function notifyGateTransition(upstream: UpstreamName, gated?: boolean): void {
206
- ensureLoaded();
207
- const isGated = typeof gated === "boolean" ? gated : gateOf(upstream).gated;
208
- const ui = getStatusUi();
209
- if (!ui?.notify) return;
210
- const now = Date.now();
211
- const key = `${upstream}:${isGated ? "gated" : "open"}`;
212
- if (key === lastGateNotifyKey && now - lastGateNotifyAt < GATE_NOTIFY_THROTTLE_MS) {
213
- return;
214
- }
215
- lastGateNotifyKey = key;
216
- lastGateNotifyAt = now;
217
- try {
218
- ui.notify(isGated ? GATE_ENTER_MESSAGE : GATE_CLEAR_MESSAGE, isGated ? "warning" : "info");
219
- } catch { }
220
- }
221
-
222
- /** True when a status/body pair is the free-tier gate (fresh sessions refused). */
223
- export function isFreeTierGate(status: number, bodyText: unknown): boolean {
224
- if (status !== 403 || typeof bodyText !== "string") return false;
225
- return (
226
- bodyText.includes(GATE_CODE_MARKER) && bodyText.includes(GATE_MESSAGE_MARKER)
227
- );
228
- }
229
-
230
- /** Stable conversation key carried by request bodies, if the caller sent one. */
231
- export function sessionKeyOf(parsedBody: unknown): string | null {
232
- if (typeof parsedBody !== "object" || parsedBody === null) return null;
233
- const key = (parsedBody as Record<string, unknown>).prompt_cache_key;
234
- return typeof key === "string" && key.length > 0 ? key : null;
235
- }
236
-
237
- /**
238
- * Fingerprint of one request for gate-rejection memory: the stable session
239
- * key when the caller sent one, else a hash of model + path + conversation
240
- * content (chat messages or responses input). Keyless chat replays hash by
241
- * content, so a resumed conversation matches its own refused attempt.
242
- */
243
- export function rejectionFingerprint(parsedBody: unknown, pathname: string): string | null {
244
- if (typeof parsedBody !== "object" || parsedBody === null) return null;
245
- const key = sessionKeyOf(parsedBody);
246
- if (key) return `k:${key}`;
247
- const rec = parsedBody as Record<string, unknown>;
248
- const content = Array.isArray(rec.messages) ? rec.messages : rec.input;
249
- if (content === undefined) return null;
250
- const model = typeof rec.model === "string" ? rec.model : "?";
251
- return (
252
- "h:" +
253
- createHash("sha256")
254
- .update(`${model}|${String(pathname)}|${JSON.stringify(content)}`, "utf8")
255
- .digest("hex")
256
- );
257
- }
258
-
259
- function pruneRejected(now: number): void {
260
- for (const [fp, expiresAt] of rejected) {
261
- if (expiresAt <= now) rejected.delete(fp);
262
- }
263
- while (rejected.size > MAX_REJECTED) {
264
- const oldest = rejected.keys().next();
265
- if (oldest.done) break;
266
- rejected.delete(oldest.value);
267
- }
268
- }
269
-
270
- /**
271
- * Remember a session Zen just refused, so its retry fails over instead of
272
- * replaying the refusal. Only free-tier gate verdicts prime — anything else
273
- * passes through untouched.
274
- */
275
- export function rememberGateRejection(
276
- parsedBody: unknown,
277
- pathname: string,
278
- status: number,
279
- bodyText: unknown,
280
- ): void {
281
- if (!isFreeTierGate(status, bodyText)) return;
282
- const fp = rejectionFingerprint(parsedBody, pathname);
283
- if (!fp) return;
284
- const now = Date.now();
285
- pruneRejected(now);
286
- rejected.set(fp, now + REJECTED_TTL_MS);
287
- }
288
-
289
- /** True when this session already ate a gate 403 and its retry should fail over. */
290
- export function wasGateRejected(parsedBody: unknown, pathname: string): boolean {
291
- const fp = rejectionFingerprint(parsedBody, pathname);
292
- if (!fp) return false;
293
- const expiresAt = rejected.get(fp);
294
- if (expiresAt === undefined) return false;
295
- if (expiresAt <= Date.now()) {
296
- rejected.delete(fp);
297
- return false;
298
- }
299
- return true;
300
- }
301
-
302
- /**
303
- * True for sessions with no proven history: responses bodies whose key was
304
- * never established, chat bodies with at most one message and no known key.
305
- * Fails open (false) for missing bodies.
306
- */
307
- export function isUnprovenSession(parsedBody: unknown, pathname: string): boolean {
308
- if (typeof parsedBody !== "object" || parsedBody === null) return false;
309
- ensureLoaded();
310
- const key = sessionKeyOf(parsedBody);
311
- if (key && isEstablished(key)) return false;
312
- if (String(pathname ?? "").includes("responses")) return true;
313
- const messages = (parsedBody as { messages?: unknown }).messages;
314
- if (Array.isArray(messages) && messages.length > 1) return false;
315
- return true;
316
- }
317
-
318
- /**
319
- * Session routing while degraded: proven sessions and ungated traffic pass
320
- * through; gated fresh sessions get one canary per window, else failover.
321
- */
322
- export function decideZenRoute(parsedBody: unknown, pathname: string): ZenRoute {
323
- ensureLoaded();
324
- if (!gateOf("zen").gated) return "passthrough";
325
- if (!isUnprovenSession(parsedBody, pathname)) return "passthrough";
326
- const g = gateOf("zen");
327
- const now = Date.now();
328
- if (!g.lastCanaryAt || now - g.lastCanaryAt >= CANARY_INTERVAL_MS) {
329
- g.lastCanaryAt = now;
330
- persist(true);
331
- return "canary";
332
- }
333
- return "failover";
334
- }
335
-
336
- /** First healthy Kilo model id, else the built-in Kilo fallback. */
337
- export function pickFailoverModel(): string {
338
- try {
339
- const hit = getAliveCatalog().find(
340
- (m) => (m as { source?: string }).source === "kilo" && typeof m.id === "string" && m.id.length > 0,
341
- );
342
- if (hit) return hit.id;
343
- } catch { }
344
- return FALLBACK_KILO_MODEL;
345
- }
346
-
347
- /**
348
- * Record a successful upstream turn. Plain successes only establish the
349
- * session key and never clear the gate; a canary success clears it.
350
- */
351
- export function recordUpstreamSuccess(
352
- upstream: UpstreamName,
353
- opts?: { sessionKey?: string | null; canary?: boolean },
354
- ): void {
355
- ensureLoaded();
356
- const now = Date.now();
357
- let dirty = false;
358
- if (typeof opts?.sessionKey === "string" && opts.sessionKey.length > 0) {
359
- sessions.delete(opts.sessionKey);
360
- sessions.set(opts.sessionKey, now + SESSION_TTL_MS);
361
- dirty = true;
362
- }
363
- const g = gateOf(upstream);
364
- if (opts?.canary === true) {
365
- g.lastCanaryAt = now;
366
- // A canary success proves Zen serves fresh sessions again, so remembered
367
- // refusals are stale whether or not the gate flag was set.
368
- if (rejected.size > 0) {
369
- rejected.clear();
370
- dirty = true;
371
- }
372
- if (g.gated) {
373
- g.gated = false;
374
- g.consecutiveFreeTier403 = 0;
375
- g.firstGatedAt = 0;
376
- g.lastTransitionAt = now;
377
- log("info", "upstream recovered for new sessions", { upstream });
378
- persist(true);
379
- notifyGateTransition(upstream, false);
380
- return;
381
- }
382
- dirty = true;
383
- } else if (!g.gated && g.consecutiveFreeTier403 !== 0) {
384
- g.consecutiveFreeTier403 = 0;
385
- dirty = true;
386
- }
387
- if (dirty) persist(false);
388
- }
389
-
390
- /** Record a failed upstream turn; consecutive gate 403s flip the gate. */
391
- export function recordUpstreamFailure(
392
- upstream: UpstreamName,
393
- status: number,
394
- bodyText: unknown,
395
- ): void {
396
- ensureLoaded();
397
- if (!isFreeTierGate(status, bodyText)) {
398
- const g = gateOf(upstream);
399
- if (g.consecutiveFreeTier403 !== 0) {
400
- g.consecutiveFreeTier403 = 0;
401
- persist(false);
402
- }
403
- return;
404
- }
405
- const g = gateOf(upstream);
406
- g.consecutiveFreeTier403 += 1;
407
- if (!g.gated && g.consecutiveFreeTier403 >= GATE_ENTER_AFTER) {
408
- const now = Date.now();
409
- g.gated = true;
410
- if (!g.firstGatedAt) g.firstGatedAt = now;
411
- g.lastTransitionAt = now;
412
- log("warn", "upstream gating new sessions, failing over to fallback", { upstream });
413
- persist(true);
414
- notifyGateTransition(upstream, true);
415
- return;
416
- }
417
- persist(false);
418
- }
419
-
420
- /** Whether the upstream is currently gated for fresh sessions. */
421
- export function isUpstreamGated(upstream: UpstreamName): boolean {
422
- ensureLoaded();
423
- return gateOf(upstream).gated;
424
- }
425
-
426
- /** Copy of the current per-upstream health snapshot. */
427
- export function getUpstreamHealth(upstream: UpstreamName): UpstreamHealthSnapshot {
428
- ensureLoaded();
429
- return { ...gateOf(upstream) };
430
- }
431
-
432
- /**
433
- * Attach an actionable hint to fast-failed 403 gate bodies. Any other
434
- * status or body passes through byte-identical. Hint notifications are
435
- * throttled to one per 10 minutes.
436
- */
437
- export function withFreeTierHint(status: number, data: string): string {
438
- if (!isFreeTierGate(status, data)) return data;
439
- let parsed: unknown;
440
- try {
441
- parsed = JSON.parse(data);
442
- } catch {
443
- return data;
444
- }
445
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
446
- return data;
447
- }
448
- const json = JSON.stringify({
449
- ...(parsed as Record<string, unknown>),
450
- hint: FREE_TIER_HINT,
451
- });
452
- try {
453
- const ui = getStatusUi();
454
- const now = Date.now();
455
- if (ui?.notify && now - lastHintNotifyAt >= HINT_NOTIFY_THROTTLE_MS) {
456
- lastHintNotifyAt = now;
457
- ui.notify(FREE_TIER_HINT, "warning");
458
- }
459
- } catch { }
460
- return json;
461
- }
462
- /** Reset the degradation gate for an upstream (e.g. after client fingerprint fix or manual recovery). */
463
- export function resetUpstreamGate(upstream: UpstreamName): void {
464
- ensureLoaded();
465
- const g = gateOf(upstream);
466
- g.gated = false;
467
- g.consecutiveFreeTier403 = 0;
468
- g.firstGatedAt = 0;
469
- g.lastTransitionAt = Date.now();
470
- rejected.clear();
471
- persist(true);
472
- }
473
-
474
-
475
- /** Test-only: drop all in-memory health and remove the persisted file. */
476
- export function _resetUpstreamHealthForTest(): void {
477
- gates.clear();
478
- sessions.clear();
479
- rejected.clear();
480
- loaded = false;
481
- lastSaveAt = 0;
482
- lastGateNotifyAt = 0;
483
- lastGateNotifyKey = "";
484
- try {
485
- fs.rmSync(resolveUpstreamHealthPath(), { force: true });
486
- } catch { }
487
- }
488
-
489
- /** Test-only: reset the hint notification throttle. */
490
- export function _resetFreeTierHintForTest(): void {
491
- lastHintNotifyAt = 0;
492
- }
1
+ /**
2
+ * Upstream degradation health for pi-freeflow.
3
+ *
4
+ * Tracks OpenCode Zen free-tier gating (HTTP 403 FreeTierError: the free
5
+ * tier can only be used from within OpenCode) in a small persisted state
6
+ * machine. While gated, fresh Zen sessions fail over to a healthy Kilo
7
+ * model on the same wire API (chat and responses both); proven sessions
8
+ * keep their upstream and a periodic canary probes for recovery.
9
+ */
10
+
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { getAliveCatalog } from "./catalog.ts";
15
+ import { resolveUpstreamHealthPath } from "./config.ts";
16
+ import { log } from "./logger.ts";
17
+ import { getStatusUi } from "./relay-state.ts";
18
+
19
+ export type UpstreamName = "zen" | "kilo";
20
+
21
+ export interface UpstreamHealthSnapshot {
22
+ gated: boolean;
23
+ consecutiveFreeTier403: number;
24
+ firstGatedAt: number;
25
+ lastTransitionAt: number;
26
+ lastCanaryAt: number;
27
+ }
28
+
29
+ export const GATE_ENTER_AFTER = 2;
30
+ export const FALLBACK_KILO_MODEL = "dots-studio/dots-3-note-preview:free";
31
+
32
+ export type ZenRoute = "passthrough" | "failover" | "canary";
33
+
34
+ /** Free-tier gate marker: the free tier only works from inside OpenCode. */
35
+ const GATE_CODE_MARKER = "FreeTierError";
36
+ const GATE_MESSAGE_MARKER = "only be used from within OpenCode";
37
+
38
+ /** User-visible hint attached to fast-failed 403 gate bodies. */
39
+ const FREE_TIER_HINT =
40
+ "OpenCode's free tier is only available inside OpenCode right now, " +
41
+ "so a new session can't start here. Try again later or continue with a fallback model.";
42
+
43
+ const GATE_ENTER_MESSAGE =
44
+ "OpenCode's free tier is only available inside OpenCode right now — " +
45
+ "new sessions here will use a fallback model until it recovers.";
46
+ const GATE_CLEAR_MESSAGE =
47
+ "OpenCode's free tier is available again — new sessions here are using it.";
48
+
49
+ /** Established session keys live a day; the table is capped and pruned on save. */
50
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1_000;
51
+ const MAX_SESSION_KEYS = 1_000;
52
+ /**
53
+ * Attach a hint so hosts that render only `error.message` still show it.
54
+ * String-shaped errors keep their contract untouched (hint sibling only);
55
+ * object errors gain the guidance inside `message` (hint kept for compat).
56
+ */
57
+ export function attachHintForDisplay(body: Record<string, unknown>, hint: string): Record<string, unknown> {
58
+ const err = body.error;
59
+ if (err && typeof err === "object" && !Array.isArray(err)) {
60
+ const msg = (err as Record<string, unknown>).message;
61
+ if (typeof msg === "string" && !msg.includes(hint)) {
62
+ // Upstream text often ends without punctuation ("...in 9h 20m"), so join
63
+ // with a sentence break instead of gluing two sentences together.
64
+ const text = msg.trimEnd();
65
+ const sep = /[.!?]$/.test(text) ? " " : ". ";
66
+ return { ...body, error: { ...(err as Record<string, unknown>), message: `${text}${sep}${hint}` }, hint };
67
+ }
68
+ }
69
+ return { ...body, hint };
70
+ }
71
+ /** Non-transition saves are throttled; gate flips and canaries always persist. */
72
+ const SAVE_THROTTLE_MS = 1_000;
73
+ /** While gated, at most one unproven canary per window probes for recovery. */
74
+ const CANARY_INTERVAL_MS = 5 * 60 * 1_000;
75
+ /** Gate flip notifications are throttled per direction. */
76
+ const GATE_NOTIFY_THROTTLE_MS = 60_000;
77
+ const HINT_NOTIFY_THROTTLE_MS = 10 * 60 * 1_000;
78
+
79
+ interface GateState {
80
+ gated: boolean;
81
+ consecutiveFreeTier403: number;
82
+ firstGatedAt: number;
83
+ lastTransitionAt: number;
84
+ lastCanaryAt: number;
85
+ }
86
+
87
+ function cleanGate(): GateState {
88
+ return {
89
+ gated: false,
90
+ consecutiveFreeTier403: 0,
91
+ firstGatedAt: 0,
92
+ lastTransitionAt: 0,
93
+ lastCanaryAt: 0,
94
+ };
95
+ }
96
+
97
+ const gates = new Map<UpstreamName, GateState>();
98
+ const sessions = new Map<string, number>();
99
+ /**
100
+ * Gate-rejection memory: fingerprints of sessions Zen just refused with a
101
+ * free-tier gate 403. A retried or resumed session that matches is failed
102
+ * over immediately instead of replaying the refusal — this is what heals
103
+ * resumed conversations (multi-turn replays, stale keys) without touching
104
+ * working traffic. Memory-only (a daemon restart costs one 403 re-prime)
105
+ * and dropped wholesale when the gate clears.
106
+ */
107
+ const rejected = new Map<string, number>();
108
+ const REJECTED_TTL_MS = 60 * 60 * 1_000;
109
+ const MAX_REJECTED = 1_000;
110
+ let loaded = false;
111
+ let lastSaveAt = 0;
112
+ let lastGateNotifyAt = 0;
113
+ let lastGateNotifyKey = "";
114
+ let lastHintNotifyAt = 0;
115
+
116
+ function gateOf(upstream: UpstreamName): GateState {
117
+ let g = gates.get(upstream);
118
+ if (!g) {
119
+ g = cleanGate();
120
+ gates.set(upstream, g);
121
+ }
122
+ return g;
123
+ }
124
+
125
+ function isEstablished(key: string): boolean {
126
+ const expiresAt = sessions.get(key);
127
+ if (expiresAt === undefined) return false;
128
+ if (expiresAt <= Date.now()) {
129
+ sessions.delete(key);
130
+ return false;
131
+ }
132
+ return true;
133
+ }
134
+
135
+ function ensureLoaded(): void {
136
+ if (loaded) return;
137
+ loaded = true;
138
+ let raw: string;
139
+ try {
140
+ raw = fs.readFileSync(resolveUpstreamHealthPath(), "utf8");
141
+ } catch {
142
+ return;
143
+ }
144
+ try {
145
+ const data = JSON.parse(raw) as {
146
+ sessions?: Record<string, number>;
147
+ gates?: Record<string, Partial<GateState>>;
148
+ };
149
+ const now = Date.now();
150
+ if (data.sessions && typeof data.sessions === "object") {
151
+ for (const [k, v] of Object.entries(data.sessions)) {
152
+ if (typeof k === "string" && k.length > 0 && typeof v === "number" && v > now) {
153
+ sessions.set(k, v);
154
+ }
155
+ }
156
+ }
157
+ for (const name of ["zen", "kilo"] as UpstreamName[]) {
158
+ const stored = data.gates?.[name];
159
+ if (stored && typeof stored === "object") {
160
+ gates.set(name, {
161
+ gated: stored.gated === true,
162
+ consecutiveFreeTier403:
163
+ typeof stored.consecutiveFreeTier403 === "number" &&
164
+ Number.isFinite(stored.consecutiveFreeTier403)
165
+ ? Math.max(0, Math.floor(stored.consecutiveFreeTier403))
166
+ : 0,
167
+ firstGatedAt: typeof stored.firstGatedAt === "number" ? stored.firstGatedAt : 0,
168
+ lastTransitionAt:
169
+ typeof stored.lastTransitionAt === "number" ? stored.lastTransitionAt : 0,
170
+ lastCanaryAt: typeof stored.lastCanaryAt === "number" ? stored.lastCanaryAt : 0,
171
+ });
172
+ }
173
+ }
174
+ } catch {
175
+ gates.clear();
176
+ sessions.clear();
177
+ }
178
+ }
179
+
180
+ function persist(force: boolean): void {
181
+ const now = Date.now();
182
+ if (!force && now - lastSaveAt < SAVE_THROTTLE_MS) return;
183
+ for (const [k, v] of sessions) {
184
+ if (v <= now) sessions.delete(k);
185
+ }
186
+ while (sessions.size > MAX_SESSION_KEYS) {
187
+ const oldest = sessions.keys().next();
188
+ if (oldest.done) break;
189
+ sessions.delete(oldest.value);
190
+ }
191
+ try {
192
+ const file = resolveUpstreamHealthPath();
193
+ const dir = path.dirname(file);
194
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
195
+ const body = JSON.stringify(
196
+ {
197
+ version: 1,
198
+ sessions: Object.fromEntries(sessions),
199
+ gates: {
200
+ zen: gates.get("zen") ?? cleanGate(),
201
+ kilo: gates.get("kilo") ?? cleanGate(),
202
+ },
203
+ },
204
+ null,
205
+ 2,
206
+ );
207
+ const tmp = `${file}.${randomUUID()}.tmp`;
208
+ fs.writeFileSync(tmp, body, "utf8");
209
+ try {
210
+ fs.renameSync(tmp, file);
211
+ } catch {
212
+ try {
213
+ fs.rmSync(tmp, { force: true });
214
+ } catch { }
215
+ fs.writeFileSync(file, body, "utf8");
216
+ }
217
+ lastSaveAt = Date.now();
218
+ } catch (e) {
219
+ log("warn", "Could not persist upstream health", { error: String(e) });
220
+ }
221
+ }
222
+
223
+ export function notifyGateTransition(upstream: UpstreamName, gated?: boolean): void {
224
+ ensureLoaded();
225
+ const isGated = typeof gated === "boolean" ? gated : gateOf(upstream).gated;
226
+ const ui = getStatusUi();
227
+ if (!ui?.notify) return;
228
+ const now = Date.now();
229
+ const key = `${upstream}:${isGated ? "gated" : "open"}`;
230
+ if (key === lastGateNotifyKey && now - lastGateNotifyAt < GATE_NOTIFY_THROTTLE_MS) {
231
+ return;
232
+ }
233
+ lastGateNotifyKey = key;
234
+ lastGateNotifyAt = now;
235
+ try {
236
+ ui.notify(isGated ? GATE_ENTER_MESSAGE : GATE_CLEAR_MESSAGE, isGated ? "warning" : "info");
237
+ } catch { }
238
+ }
239
+
240
+ /** True when a status/body pair is the free-tier gate (fresh sessions refused). */
241
+ export function isFreeTierGate(status: number, bodyText: unknown): boolean {
242
+ if (status !== 403 || typeof bodyText !== "string") return false;
243
+ return (
244
+ bodyText.includes(GATE_CODE_MARKER) && bodyText.includes(GATE_MESSAGE_MARKER)
245
+ );
246
+ }
247
+
248
+ /** Stable conversation key carried by request bodies, if the caller sent one. */
249
+ export function sessionKeyOf(parsedBody: unknown): string | null {
250
+ if (typeof parsedBody !== "object" || parsedBody === null) return null;
251
+ const key = (parsedBody as Record<string, unknown>).prompt_cache_key;
252
+ return typeof key === "string" && key.length > 0 ? key : null;
253
+ }
254
+
255
+ /**
256
+ * Fingerprint of one request for gate-rejection memory: the stable session
257
+ * key when the caller sent one, else a hash of model + path + conversation
258
+ * content (chat messages or responses input). Keyless chat replays hash by
259
+ * content, so a resumed conversation matches its own refused attempt.
260
+ */
261
+ export function rejectionFingerprint(parsedBody: unknown, pathname: string): string | null {
262
+ if (typeof parsedBody !== "object" || parsedBody === null) return null;
263
+ const key = sessionKeyOf(parsedBody);
264
+ if (key) return `k:${key}`;
265
+ const rec = parsedBody as Record<string, unknown>;
266
+ const content = Array.isArray(rec.messages) ? rec.messages : rec.input;
267
+ if (content === undefined) return null;
268
+ const model = typeof rec.model === "string" ? rec.model : "?";
269
+ return (
270
+ "h:" +
271
+ createHash("sha256")
272
+ .update(`${model}|${String(pathname)}|${JSON.stringify(content)}`, "utf8")
273
+ .digest("hex")
274
+ );
275
+ }
276
+
277
+ function pruneRejected(now: number): void {
278
+ for (const [fp, expiresAt] of rejected) {
279
+ if (expiresAt <= now) rejected.delete(fp);
280
+ }
281
+ while (rejected.size > MAX_REJECTED) {
282
+ const oldest = rejected.keys().next();
283
+ if (oldest.done) break;
284
+ rejected.delete(oldest.value);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Remember a session Zen just refused, so its retry fails over instead of
290
+ * replaying the refusal. Only free-tier gate verdicts prime — anything else
291
+ * passes through untouched.
292
+ */
293
+ export function rememberGateRejection(
294
+ parsedBody: unknown,
295
+ pathname: string,
296
+ status: number,
297
+ bodyText: unknown,
298
+ ): void {
299
+ if (!isFreeTierGate(status, bodyText)) return;
300
+ const fp = rejectionFingerprint(parsedBody, pathname);
301
+ if (!fp) return;
302
+ const now = Date.now();
303
+ pruneRejected(now);
304
+ rejected.set(fp, now + REJECTED_TTL_MS);
305
+ }
306
+
307
+ /** True when this session already ate a gate 403 and its retry should fail over. */
308
+ export function wasGateRejected(parsedBody: unknown, pathname: string): boolean {
309
+ const fp = rejectionFingerprint(parsedBody, pathname);
310
+ if (!fp) return false;
311
+ const expiresAt = rejected.get(fp);
312
+ if (expiresAt === undefined) return false;
313
+ if (expiresAt <= Date.now()) {
314
+ rejected.delete(fp);
315
+ return false;
316
+ }
317
+ return true;
318
+ }
319
+
320
+ /**
321
+ * True for sessions with no proven history: responses bodies whose key was
322
+ * never established, chat bodies with at most one message and no known key.
323
+ * Fails open (false) for missing bodies.
324
+ */
325
+ export function isUnprovenSession(parsedBody: unknown, pathname: string): boolean {
326
+ if (typeof parsedBody !== "object" || parsedBody === null) return false;
327
+ ensureLoaded();
328
+ const key = sessionKeyOf(parsedBody);
329
+ if (key && isEstablished(key)) return false;
330
+ if (String(pathname ?? "").includes("responses")) return true;
331
+ const messages = (parsedBody as { messages?: unknown }).messages;
332
+ if (Array.isArray(messages) && messages.length > 1) return false;
333
+ return true;
334
+ }
335
+
336
+ /**
337
+ * Session routing while degraded: proven sessions and ungated traffic pass
338
+ * through; gated fresh sessions get one canary per window, else failover.
339
+ */
340
+ export function decideZenRoute(parsedBody: unknown, pathname: string): ZenRoute {
341
+ ensureLoaded();
342
+ if (!gateOf("zen").gated) return "passthrough";
343
+ if (!isUnprovenSession(parsedBody, pathname)) return "passthrough";
344
+ const g = gateOf("zen");
345
+ const now = Date.now();
346
+ if (!g.lastCanaryAt || now - g.lastCanaryAt >= CANARY_INTERVAL_MS) {
347
+ g.lastCanaryAt = now;
348
+ persist(true);
349
+ return "canary";
350
+ }
351
+ return "failover";
352
+ }
353
+
354
+ /** First healthy Kilo model id, else the built-in Kilo fallback. */
355
+ export function pickFailoverModel(): string {
356
+ try {
357
+ const hit = getAliveCatalog().find(
358
+ (m) => (m as { source?: string }).source === "kilo" && typeof m.id === "string" && m.id.length > 0,
359
+ );
360
+ if (hit) return hit.id;
361
+ } catch { }
362
+ return FALLBACK_KILO_MODEL;
363
+ }
364
+
365
+ /**
366
+ * Record a successful upstream turn. Plain successes only establish the
367
+ * session key and never clear the gate; a canary success clears it.
368
+ */
369
+ export function recordUpstreamSuccess(
370
+ upstream: UpstreamName,
371
+ opts?: { sessionKey?: string | null; canary?: boolean },
372
+ ): void {
373
+ ensureLoaded();
374
+ const now = Date.now();
375
+ let dirty = false;
376
+ if (typeof opts?.sessionKey === "string" && opts.sessionKey.length > 0) {
377
+ sessions.delete(opts.sessionKey);
378
+ sessions.set(opts.sessionKey, now + SESSION_TTL_MS);
379
+ dirty = true;
380
+ }
381
+ const g = gateOf(upstream);
382
+ if (opts?.canary === true) {
383
+ g.lastCanaryAt = now;
384
+ // A canary success proves Zen serves fresh sessions again, so remembered
385
+ // refusals are stale whether or not the gate flag was set.
386
+ if (rejected.size > 0) {
387
+ rejected.clear();
388
+ dirty = true;
389
+ }
390
+ if (g.gated) {
391
+ g.gated = false;
392
+ g.consecutiveFreeTier403 = 0;
393
+ g.firstGatedAt = 0;
394
+ g.lastTransitionAt = now;
395
+ log("info", "upstream recovered for new sessions", { upstream });
396
+ persist(true);
397
+ notifyGateTransition(upstream, false);
398
+ return;
399
+ }
400
+ dirty = true;
401
+ } else if (!g.gated && g.consecutiveFreeTier403 !== 0) {
402
+ g.consecutiveFreeTier403 = 0;
403
+ dirty = true;
404
+ }
405
+ if (dirty) persist(false);
406
+ }
407
+
408
+ /** Record a failed upstream turn; consecutive gate 403s flip the gate. */
409
+ export function recordUpstreamFailure(
410
+ upstream: UpstreamName,
411
+ status: number,
412
+ bodyText: unknown,
413
+ ): void {
414
+ ensureLoaded();
415
+ if (!isFreeTierGate(status, bodyText)) {
416
+ const g = gateOf(upstream);
417
+ if (g.consecutiveFreeTier403 !== 0) {
418
+ g.consecutiveFreeTier403 = 0;
419
+ persist(false);
420
+ }
421
+ return;
422
+ }
423
+ const g = gateOf(upstream);
424
+ g.consecutiveFreeTier403 += 1;
425
+ if (!g.gated && g.consecutiveFreeTier403 >= GATE_ENTER_AFTER) {
426
+ const now = Date.now();
427
+ g.gated = true;
428
+ if (!g.firstGatedAt) g.firstGatedAt = now;
429
+ g.lastTransitionAt = now;
430
+ log("warn", "upstream gating new sessions, failing over to fallback", { upstream });
431
+ persist(true);
432
+ notifyGateTransition(upstream, true);
433
+ return;
434
+ }
435
+ persist(false);
436
+ }
437
+
438
+ /** Whether the upstream is currently gated for fresh sessions. */
439
+ export function isUpstreamGated(upstream: UpstreamName): boolean {
440
+ ensureLoaded();
441
+ return gateOf(upstream).gated;
442
+ }
443
+
444
+ /** Copy of the current per-upstream health snapshot. */
445
+ export function getUpstreamHealth(upstream: UpstreamName): UpstreamHealthSnapshot {
446
+ ensureLoaded();
447
+ return { ...gateOf(upstream) };
448
+ }
449
+
450
+ /**
451
+ * Attach an actionable hint to fast-failed 403 gate bodies. Any other
452
+ * status or body passes through byte-identical. Hint notifications are
453
+ * throttled to one per 10 minutes.
454
+ */
455
+ export function withFreeTierHint(status: number, data: string): string {
456
+ if (!isFreeTierGate(status, data)) return data;
457
+ let parsed: unknown;
458
+ try {
459
+ parsed = JSON.parse(data);
460
+ } catch {
461
+ return data;
462
+ }
463
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
464
+ return data;
465
+ }
466
+ const json = JSON.stringify(attachHintForDisplay(parsed as Record<string, unknown>, FREE_TIER_HINT));
467
+ try {
468
+ const ui = getStatusUi();
469
+ const now = Date.now();
470
+ if (ui?.notify && now - lastHintNotifyAt >= HINT_NOTIFY_THROTTLE_MS) {
471
+ lastHintNotifyAt = now;
472
+ ui.notify(FREE_TIER_HINT, "warning");
473
+ }
474
+ } catch { }
475
+ return json;
476
+ }
477
+ /** Reset the degradation gate for an upstream (e.g. after client fingerprint fix or manual recovery). */
478
+ export function resetUpstreamGate(upstream: UpstreamName): void {
479
+ ensureLoaded();
480
+ const g = gateOf(upstream);
481
+ g.gated = false;
482
+ g.consecutiveFreeTier403 = 0;
483
+ g.firstGatedAt = 0;
484
+ g.lastTransitionAt = Date.now();
485
+ rejected.clear();
486
+ persist(true);
487
+ }
488
+
489
+
490
+ /** Test-only: drop all in-memory health and remove the persisted file. */
491
+ export function _resetUpstreamHealthForTest(): void {
492
+ gates.clear();
493
+ sessions.clear();
494
+ rejected.clear();
495
+ loaded = false;
496
+ lastSaveAt = 0;
497
+ lastGateNotifyAt = 0;
498
+ lastGateNotifyKey = "";
499
+ try {
500
+ fs.rmSync(resolveUpstreamHealthPath(), { force: true });
501
+ } catch { }
502
+ }
503
+
504
+ /** Test-only: reset the hint notification throttle. */
505
+ export function _resetFreeTierHintForTest(): void {
506
+ lastHintNotifyAt = 0;
507
+ }