Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
1159 lines (1076 loc) · 46.4 KB
/
Copy pathserver.js
File metadata and controls
1159 lines (1076 loc) · 46.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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
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
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
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
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
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
'use strict';
const http = require('http');
const https = require('https');
const { URL } = require('url');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const vpn = require('./lib/vpn');
const configPath = path.join(__dirname, 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
// undefined = never configured, auto-generate one; null/"" = auth explicitly disabled, leave it
if (config.dashboardToken === undefined) {
config.dashboardToken = crypto.randomBytes(16).toString('hex');
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf8'));
onDisk.dashboardToken = config.dashboardToken;
fs.writeFileSync(configPath, JSON.stringify(onDisk, null, 2) + '\n');
}
// Default-shape the vpn section so persistConfig() always has an object to
// write. The VPN module reads config.json directly for its own state, but we
// keep config.vpn in sync so dashboard saves flow through the same path as
// everything else.
if (!config.vpn || typeof config.vpn !== 'object') {
config.vpn = {
enabled: false,
vpnName: '',
pingTarget: '',
intervalSec: 30,
waitAfterReconnectSec: 5,
};
}
const backends = config.backends.map((b, i) => ({
...b,
priority: b.priority != null ? b.priority : i + 1, // lower number = tried first
healthy: true,
requests: 0,
errors: 0,
lastLatencyMs: null,
lastError: null,
lastSwitchAt: null,
lastUsedAt: null,
retryAfterAt: null, // ISO timestamp when a rate-limit cooldown ends, or null
hardCooldown: false, // true when retryAfterAt came from a real server retry-after
tokensIn: 0, tokensOut: 0, tokensCacheRead: 0, tokensCacheWrite: 0,
budget: null,
inFlight: 0, // requests currently being served by this backend // {used,max,at} scraped from a quota 429, when the provider says
}));
// indices sorted by ascending priority (highest priority first), ties broken by config order
let priorityOrder = [];
function recomputePriorityOrder() {
priorityOrder = backends
.map((_, idx) => idx)
.sort((a, b) => backends[a].priority - backends[b].priority || a - b);
}
recomputePriorityOrder();
const STATE_PATH = path.join(__dirname, 'logs', 'state.json');
function loadState() {
try {
const saved = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
(saved.backends || []).forEach((s) => {
const b = backends.find((x) => x.name === s.name);
if (b) {
b.requests = s.requests || 0;
b.errors = s.errors || 0;
b.tokensIn = s.tokensIn || 0;
b.tokensOut = s.tokensOut || 0;
b.tokensCacheRead = s.tokensCacheRead || 0;
b.tokensCacheWrite = s.tokensCacheWrite || 0;
b.budget = s.budget || null;
}
});
} catch {
// no prior state on disk yet, start from zero
}
}
loadState();
function saveState() {
try {
fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true });
fs.writeFileSync(STATE_PATH, JSON.stringify({
savedAt: new Date().toISOString(),
backends: backends.map((b) => ({
name: b.name, requests: b.requests, errors: b.errors,
tokensIn: b.tokensIn, tokensOut: b.tokensOut,
tokensCacheRead: b.tokensCacheRead, tokensCacheWrite: b.tokensCacheWrite,
budget: b.budget,
})),
}, null, 2));
} catch {
// best-effort; losing cumulative counters on a crash is acceptable
}
}
setInterval(saveState, 15000);
process.on('SIGTERM', () => { saveState(); process.exit(0); });
process.on('SIGINT', () => { saveState(); process.exit(0); });
function persistConfig() {
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf8'));
onDisk.preemptOnRecovery = config.preemptOnRecovery !== false;
onDisk.businessHours = config.businessHours;
onDisk.pinnedBackend = config.pinnedBackend || null;
onDisk.vpn = config.vpn || {};
// `backends` (live, in-memory) is the source of truth from here on, so add/remove
// operations just work — persist only the config-shaped fields, not runtime state.
onDisk.backends = backends.map((b) => {
const persisted = { name: b.name, baseUrl: b.baseUrl, token: b.token, priority: b.priority };
if (b.businessHoursAware) persisted.businessHoursAware = true;
if (b.modelOverride) persisted.modelOverride = b.modelOverride;
if (b.firstByteTimeoutMs != null) persisted.firstByteTimeoutMs = b.firstByteTimeoutMs;
if (b.maxConcurrent) persisted.maxConcurrent = b.maxConcurrent;
return persisted;
});
const out = JSON.stringify(onDisk, null, 2) + '\n';
lastPersisted = out;
fs.writeFileSync(configPath, out);
}
// --- hot reload: pick up hand-edits to config.json ---
// Without this, editing the file while the proxy runs is silently lost: the
// next dashboard action calls persistConfig(), which rewrites the whole file
// from in-memory state and clobbers the edit. persistConfig() writes this file
// too, so remember exactly what we wrote and ignore that content — otherwise
// every dashboard change would look like an external edit.
let lastPersisted = null;
function applyExternalConfig(text) {
let next;
try {
next = JSON.parse(text);
} catch (err) {
logEvent('config-reload', `ignored invalid config.json: ${err.message}`);
return;
}
if (!Array.isArray(next.backends) || !next.backends.length) {
logEvent('config-reload', 'ignored config.json with no backends');
return;
}
for (const key of ['preemptOnRecovery', 'businessHours', 'pinnedBackend',
'firstByteTimeoutMs', 'idleTimeoutMs', 'errorCooldownMs']) {
if (key in next) config[key] = next[key];
}
// vpn section is owned by the VPN module but mirrored in config.vpn so the
// dashboard routes can mutate it via persistConfig. Keep the live copy in
// sync when the file is hand-edited too.
if (next.vpn && typeof next.vpn === 'object') config.vpn = next.vpn;
// Reuse the live object for backends that survive the edit so their counters,
// health and cooldowns aren't reset just because an unrelated field changed.
const byName = new Map(backends.map((b) => [b.name, b]));
const rebuilt = next.backends.map((nb, i) => {
const priority = nb.priority != null ? nb.priority : i + 1;
const existing = byName.get(nb.name);
if (existing) {
existing.baseUrl = nb.baseUrl;
existing.token = nb.token;
existing.priority = priority;
existing.businessHoursAware = !!nb.businessHoursAware;
existing.modelOverride = nb.modelOverride;
existing.firstByteTimeoutMs = nb.firstByteTimeoutMs;
existing.maxConcurrent = nb.maxConcurrent;
return existing;
}
return {
...nb, priority,
healthy: true, requests: 0, errors: 0, lastLatencyMs: null, lastError: null,
lastSwitchAt: null, lastUsedAt: null, retryAfterAt: null, hardCooldown: false,
tokensIn: 0, tokensOut: 0, tokensCacheRead: 0, tokensCacheWrite: 0, budget: null,
inFlight: 0,
};
});
backends.length = 0;
backends.push(...rebuilt);
if (config.pinnedBackend && !backends.some((b) => b.name === config.pinnedBackend)) {
config.pinnedBackend = null;
}
if (currentIndex >= backends.length) currentIndex = 0;
recomputePriorityOrder();
logEvent('config-reload', `reloaded from disk (${backends.length} backends)`);
}
// watchFile (polling) rather than watch(): editors that save by rename would
// silently detach an fs.watch handle.
fs.watchFile(configPath, { interval: 2000 }, () => {
let text;
try {
text = fs.readFileSync(configPath, 'utf8');
} catch {
return; // mid-write or momentarily missing; the next poll will catch it
}
if (text === lastPersisted) return; // our own write
applyExternalConfig(text);
});
let currentIndex = 0;
const events = []; // ring buffer of recent events
const MAX_EVENTS = 100;
const LOG_DIR = path.join(__dirname, 'logs');
const APP_LOG = path.join(LOG_DIR, 'app.log');
const MAX_LOG_BYTES = 5 * 1024 * 1024;
const MAX_LOG_BACKUPS = 3;
function rotateLogIfNeeded() {
let size = 0;
try {
size = fs.statSync(APP_LOG).size;
} catch {
return;
}
if (size < MAX_LOG_BYTES) return;
for (let i = MAX_LOG_BACKUPS; i >= 1; i--) {
const src = i === 1 ? APP_LOG : `${APP_LOG}.${i - 1}`;
const dst = `${APP_LOG}.${i}`;
try {
fs.renameSync(src, dst);
} catch {
// no such backup yet, nothing to rotate at this slot
}
}
}
function writeLog(line) {
try {
fs.mkdirSync(LOG_DIR, { recursive: true });
rotateLogIfNeeded();
fs.appendFileSync(APP_LOG, line + '\n');
} catch {
// logging must never crash the proxy itself
}
}
function logEvent(type, detail) {
events.unshift({ ts: new Date().toISOString(), type, detail });
if (events.length > MAX_EVENTS) events.pop();
writeLog(`[${new Date().toISOString()}] ${type}: ${detail}`);
}
function isAvailable(b) {
if (b.retryAfterAt && Date.now() >= new Date(b.retryAfterAt).getTime()) {
// Cooldown elapsed: clear the lock, but DON'T flip to healthy on a guess.
// A recovered backend must be re-confirmed by a successful real request
// before it can preempt a healthy lower-priority one — otherwise a 4xx
// backend (e.g. an expired key) that the passive HEAD probe can't tell
// from real health would get preferred over a working backend every
// cooldown window. probe-pending (isProbePending) makes it retryable as a
// last resort in pickAvailableIndex's second pass instead.
b.retryAfterAt = null;
b.hardCooldown = false;
}
return b.healthy;
}
// A backend whose cooldown has elapsed but hasn't yet been confirmed healthy
// by a successful real request. It's not "available" (don't prefer it), but
// it's retryable as a last resort — unlike a hard-cooldown backend which must
// be respected.
function isProbePending(b) {
return !b.healthy && !b.retryAfterAt && !b.hardCooldown;
}
// Always prefer the highest-priority available backend, skipping ones already
// tried in this request. This makes a recovered higher-priority backend get
// used again immediately on the very next request, instead of sticking with
// whatever lower-priority backend the previous request fell back to.
// A backend with maxConcurrent set only accepts that many in-flight requests
// before new ones prefer the next backend down. This is what keeps a burst of
// subagents from concentrating on one key: unlike rotation it leaves the
// priority order intact, and only diverts the overflow.
function acquireSlot(b) {
b.inFlight++;
let released = false;
return () => {
if (released) return; // idempotent: several exit paths can fire for one request
released = true;
b.inFlight = Math.max(0, b.inFlight - 1);
};
}
function hasCapacity(b) {
return !b.maxConcurrent || b.inFlight < b.maxConcurrent;
}
function pickAvailableIndex(triedSet) {
for (const idx of priorityOrder) {
if (triedSet.has(idx)) continue;
if (isAvailable(backends[idx]) && hasCapacity(backends[idx])) return idx;
}
// Every healthy backend is at its cap. The cap exists to shape preference,
// not to reject work — sending this request anyway is far better than
// failing it, so fall back to priority order ignoring capacity.
for (const idx of priorityOrder) {
if (triedSet.has(idx)) continue;
if (isAvailable(backends[idx])) return idx;
}
// No healthy untried backend. As a last resort, try one whose cooldown has
// elapsed but hasn't been re-confirmed yet (probe-pending) — prefer in
// priority order. This lets a real request validate recovery instead of the
// caller failing when traffic is the only honest probe. Hard cooldowns are
// still respected: never retry one early.
for (const idx of priorityOrder) {
if (triedSet.has(idx)) continue;
const b = backends[idx];
if (b.hardCooldown) continue;
if (isProbePending(b)) return idx;
}
// Otherwise retry whichever untried soft-cooldown backend recovers soonest.
let bestIdx = null;
let bestAt = Infinity;
for (const idx of priorityOrder) {
if (triedSet.has(idx)) continue;
if (backends[idx].hardCooldown) continue;
const at = backends[idx].retryAfterAt ? new Date(backends[idx].retryAfterAt).getTime() : Infinity;
if (at < bestAt) {
bestAt = at;
bestIdx = idx;
}
}
return bestIdx;
}
// Preemption strategy, user-editable in config.json:
// "preemptOnRecovery": true -> always use the highest-priority available backend,
// switching back the instant a higher one recovers (default)
// "preemptOnRecovery": false -> sticky: keep using the current backend until it fails,
// only falling back to priority order at that point
//
// "pinnedBackend": manual override from the dashboard. Forced first on every new
// request even if it's down or on a soft (guessed) cooldown — the user explicitly
// asked for this one, and if it fails the request still fails over through normal
// priority order rather than hard-erroring. It does NOT override a hard cooldown
// (a real server-issued retry-after, e.g. a 429): retrying an active rate limit
// on purpose just earns another 429 and can make the account-level limit worse.
function pickStartIndex(triedSet) {
if (config.pinnedBackend) {
const idx = backends.findIndex((b) => b.name === config.pinnedBackend);
// A pin defers to the concurrency cap: capping exists precisely to stop a
// burst from piling onto one key, and a pin is usually set on the very key
// being protected. Overflow goes down the priority list; the pin resumes
// taking traffic as soon as a slot frees up.
if (idx !== -1 && !triedSet.has(idx) && !backends[idx].hardCooldown &&
hasCapacity(backends[idx])) return idx;
}
if (config.preemptOnRecovery === false) {
if (!triedSet.has(currentIndex) && isAvailable(backends[currentIndex])) return currentIndex;
}
return pickAvailableIndex(triedSet);
}
// A server-provided retry-after at or below this is treated as transient
// throttling: wait it out and retry the same backend rather than failing over.
const SHORT_COOLDOWN_MAX_SECONDS = 60;
// Total time a single request may spend waiting out throttles before it gives
// up and fails over, so a provider that keeps saying "retry in 60s" can't stall
// the caller indefinitely.
const MAX_THROTTLE_WAIT_TOTAL_MS = 45000;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// When a failure carries no server-provided retry-after (connection hang, plain
// 5xx), we don't know the real reactivation time. If business hours are enabled,
// assume the backend is only expected to work inside that window: a short retry
// while we're inside it, or a wait straight through to the next window's start
// if we're outside it (so we stop hammering it overnight/weekends for nothing).
function unknownCooldownSeconds(now, backend) {
const bh = config.businessHours;
const shortRetry = (config.errorCooldownMs || 30000) / 1000;
if (!bh || !bh.enabled || !backend.businessHoursAware) return shortRetry;
const days = bh.days && bh.days.length ? bh.days : [1, 2, 3, 4, 5];
const startHour = bh.startHour != null ? bh.startHour : 9;
const endHour = bh.endHour != null ? bh.endHour : 21;
const hourNow = now.getHours() + now.getMinutes() / 60;
if (days.includes(now.getDay()) && hourNow >= startHour && hourNow < endHour) {
return shortRetry;
}
for (let addDays = 0; addDays <= 8; addDays++) {
const candidate = new Date(now);
candidate.setDate(candidate.getDate() + addDays);
candidate.setHours(startHour, 0, 0, 0);
if (candidate > now && days.includes(candidate.getDay())) {
return Math.max(1, Math.round((candidate.getTime() - now.getTime()) / 1000));
}
}
return shortRetry;
}
function markUnhealthy(idx, reason, retryAfterSeconds) {
const b = backends[idx];
if (b.healthy) {
b.healthy = false;
b.lastSwitchAt = new Date().toISOString();
}
b.errors++;
b.lastError = reason;
// Every failure gets a cooldown, even ones with no server-provided retry-after
// (e.g. a connection hang from an IP-restricted host), so a single request
// never re-pays the same slow timeout on every subsequent call.
const seconds = retryAfterSeconds != null ? retryAfterSeconds : unknownCooldownSeconds(new Date(), b);
const at = new Date(Date.now() + seconds * 1000).toISOString();
b.retryAfterAt = at;
// Providers use 429 for two very different things: a short retry-after (a few
// seconds) is transient throttling — "you're sending too fast" — while a long
// one means the quota/rate window is actually exhausted. Only the long kind is
// an authoritative lockout that the "nothing else available" fallback and the
// manual pin must respect; treating a 5-second throttle as a lockout would
// bounce traffic onto other backends (and away from a pinned one) needlessly.
b.hardCooldown = retryAfterSeconds != null && retryAfterSeconds > SHORT_COOLDOWN_MAX_SECONDS;
logEvent('backend-down', `${b.name} unhealthy: ${reason}, retry in ${seconds}s (until ${at})`);
}
function markHealthyByRef(b) {
b.healthy = true;
b.retryAfterAt = null;
b.hardCooldown = false;
logEvent('backend-up', `${b.name} recovered`);
}
function markHealthy(idx) {
const b = backends[idx];
if (!b.healthy) markHealthyByRef(b);
}
const FAILOVER_STATUS = new Set([401, 403, 429, 500, 502, 503, 504]);
// Anthropic reports token counts in the response body: one `usage` object for a
// normal reply, or split across SSE events when streaming — `message_start`
// carries the input/cache counts and each `message_delta` carries the running
// output total. Collect both shapes while the bytes are already flowing through
// so the dashboard can show real spend, without buffering a whole stream.
const MAX_USAGE_BUFFER = 4 * 1024 * 1024;
function createUsageCollector(isStream) {
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
let seen = false;
let buf = '';
// Within one request these counters only ever grow (input is reported once,
// output arrives as a running total), so max() is correct for both shapes.
const take = (u) => {
if (!u || typeof u !== 'object') return;
const pick = (key, field) => {
const v = u[key];
if (typeof v === 'number' && v > usage[field]) { usage[field] = v; seen = true; }
};
pick('input_tokens', 'input');
pick('output_tokens', 'output');
pick('cache_read_input_tokens', 'cacheRead');
pick('cache_creation_input_tokens', 'cacheWrite');
};
return {
push(chunk) {
const text = chunk.toString('utf8');
if (!isStream) {
// Non-streaming bodies are bounded by max_tokens; parse once at the end.
if (buf.length < MAX_USAGE_BUFFER) buf += text;
return;
}
// Streaming: parse whole SSE data lines as they complete, keeping only
// the trailing partial line so memory stays flat on long responses.
buf += text;
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data:')) continue;
try {
const evt = JSON.parse(line.slice(5).trim());
take(evt.usage);
take(evt.message && evt.message.usage);
} catch {
// keepalive/ping lines and partial JSON: nothing to read here
}
}
},
finish() {
if (!isStream && buf) {
try { take(JSON.parse(buf).usage); } catch { /* not JSON */ }
}
return seen ? usage : null;
},
};
}
function recordUsage(backend, usage) {
if (!usage) return;
backend.tokensIn += usage.input;
backend.tokensOut += usage.output;
backend.tokensCacheRead += usage.cacheRead;
backend.tokensCacheWrite += usage.cacheWrite;
}
// Some gateways only reveal quota in the body of the 429 that rejects you, e.g.
// "Budget has been exceeded! ... Current cost: 82209617.0, Max budget: 80000000.0".
// No provider we talk to exposes remaining quota on a successful call, so this
// opportunistic scrape is the only quota signal available.
function parseBudget(text) {
const m = /Current cost:\s*([\d.]+).*?Max budget:\s*([\d.]+)/s.exec(text || '');
if (!m) return null;
return { used: Number(m[1]), max: Number(m[2]), at: new Date().toISOString() };
}
// Retry-After is either delay-seconds ("120") or an HTTP-date
// ("Fri, 07 Aug 2026 04:52:33 GMT") per RFC 7231 7.1.3.
function parseRetryAfter(header) {
if (header == null) return null;
const asSeconds = Number(header);
if (!isNaN(asSeconds)) return asSeconds;
const asDate = Date.parse(header);
if (!isNaN(asDate)) return Math.max(0, Math.round((asDate - Date.now()) / 1000));
return null;
}
// Claude Code sends whatever ANTHROPIC_DEFAULT_*_MODEL is configured locally
// (one fixed name for every backend) as the request body's "model" field.
// Backends with a different model naming scheme (e.g. Kimi's "k3" vs our
// local "glm-5.2") need that field rewritten before forwarding, or the
// backend rejects the request as an unrecognized model.
// Providers disagree on empty text blocks: Kimi rejects them outright with
// "text content is empty", while GLM accepts them. Clients legitimately produce
// them — e.g. Claude Code recording a turn that was interrupted mid-stream — so
// the same conversation would work on one backend and 400 on another. Empty text
// carries no information, so strip it and make every backend behave the same.
function stripEmptyTextBlocks(payload) {
let changed = false;
const isEmptyText = (b) =>
b && b.type === 'text' && typeof b.text === 'string' && b.text.trim() === '';
const clean = (blocks) => {
const kept = [];
let removed = false;
for (const b of blocks) {
if (isEmptyText(b)) { removed = true; changed = true; continue; }
// tool_result blocks carry their own nested content array — a tool that
// produced no output lands here, which is exactly the common case.
if (b && Array.isArray(b.content)) b.content = clean(b.content);
kept.push(b);
}
// Never leave a message with no content: that is invalid everywhere, and
// dropping the message outright would break user/assistant alternation.
if (!kept.length && removed) return [{ type: 'text', text: ' ' }];
return kept;
};
if (Array.isArray(payload.messages)) {
for (const msg of payload.messages) {
if (msg && Array.isArray(msg.content)) msg.content = clean(msg.content);
}
}
if (Array.isArray(payload.system)) payload.system = clean(payload.system);
return changed;
}
// Claude Code sends whatever ANTHROPIC_DEFAULT_*_MODEL is configured locally
// (one fixed name for every backend) as the request body's "model" field.
// Backends with a different model naming scheme (e.g. Kimi's "k3" vs our
// local "glm-5.2") need that field rewritten before forwarding, or the
// backend rejects the request as an unrecognized model.
function prepareBodyForBackend(backend, bodyBuffer) {
if (!bodyBuffer || !bodyBuffer.length) return bodyBuffer;
let parsed;
try {
parsed = JSON.parse(bodyBuffer.toString('utf8'));
} catch {
return bodyBuffer; // not a JSON body (e.g. GET with no body) — leave untouched
}
if (typeof parsed !== 'object' || parsed === null) return bodyBuffer;
let changed = false;
if (backend.modelOverride && 'model' in parsed) {
parsed.model = backend.modelOverride;
changed = true;
}
if (stripEmptyTextBlocks(parsed)) changed = true;
return changed ? Buffer.from(JSON.stringify(parsed), 'utf8') : bodyBuffer;
}
function forwardOnce(backend, req, bodyBuffer) {
return new Promise((resolve, reject) => {
const target = new URL(backend.baseUrl.replace(/\/+$/, '') + req.url);
const isHttps = target.protocol === 'https:';
const lib = isHttps ? https : http;
const effectiveBody = prepareBodyForBackend(backend, bodyBuffer);
const headers = { ...req.headers };
delete headers.host;
delete headers['content-length'];
headers['x-api-key'] = backend.token;
headers['authorization'] = `Bearer ${backend.token}`;
const options = {
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
path: target.pathname + target.search,
method: req.method,
headers,
};
const start = Date.now();
// Reasoning models (e.g. Kimi's k3) think before emitting the first token,
// so time-to-first-byte can far exceed what a normal backend needs. Let a
// backend override the global budget rather than being failed over every time.
const firstByteTimeoutMs = backend.firstByteTimeoutMs != null
? backend.firstByteTimeoutMs
: config.firstByteTimeoutMs;
let firstByteTimer = setTimeout(() => {
upstreamReq.destroy(new Error('first-byte-timeout'));
}, firstByteTimeoutMs);
const upstreamReq = lib.request(options, (upstreamRes) => {
clearTimeout(firstByteTimer);
const latency = Date.now() - start;
resolve({ upstreamRes, latency });
});
upstreamReq.on('error', (err) => {
clearTimeout(firstByteTimer);
reject(err);
});
if (effectiveBody && effectiveBody.length) upstreamReq.write(effectiveBody);
upstreamReq.end();
});
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
// Drain an upstream response body into a string so we can classify 4xx errors
// (auth-403 vs model-access-403). Bounded by a small cap so a huge error page
// can't stall failover.
function readUpstreamBody(upstreamRes, cap = 8192) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
upstreamRes.on('data', (c) => {
size += c.length;
if (size <= cap) chunks.push(c);
});
upstreamRes.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
upstreamRes.on('error', reject);
});
}
async function handleProxyRequest(req, res) {
const bodyBuffer = await readBody(req).catch(() => Buffer.alloc(0));
const tried = new Set();
let lastErr = null;
let throttleWaits = 0;
while (tried.size < backends.length) {
const idx = pickStartIndex(tried);
if (idx == null) break;
tried.add(idx);
const backend = backends[idx];
backend.requests++;
backend.lastUsedAt = new Date().toISOString();
// Occupy a concurrency slot for exactly as long as this backend is working
// on the request. Every exit has to release it — upstream failure, failover,
// normal end, mid-stream error, or the client hanging up — or the count only
// ever climbs and the backend looks permanently saturated.
const release = acquireSlot(backend);
try {
const { upstreamRes, latency } = await forwardOnce(backend, req, bodyBuffer);
if (FAILOVER_STATUS.has(upstreamRes.statusCode)) {
const retryAfterSeconds = parseRetryAfter(upstreamRes.headers['retry-after']);
// 401/403 are not transient — they're key expiry or model-access
// denials. Retrying in 30s just re-logs the same error and inflates
// the error counter; read the body to tell auth-403 from
// model-access-403, then cool down hard.
let reason = `HTTP ${upstreamRes.statusCode}`;
let cooldown = retryAfterSeconds;
if (upstreamRes.statusCode === 401 || upstreamRes.statusCode === 403) {
const bodyText = await readUpstreamBody(upstreamRes).catch(() => '');
upstreamRes.resume();
const lower = bodyText.toLowerCase();
const isModelAccess = lower.includes('model') && (
lower.includes('not allowed') || lower.includes('access') ||
lower.includes('denied') || lower.includes('permission'));
reason = isModelAccess
? `HTTP ${upstreamRes.statusCode} (model-access-denied)`
: `HTTP ${upstreamRes.statusCode} (auth)`;
// Model-access denial won't change in 30s — cool it to next business
// window (or 1h if no business hours). Plain auth (bad/expired key)
// gets the same long cool: the user has to fix the key, not us.
cooldown = unknownCooldownSeconds(new Date(), backend);
if (!cooldown || cooldown * 1000 < 60 * 60 * 1000) {
cooldown = 60 * 60; // 1h floor when business-hours math gives short
}
} else if (upstreamRes.statusCode === 429) {
// A quota-style 429 body sometimes carries the only budget figures
// this provider ever exposes — grab them on the way past.
const bodyText = await readUpstreamBody(upstreamRes).catch(() => '');
upstreamRes.resume();
const budget = parseBudget(bodyText);
if (budget) backend.budget = budget;
} else {
upstreamRes.resume(); // drain
}
markUnhealthy(idx, reason, cooldown);
release(); // done with this backend; free the slot before any wait
// Transient throttle ("slow down", a few seconds) rather than an
// exhausted quota. Failing over is pointless when the user pinned this
// backend, or when nothing else is left to try — wait it out and retry
// the same one instead of abandoning it or erroring the request.
const isShortThrottle = retryAfterSeconds != null &&
retryAfterSeconds <= SHORT_COOLDOWN_MAX_SECONDS;
const noAlternative = pickAvailableIndex(tried) == null;
const waitMs = retryAfterSeconds * 1000;
if (isShortThrottle && throttleWaits + waitMs <= MAX_THROTTLE_WAIT_TOTAL_MS &&
(config.pinnedBackend === backend.name || noAlternative)) {
throttleWaits += waitMs;
logEvent('backend-throttled',
`${backend.name} throttled ${retryAfterSeconds}s, waiting to retry same backend`);
await sleep(waitMs);
// A concurrent request may have hit a real quota wall on this backend
// while we slept; that lockout is authoritative, so don't wipe it.
if (!backend.hardCooldown) {
backend.healthy = true;
backend.retryAfterAt = null;
}
tried.delete(idx);
continue;
}
continue;
}
markHealthy(idx);
backend.lastLatencyMs = latency;
currentIndex = idx;
res.writeHead(upstreamRes.statusCode, upstreamRes.headers);
const collector = createUsageCollector(
/event-stream/i.test(upstreamRes.headers['content-type'] || ''));
let idleTimer = setTimeout(() => {
upstreamRes.destroy(new Error('idle-timeout'));
}, config.idleTimeoutMs);
upstreamRes.on('data', (chunk) => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
upstreamRes.destroy(new Error('idle-timeout'));
}, config.idleTimeoutMs);
res.write(chunk); // forward first; accounting must never delay the client
collector.push(chunk);
});
upstreamRes.on('end', () => {
clearTimeout(idleTimer);
recordUsage(backend, collector.finish());
release();
res.end();
});
upstreamRes.on('error', () => {
clearTimeout(idleTimer);
release();
res.end();
});
// The client can hang up mid-stream; without this the slot leaks.
res.on('close', release);
return;
} catch (err) {
release();
lastErr = err;
markUnhealthy(idx, err.message);
}
}
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'all_backends_unavailable', lastError: lastErr && lastErr.message }));
}
const proxyServer = http.createServer((req, res) => {
handleProxyRequest(req, res).catch((err) => {
logEvent('proxy-error', err.message);
if (!res.headersSent) res.writeHead(500);
res.end();
});
});
proxyServer.listen(config.proxyPort, '127.0.0.1', () => {
logEvent('startup', `proxy listening on 127.0.0.1:${config.proxyPort}`);
});
// --- passive health check for downed backends without a known retry-after ---
setInterval(() => {
backends.forEach((b, idx) => {
if (b.healthy) return;
// Wait out an *active* cooldown instead of hammering with probes — but once
// it has lapsed, probe. Testing the field for mere presence would skip
// forever: retryAfterAt is only cleared lazily by isAvailable() on the
// request path, so an idle backend would never get re-confirmed and could
// never preempt its way back to the front.
if (b.retryAfterAt && Date.now() < new Date(b.retryAfterAt).getTime()) return;
const target = new URL(b.baseUrl);
const isHttps = target.protocol === 'https:';
const lib = isHttps ? https : http;
const req = lib.request(
{ hostname: target.hostname, port: target.port || (isHttps ? 443 : 80), path: '/', method: 'HEAD', timeout: 5000 },
(r) => {
r.resume();
// 4xx (auth/quota/model-access) is NOT health. A 401/403 backend will
// fail every real request the same way it failed the probe; marking it
// healthy here just triggers the 30s bounce loop again on the next call.
// Treat 2xx/3xx as recovered; leave 4xx/5xx to their existing cooldown.
if (r.statusCode && r.statusCode < 400) markHealthy(idx);
}
);
req.on('timeout', () => req.destroy());
req.on('error', () => {});
req.end();
});
}, config.healthCheckIntervalMs);
// proactively flip backends back to healthy once their retry-after cooldown elapses,
// so the dashboard reflects recovery even with no live traffic
setInterval(() => {
backends.forEach((b) => isAvailable(b));
}, 5000);
// --- dashboard server ---
// The dashboard is shipped as a standalone HTML file so the VPN panel and any
// future UI work can be edited without wading through a 300-line template
// literal. Load it once at startup; if it's missing (e.g. running from a
// source tree that hasn't been synced) fall back to a stub that makes the
// problem obvious instead of rendering an empty page.
let DASHBOARD_HTML;
try {
DASHBOARD_HTML = fs.readFileSync(path.join(__dirname, 'public', 'dashboard.html'), 'utf8');
} catch (e) {
DASHBOARD_HTML = '<!doctype html><html><body><h1>dashboard.html missing</h1><p>public/dashboard.html was not found next to server.js.</p></body></html>';
}
function redactBackends() {
return backends.map(({ token, ...rest }) => rest);
}
function checkDashboardAuth(req, query) {
const token = config.dashboardToken;
if (!token) return true;
if (query.get('token') === token) return true;
const cookie = req.headers.cookie || '';
const match = cookie.match(/(?:^|;\s*)dt=([^;]+)/);
if (match && match[1] === token) return true;
if (req.headers['x-dashboard-token'] === token) return true;
return false;
}
const dashboardServer = http.createServer((req, res) => {
const reqUrl = new URL(req.url, 'http://localhost');
if (!checkDashboardAuth(req, reqUrl.searchParams)) {
res.writeHead(401, { 'content-type': 'text/plain' });
res.end(`Unauthorized. Open http://127.0.0.1:${config.dashboardPort}/?token=<your-token> once (token is in config.json) to set a session cookie.`);
return;
}
if (req.url.startsWith('/api/status')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
currentIndex,
preemptOnRecovery: config.preemptOnRecovery !== false,
pinnedBackend: config.pinnedBackend || null,
businessHours: config.businessHours,
backends: redactBackends(),
events,
}));
return;
}
if (req.url === '/api/config' && req.method === 'POST') {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
try {
const update = JSON.parse(body || '{}');
if (Array.isArray(update.priorities)) {
// Resolve and validate every entry before touching any of them: a
// rejected request must not leave half the priorities applied.
const resolved = update.priorities.map((entry) => {
const b = backends.find((x) => x.name === entry.name);
if (!b) throw new Error(`unknown backend: ${entry.name}`);
const priority = Number(entry.priority);
if (!Number.isFinite(priority)) throw new Error(`invalid priority for ${entry.name}`);
return { backend: b, priority };
});
for (const { backend, priority } of resolved) backend.priority = priority;
recomputePriorityOrder();
}
if (Array.isArray(update.concurrency)) {
// validate everything before applying, same as priorities
const resolved = update.concurrency.map((entry) => {
const bk = backends.find((x) => x.name === entry.name);
if (!bk) throw new Error(`unknown backend: ${entry.name}`);
const raw = entry.maxConcurrent;
if (raw === null || raw === '' || raw === 0) return { backend: bk, max: 0 };
const max = Number(raw);
if (!Number.isInteger(max) || max < 0) {
throw new Error(`invalid maxConcurrent for ${entry.name}`);
}
return { backend: bk, max };
});
for (const { backend, max } of resolved) backend.maxConcurrent = max || undefined;
}
if (typeof update.preemptOnRecovery === 'boolean') {
config.preemptOnRecovery = update.preemptOnRecovery;
}
if ('pinnedBackend' in update) {
if (update.pinnedBackend === null || update.pinnedBackend === '') {
config.pinnedBackend = null;
} else if (backends.some((b) => b.name === update.pinnedBackend)) {
config.pinnedBackend = update.pinnedBackend;
} else {
throw new Error(`unknown backend: ${update.pinnedBackend}`);
}
}
if (update.businessHours && typeof update.businessHours === 'object') {
const bh = update.businessHours;
const startHour = Number(bh.startHour);
const endHour = Number(bh.endHour);
if (!Number.isFinite(startHour) || startHour < 0 || startHour > 23) throw new Error('invalid startHour');
if (!Number.isFinite(endHour) || endHour < 1 || endHour > 24) throw new Error('invalid endHour');
if (!Array.isArray(bh.days) || bh.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6)) {
throw new Error('invalid days (expect array of 0-6, 0=Sunday)');
}
config.businessHours = {
enabled: bh.enabled !== false,
days: bh.days,
startHour,
endHour,
};
}
persistConfig();
logEvent('config-update', `updated via dashboard: ${body}`);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(400, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
}
if (req.url === '/api/backends' && req.method === 'POST') {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
try {
const input = JSON.parse(body || '{}');
const name = String(input.name || '').trim();
const baseUrl = String(input.baseUrl || '').trim();
const token = String(input.token || '').trim();
if (!name) throw new Error('name is required');
if (backends.some((b) => b.name === name)) throw new Error(`backend "${name}" already exists`);
if (!baseUrl) throw new Error('baseUrl is required');
try {
new URL(baseUrl);
} catch {
throw new Error('baseUrl is not a valid URL');
}
if (!token) throw new Error('token is required');
const maxPriority = backends.reduce((m, b) => Math.max(m, b.priority), 0);
const priority = Number.isFinite(Number(input.priority)) && input.priority !== ''
? Number(input.priority)
: maxPriority + 1;
const backend = {
name,
baseUrl,
token,
priority,
businessHoursAware: !!input.businessHoursAware,
healthy: true,
requests: 0,
errors: 0,
lastLatencyMs: null,
lastError: null,
lastSwitchAt: null,
lastUsedAt: null,
retryAfterAt: null,
hardCooldown: false,
tokensIn: 0, tokensOut: 0, tokensCacheRead: 0, tokensCacheWrite: 0,
budget: null,