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.
- package/CHANGELOG.md +53 -0
- package/README.md +96 -32
- package/bin/fcm-proxy-daemon.js +239 -0
- package/bin/free-coding-models.js +98 -20
- package/package.json +3 -2
- package/src/account-manager.js +34 -0
- package/src/anthropic-translator.js +370 -0
- package/src/config.js +24 -1
- package/src/daemon-manager.js +527 -0
- package/src/endpoint-installer.js +41 -16
- package/src/key-handler.js +327 -148
- package/src/opencode.js +30 -32
- package/src/overlays.js +272 -184
- package/src/proxy-server.js +488 -6
- package/src/proxy-sync.js +552 -0
- package/src/proxy-topology.js +80 -0
- package/src/render-table.js +24 -15
- package/src/tool-launchers.js +138 -18
package/src/proxy-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
396
|
-
const lines =
|
|
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
|
}
|