free-coding-models 0.2.17 → 0.3.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.
@@ -21,6 +21,11 @@ import { classifyError } from './error-classifier.js'
21
21
  import { applyThinkingBudget, compressContext } from './request-transformer.js'
22
22
  import { TokenStats } from './token-stats.js'
23
23
  import { createHash } from 'node:crypto'
24
+ import {
25
+ translateAnthropicToOpenAI,
26
+ translateOpenAIToAnthropic,
27
+ createAnthropicSSETransformer,
28
+ } from './anthropic-translator.js'
24
29
 
25
30
  // ─── Helpers ─────────────────────────────────────────────────────────────────
26
31
 
@@ -51,16 +56,31 @@ function stripRateLimitHeaders(headers) {
51
56
  return result
52
57
  }
53
58
 
59
+ // 📖 Max body size limit to prevent memory exhaustion attacks (10 MB)
60
+ const MAX_BODY_SIZE = 10 * 1024 * 1024
61
+
54
62
  /**
55
63
  * Buffer all chunks from an http.IncomingMessage and return the body as a string.
64
+ * Enforces a size limit to prevent memory exhaustion from oversized payloads.
56
65
  *
57
66
  * @param {http.IncomingMessage} req
58
67
  * @returns {Promise<string>}
68
+ * @throws {Error} with statusCode 413 if body exceeds MAX_BODY_SIZE
59
69
  */
60
70
  function readBody(req) {
61
71
  return new Promise((resolve, reject) => {
62
72
  const chunks = []
63
- req.on('data', chunk => chunks.push(chunk))
73
+ let totalSize = 0
74
+ req.on('data', chunk => {
75
+ totalSize += chunk.length
76
+ if (totalSize > MAX_BODY_SIZE) {
77
+ req.destroy()
78
+ const err = new Error('Request body too large')
79
+ err.statusCode = 413
80
+ return reject(err)
81
+ }
82
+ chunks.push(chunk)
83
+ })
64
84
  req.on('end', () => resolve(Buffer.concat(chunks).toString()))
65
85
  req.on('error', reject)
66
86
  })
@@ -114,8 +134,13 @@ export class ProxyServer {
114
134
  this._proxyApiKey = proxyApiKey
115
135
  this._accounts = accounts
116
136
  this._upstreamTimeoutMs = upstreamTimeoutMs
137
+ // 📖 Progressive backoff delays (ms) for retries — first attempt is immediate,
138
+ // subsequent ones add increasing delay + random jitter (0-100ms) to avoid
139
+ // re-hitting the same rate-limit window on 429s from providers
140
+ this._retryDelays = [0, 300, 800]
117
141
  this._accountManager = new AccountManager(accounts, accountManagerOpts)
118
142
  this._tokenStats = new TokenStats(tokenStatsOpts)
143
+ this._startTime = Date.now()
119
144
  this._running = false
120
145
  this._listeningPort = null
121
146
  this._server = http.createServer((req, res) => this._handleRequest(req, res))
@@ -173,15 +198,34 @@ export class ProxyServer {
173
198
  // ── Request routing ────────────────────────────────────────────────────────
174
199
 
175
200
  _handleRequest(req, res) {
201
+ // 📖 Health endpoint is unauthenticated so external monitors can probe it
202
+ if (req.method === 'GET' && req.url === '/v1/health') {
203
+ return this._handleHealth(res)
204
+ }
205
+
176
206
  if (!this._isAuthorized(req)) {
177
207
  return sendJson(res, 401, { error: 'Unauthorized' })
178
208
  }
179
209
 
180
210
  if (req.method === 'GET' && req.url === '/v1/models') {
181
211
  this._handleModels(res)
212
+ } else if (req.method === 'GET' && req.url === '/v1/stats') {
213
+ this._handleStats(res)
182
214
  } else if (req.method === 'POST' && req.url === '/v1/chat/completions') {
183
215
  this._handleChatCompletions(req, res).catch(err => {
184
- sendJson(res, 500, { error: 'Internal server error', message: err.message })
216
+ console.error('[proxy] Internal error:', err)
217
+ // 📖 Return 413 for body-too-large, generic 500 for everything else — never leak stack traces
218
+ const status = err.statusCode === 413 ? 413 : 500
219
+ const msg = err.statusCode === 413 ? 'Request body too large' : 'Internal server error'
220
+ sendJson(res, status, { error: msg })
221
+ })
222
+ } else if (req.method === 'POST' && req.url === '/v1/messages') {
223
+ // 📖 Anthropic Messages API translation — enables Claude Code compatibility
224
+ this._handleAnthropicMessages(req, res).catch(err => {
225
+ console.error('[proxy] Internal error:', err)
226
+ const status = err.statusCode === 413 ? 413 : 500
227
+ const msg = err.statusCode === 413 ? 'Request body too large' : 'Internal server error'
228
+ sendJson(res, status, { error: msg })
185
229
  })
186
230
  } else if (req.method === 'POST' && (req.url === '/v1/completions' || req.url === '/v1/responses')) {
187
231
  // These legacy/alternative OpenAI endpoints are not supported by the proxy.
@@ -273,10 +317,14 @@ export class ProxyServer {
273
317
  }
274
318
  }
275
319
 
276
- // 5. Retry loop
320
+ // 5. Retry loop with progressive backoff
277
321
  let pendingSwitchReason = null
278
322
  let previousAccount = null
279
323
  for (let attempt = 0; attempt < this._retries; attempt++) {
324
+ // 📖 Apply backoff delay before retries (first attempt is immediate)
325
+ const delay = this._retryDelays[Math.min(attempt, this._retryDelays.length - 1)]
326
+ if (delay > 0) await new Promise(r => setTimeout(r, delay + Math.random() * 100))
327
+
280
328
  // First attempt: respect sticky session.
281
329
  // Subsequent retries: fresh P2C (don't hammer the same failed account).
282
330
  const selectOpts = attempt === 0
@@ -354,7 +402,13 @@ export class ProxyServer {
354
402
 
355
403
  // Build the full upstream URL from the account's base URL
356
404
  const baseUrl = account.url.replace(/\/$/, '')
357
- const upstreamUrl = new URL(baseUrl + '/chat/completions')
405
+ let upstreamUrl
406
+ try {
407
+ upstreamUrl = new URL(baseUrl + '/chat/completions')
408
+ } catch {
409
+ // 📖 Malformed upstream URL — resolve as network error so retry loop can continue
410
+ return resolve({ done: false, statusCode: 0, responseBody: 'Invalid upstream URL', networkError: true })
411
+ }
358
412
 
359
413
  // Choose http or https module BEFORE creating the request
360
414
  const client = selectClient(account.url)
@@ -390,10 +444,14 @@ export class ProxyServer {
390
444
 
391
445
  // Tap the data stream to capture usage from the last data line.
392
446
  // Register BEFORE pipe() so both listeners share the same event queue.
447
+ // 📖 sseLineBuffer persists between chunks to handle lines split across boundaries
393
448
  let lastChunkData = ''
449
+ let sseLineBuffer = ''
394
450
  upstreamRes.on('data', chunk => {
395
- const text = chunk.toString()
396
- const lines = text.split('\n')
451
+ sseLineBuffer += chunk.toString()
452
+ const lines = sseLineBuffer.split('\n')
453
+ // 📖 Last element may be an incomplete line — keep it for next chunk
454
+ sseLineBuffer = lines.pop() || ''
397
455
  for (const line of lines) {
398
456
  if (line.startsWith('data: ') && !line.includes('[DONE]')) {
399
457
  lastChunkData = line.slice(6).trim()
@@ -433,6 +491,10 @@ export class ProxyServer {
433
491
  this._persistQuotaSnapshot(account, quotaUpdated)
434
492
  })
435
493
 
494
+ // 📖 Error handlers on both sides of the pipe to prevent uncaught errors
495
+ upstreamRes.on('error', err => { if (!clientRes.destroyed) clientRes.destroy(err) })
496
+ clientRes.on('error', () => { if (!upstreamRes.destroyed) upstreamRes.destroy() })
497
+
436
498
  // Pipe after listeners are registered; upstream → client, no buffering
437
499
  upstreamRes.pipe(clientRes)
438
500
 
@@ -590,4 +652,424 @@ export class ProxyServer {
590
652
  ...(account.modelId !== undefined && { modelId: account.modelId }),
591
653
  })
592
654
  }
655
+
656
+ // ── GET /v1/health ──────────────────────────────────────────────────────────
657
+
658
+ /**
659
+ * 📖 Health endpoint for daemon liveness checks. Unauthenticated so external
660
+ * monitors (TUI, launchctl, systemd) can probe without needing the token.
661
+ */
662
+ _handleHealth(res) {
663
+ const status = this.getStatus()
664
+ sendJson(res, 200, {
665
+ status: 'ok',
666
+ uptime: process.uptime(),
667
+ port: status.port,
668
+ accountCount: status.accountCount,
669
+ running: status.running,
670
+ })
671
+ }
672
+
673
+ // ── GET /v1/stats ──────────────────────────────────────────────────────────
674
+
675
+ /**
676
+ * 📖 Authenticated stats endpoint — returns per-account health, token stats summary,
677
+ * and proxy uptime. Useful for monitoring and debugging.
678
+ */
679
+ _handleStats(res) {
680
+ const healthByAccount = this._accountManager.getAllHealth()
681
+ const summary = this._tokenStats.getSummary()
682
+
683
+ // 📖 Compute totals from the summary data
684
+ const dailyEntries = Object.values(summary.daily || {})
685
+ const totalRequests = dailyEntries.reduce((sum, d) => sum + (d.requests || 0), 0)
686
+ const totalTokens = dailyEntries.reduce((sum, d) => sum + (d.tokens || 0), 0)
687
+
688
+ sendJson(res, 200, {
689
+ accounts: healthByAccount,
690
+ tokenStats: {
691
+ byModel: summary.byModel || {},
692
+ recentRequests: summary.recentRequests || [],
693
+ },
694
+ totals: {
695
+ requests: totalRequests,
696
+ tokens: totalTokens,
697
+ },
698
+ uptime: Math.floor((Date.now() - this._startTime) / 1000),
699
+ })
700
+ }
701
+
702
+ // ── POST /v1/messages (Anthropic translation) ──────────────────────────────
703
+
704
+ /**
705
+ * 📖 Handle Anthropic Messages API requests by translating to OpenAI format,
706
+ * forwarding through the existing chat completions handler, then translating
707
+ * the response back to Anthropic format.
708
+ *
709
+ * 📖 This makes Claude Code work natively through the FCM proxy.
710
+ */
711
+ async _handleAnthropicMessages(clientReq, clientRes) {
712
+ const rawBody = await readBody(clientReq)
713
+ let anthropicBody
714
+ try {
715
+ anthropicBody = JSON.parse(rawBody)
716
+ } catch {
717
+ return sendJson(clientRes, 400, { error: { type: 'invalid_request_error', message: 'Invalid JSON body' } })
718
+ }
719
+
720
+ // 📖 Translate Anthropic → OpenAI
721
+ const openaiBody = translateAnthropicToOpenAI(anthropicBody)
722
+ const isStreaming = openaiBody.stream === true
723
+
724
+ if (isStreaming) {
725
+ // 📖 Streaming mode: pipe through SSE transformer
726
+ await this._handleAnthropicMessagesStreaming(openaiBody, anthropicBody.model, clientRes)
727
+ } else {
728
+ // 📖 JSON mode: forward, translate response, return
729
+ await this._handleAnthropicMessagesJson(openaiBody, anthropicBody.model, clientRes)
730
+ }
731
+ }
732
+
733
+ /**
734
+ * 📖 Handle non-streaming Anthropic Messages by internally dispatching to
735
+ * chat completions logic and translating the JSON response back.
736
+ */
737
+ async _handleAnthropicMessagesJson(openaiBody, requestModel, clientRes) {
738
+ // 📖 Create a fake request/response pair to capture the OpenAI response
739
+ const capturedChunks = []
740
+ let capturedStatusCode = 200
741
+ let capturedHeaders = {}
742
+
743
+ const fakeRes = {
744
+ headersSent: false,
745
+ destroyed: false,
746
+ socket: null,
747
+ writeHead(statusCode, headers) {
748
+ capturedStatusCode = statusCode
749
+ capturedHeaders = headers || {}
750
+ this.headersSent = true
751
+ },
752
+ write(chunk) { capturedChunks.push(chunk) },
753
+ end(data) {
754
+ if (data) capturedChunks.push(data)
755
+ },
756
+ on() { return this },
757
+ once() { return this },
758
+ emit() { return false },
759
+ destroy() { this.destroyed = true },
760
+ removeListener() { return this },
761
+ }
762
+
763
+ // 📖 Build a fake IncomingMessage-like with pre-parsed body
764
+ const fakeReq = {
765
+ method: 'POST',
766
+ url: '/v1/chat/completions',
767
+ headers: { 'content-type': 'application/json' },
768
+ on(event, cb) {
769
+ if (event === 'data') cb(Buffer.from(JSON.stringify(openaiBody)))
770
+ if (event === 'end') cb()
771
+ return this
772
+ },
773
+ removeListener() { return this },
774
+ }
775
+
776
+ // 📖 Use internal handler directly instead of fake request
777
+ await this._handleChatCompletionsInternal(openaiBody, fakeRes)
778
+
779
+ const responseBody = capturedChunks.join('')
780
+
781
+ if (capturedStatusCode >= 200 && capturedStatusCode < 300) {
782
+ try {
783
+ const openaiResponse = JSON.parse(responseBody)
784
+ const anthropicResponse = translateOpenAIToAnthropic(openaiResponse, requestModel)
785
+ sendJson(clientRes, 200, anthropicResponse)
786
+ } catch {
787
+ // 📖 Couldn't parse — forward raw
788
+ sendJson(clientRes, capturedStatusCode, responseBody)
789
+ }
790
+ } else {
791
+ // 📖 Error — wrap in Anthropic error format
792
+ sendJson(clientRes, capturedStatusCode, {
793
+ type: 'error',
794
+ error: { type: 'api_error', message: responseBody },
795
+ })
796
+ }
797
+ }
798
+
799
+ /**
800
+ * 📖 Handle streaming Anthropic Messages by forwarding as streaming OpenAI
801
+ * chat completions and piping through the SSE translator.
802
+ */
803
+ async _handleAnthropicMessagesStreaming(openaiBody, requestModel, clientRes) {
804
+ // 📖 We need to intercept the SSE response and translate it
805
+ const { transform, getUsage } = createAnthropicSSETransformer(requestModel)
806
+
807
+ let resolveForward
808
+ const forwardPromise = new Promise(r => { resolveForward = r })
809
+
810
+ const fakeRes = {
811
+ headersSent: false,
812
+ destroyed: false,
813
+ socket: null,
814
+ writeHead(statusCode, headers) {
815
+ this.headersSent = true
816
+ if (statusCode >= 200 && statusCode < 300) {
817
+ // 📖 Write Anthropic SSE headers
818
+ clientRes.writeHead(200, {
819
+ 'content-type': 'text/event-stream',
820
+ 'cache-control': 'no-cache',
821
+ 'connection': 'keep-alive',
822
+ })
823
+ } else {
824
+ clientRes.writeHead(statusCode, headers)
825
+ }
826
+ },
827
+ write(chunk) { /* SSE data handled via pipe */ },
828
+ end(data) {
829
+ if (data && !this.headersSent) {
830
+ // 📖 Non-streaming error response
831
+ clientRes.end(data)
832
+ }
833
+ resolveForward()
834
+ },
835
+ on() { return this },
836
+ once() { return this },
837
+ emit() { return false },
838
+ destroy() { this.destroyed = true },
839
+ removeListener() { return this },
840
+ }
841
+
842
+ // 📖 Actually we need to pipe the upstream SSE through our transformer.
843
+ // 📖 The simplest approach: use _handleChatCompletionsInternal with stream=true
844
+ // 📖 and capture the piped response through our transformer.
845
+
846
+ // 📖 For streaming, we go lower level — use the retry loop directly
847
+ await this._handleAnthropicStreamDirect(openaiBody, requestModel, clientRes, transform)
848
+ }
849
+
850
+ /**
851
+ * 📖 Direct streaming handler for Anthropic messages.
852
+ * 📖 Runs the retry loop, pipes upstream SSE through the Anthropic transformer.
853
+ */
854
+ async _handleAnthropicStreamDirect(openaiBody, requestModel, clientRes, sseTransform) {
855
+ const { createHash: _createHash } = await import('node:crypto')
856
+ const fingerprint = _createHash('sha256')
857
+ .update(JSON.stringify(openaiBody.messages?.slice(-1) ?? []))
858
+ .digest('hex')
859
+ .slice(0, 16)
860
+
861
+ const requestedModel = typeof openaiBody.model === 'string'
862
+ ? openaiBody.model.replace(/^fcm-proxy\//, '')
863
+ : undefined
864
+
865
+ if (requestedModel && !this._accountManager.hasAccountsForModel(requestedModel)) {
866
+ return sendJson(clientRes, 404, {
867
+ type: 'error',
868
+ error: { type: 'not_found_error', message: `Model '${requestedModel}' is not available.` },
869
+ })
870
+ }
871
+
872
+ // 📖 Pipe the transform to client
873
+ sseTransform.pipe(clientRes)
874
+
875
+ for (let attempt = 0; attempt < this._retries; attempt++) {
876
+ // 📖 Progressive backoff for retries (same as chat completions)
877
+ const delay = this._retryDelays[Math.min(attempt, this._retryDelays.length - 1)]
878
+ if (delay > 0) await new Promise(r => setTimeout(r, delay + Math.random() * 100))
879
+
880
+ const selectOpts = attempt === 0
881
+ ? { sessionFingerprint: fingerprint, requestedModel }
882
+ : { requestedModel }
883
+ const account = this._accountManager.selectAccount(selectOpts)
884
+ if (!account) break
885
+
886
+ const result = await this._forwardRequestForAnthropicStream(account, openaiBody, sseTransform, clientRes)
887
+
888
+ if (result.done) return
889
+
890
+ const { statusCode, responseBody, responseHeaders, networkError } = result
891
+ const classified = classifyError(
892
+ networkError ? 0 : statusCode,
893
+ responseBody || '',
894
+ responseHeaders || {}
895
+ )
896
+ this._accountManager.recordFailure(account.id, classified, { providerKey: account.providerKey })
897
+ if (!classified.shouldRetry) {
898
+ sseTransform.end()
899
+ return sendJson(clientRes, statusCode || 500, {
900
+ type: 'error',
901
+ error: { type: 'api_error', message: responseBody || 'Upstream error' },
902
+ })
903
+ }
904
+ }
905
+
906
+ sseTransform.end()
907
+ sendJson(clientRes, 503, {
908
+ type: 'error',
909
+ error: { type: 'overloaded_error', message: 'All accounts exhausted or unavailable' },
910
+ })
911
+ }
912
+
913
+ /**
914
+ * 📖 Forward a streaming request to upstream and pipe SSE through transform.
915
+ */
916
+ _forwardRequestForAnthropicStream(account, body, sseTransform, clientRes) {
917
+ return new Promise(resolve => {
918
+ const newBody = { ...body, model: account.modelId, stream: true }
919
+ const bodyStr = JSON.stringify(newBody)
920
+ const baseUrl = account.url.replace(/\/$/, '')
921
+ let upstreamUrl
922
+ try {
923
+ upstreamUrl = new URL(baseUrl + '/chat/completions')
924
+ } catch {
925
+ return resolve({ done: false, statusCode: 0, responseBody: 'Invalid upstream URL', networkError: true })
926
+ }
927
+ const client = selectClient(account.url)
928
+ const startTime = Date.now()
929
+
930
+ const requestOptions = {
931
+ hostname: upstreamUrl.hostname,
932
+ port: upstreamUrl.port || (upstreamUrl.protocol === 'https:' ? 443 : 80),
933
+ path: upstreamUrl.pathname + (upstreamUrl.search || ''),
934
+ method: 'POST',
935
+ headers: {
936
+ 'authorization': `Bearer ${account.apiKey}`,
937
+ 'content-type': 'application/json',
938
+ 'content-length': Buffer.byteLength(bodyStr),
939
+ },
940
+ }
941
+
942
+ const upstreamReq = client.request(requestOptions, upstreamRes => {
943
+ const { statusCode } = upstreamRes
944
+
945
+ if (statusCode >= 200 && statusCode < 300) {
946
+ // 📖 Write Anthropic SSE headers if not already sent
947
+ if (!clientRes.headersSent) {
948
+ clientRes.writeHead(200, {
949
+ 'content-type': 'text/event-stream',
950
+ 'cache-control': 'no-cache',
951
+ })
952
+ }
953
+
954
+ // 📖 Error handlers on both sides of the pipe to prevent uncaught errors
955
+ upstreamRes.on('error', err => { if (!clientRes.destroyed) clientRes.destroy(err) })
956
+ clientRes.on('error', () => { if (!upstreamRes.destroyed) upstreamRes.destroy() })
957
+
958
+ // 📖 Pipe upstream SSE through Anthropic translator
959
+ upstreamRes.pipe(sseTransform, { end: true })
960
+
961
+ upstreamRes.on('end', () => {
962
+ this._accountManager.recordSuccess(account.id, Date.now() - startTime)
963
+ })
964
+
965
+ clientRes.on('close', () => {
966
+ if (!upstreamRes.destroyed) upstreamRes.destroy()
967
+ if (!upstreamReq.destroyed) upstreamReq.destroy()
968
+ })
969
+
970
+ resolve({ done: true })
971
+ } else {
972
+ const chunks = []
973
+ upstreamRes.on('data', chunk => chunks.push(chunk))
974
+ upstreamRes.on('end', () => {
975
+ resolve({
976
+ done: false,
977
+ statusCode,
978
+ responseBody: Buffer.concat(chunks).toString(),
979
+ responseHeaders: upstreamRes.headers,
980
+ networkError: false,
981
+ })
982
+ })
983
+ }
984
+ })
985
+
986
+ upstreamReq.on('error', err => {
987
+ resolve({
988
+ done: false,
989
+ statusCode: 0,
990
+ responseBody: err.message,
991
+ responseHeaders: {},
992
+ networkError: true,
993
+ })
994
+ })
995
+
996
+ upstreamReq.setTimeout(this._upstreamTimeoutMs, () => {
997
+ upstreamReq.destroy(new Error(`Upstream request timed out after ${this._upstreamTimeoutMs}ms`))
998
+ })
999
+
1000
+ upstreamReq.write(bodyStr)
1001
+ upstreamReq.end()
1002
+ })
1003
+ }
1004
+
1005
+ /**
1006
+ * 📖 Internal version of chat completions handler that takes a pre-parsed body.
1007
+ * 📖 Used by the Anthropic JSON translation path to avoid re-parsing.
1008
+ */
1009
+ async _handleChatCompletionsInternal(body, clientRes) {
1010
+ // 📖 Reuse the exact same logic as _handleChatCompletions but with pre-parsed body
1011
+ if (this._compressionOpts && Array.isArray(body.messages)) {
1012
+ body = { ...body, messages: compressContext(body.messages, this._compressionOpts) }
1013
+ }
1014
+ if (this._thinkingConfig) {
1015
+ body = applyThinkingBudget(body, this._thinkingConfig)
1016
+ }
1017
+
1018
+ const fingerprint = createHash('sha256')
1019
+ .update(JSON.stringify(body.messages?.slice(-1) ?? []))
1020
+ .digest('hex')
1021
+ .slice(0, 16)
1022
+
1023
+ const requestedModel = typeof body.model === 'string'
1024
+ ? body.model.replace(/^fcm-proxy\//, '')
1025
+ : undefined
1026
+
1027
+ if (requestedModel && !this._accountManager.hasAccountsForModel(requestedModel)) {
1028
+ return sendJson(clientRes, 404, {
1029
+ error: 'Model not found',
1030
+ message: `Model '${requestedModel}' is not available.`,
1031
+ })
1032
+ }
1033
+
1034
+ for (let attempt = 0; attempt < this._retries; attempt++) {
1035
+ const delay = this._retryDelays[Math.min(attempt, this._retryDelays.length - 1)]
1036
+ if (delay > 0) await new Promise(r => setTimeout(r, delay + Math.random() * 100))
1037
+
1038
+ const selectOpts = attempt === 0
1039
+ ? { sessionFingerprint: fingerprint, requestedModel }
1040
+ : { requestedModel }
1041
+ const account = this._accountManager.selectAccount(selectOpts)
1042
+ if (!account) break
1043
+
1044
+ const result = await this._forwardRequest(account, body, clientRes, { requestedModel })
1045
+ if (result.done) return
1046
+
1047
+ const { statusCode, responseBody, responseHeaders, networkError } = result
1048
+ const classified = classifyError(
1049
+ networkError ? 0 : statusCode,
1050
+ responseBody || '',
1051
+ responseHeaders || {}
1052
+ )
1053
+ this._accountManager.recordFailure(account.id, classified, { providerKey: account.providerKey })
1054
+ if (!classified.shouldRetry) {
1055
+ return sendJson(clientRes, statusCode || 500, responseBody || JSON.stringify({ error: 'Upstream error' }))
1056
+ }
1057
+ }
1058
+
1059
+ sendJson(clientRes, 503, { error: 'All accounts exhausted or unavailable' })
1060
+ }
1061
+
1062
+ // ── Hot-reload accounts ─────────────────────────────────────────────────────
1063
+
1064
+ /**
1065
+ * 📖 Atomically swap the account list and rebuild the AccountManager.
1066
+ * 📖 Used by the daemon when config changes (new API keys, providers toggled).
1067
+ * 📖 In-flight requests on old accounts will finish naturally.
1068
+ *
1069
+ * @param {Array} accounts — new account list
1070
+ */
1071
+ updateAccounts(accounts) {
1072
+ this._accounts = accounts
1073
+ this._accountManager = new AccountManager(accounts, {})
1074
+ }
593
1075
  }