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.
- package/CHANGELOG.md +31 -0
- package/README.md +381 -379
- package/package.json +1 -1
- package/src/catalog.ts +19 -13
- package/src/cline-accounts.ts +891 -634
- package/src/index.ts +1 -1
- package/src/models.ts +58 -30
- package/src/proxy.ts +1509 -1466
- package/src/upstream-health.ts +507 -492
package/src/upstream-health.ts
CHANGED
|
@@ -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 = "
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
export function
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
return
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
+
}
|