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
|
@@ -98,6 +98,7 @@ import { loadConfig, saveConfig, getApiKey, getProxySettings, resolveApiKeys, ad
|
|
|
98
98
|
import { buildMergedModels } from '../src/model-merger.js'
|
|
99
99
|
import { ProxyServer } from '../src/proxy-server.js'
|
|
100
100
|
import { loadOpenCodeConfig, saveOpenCodeConfig, syncToOpenCode, restoreOpenCodeBackup, cleanupOpenCodeProxyConfig } from '../src/opencode-sync.js'
|
|
101
|
+
import { syncProxyToTool, cleanupToolConfig, PROXY_SYNCABLE_TOOLS } from '../src/proxy-sync.js'
|
|
101
102
|
import { usageForRow as _usageForRow } from '../src/usage-reader.js'
|
|
102
103
|
import { loadRecentLogs } from '../src/log-reader.js'
|
|
103
104
|
import { buildProviderModelTokenKey, loadTokenUsageByProviderModel } from '../src/token-usage-reader.js'
|
|
@@ -108,7 +109,7 @@ import { TIER_COLOR } from '../src/tier-colors.js'
|
|
|
108
109
|
import { resolveCloudflareUrl, buildPingRequest, ping, extractQuotaPercent, getProviderQuotaPercentCached, usagePlaceholderForProvider } from '../src/ping.js'
|
|
109
110
|
import { runFiableMode, filterByTierOrExit, fetchOpenRouterFreeModels } from '../src/analysis.js'
|
|
110
111
|
import { PROVIDER_METADATA, ENV_VAR_NAMES, isWindows, isMac } from '../src/provider-metadata.js'
|
|
111
|
-
import { parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig, getTelemetryDistinctId, getTelemetrySystem, getTelemetryTerminal, isTelemetryEnabled, sendUsageTelemetry,
|
|
112
|
+
import { parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig, getTelemetryDistinctId, getTelemetrySystem, getTelemetryTerminal, isTelemetryEnabled, sendUsageTelemetry, sendBugReport } from '../src/telemetry.js'
|
|
112
113
|
import { ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel } from '../src/favorites.js'
|
|
113
114
|
import { checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification } from '../src/updater.js'
|
|
114
115
|
import { promptApiKey } from '../src/setup.js'
|
|
@@ -140,7 +141,7 @@ const readline = require('readline')
|
|
|
140
141
|
const pkg = require('../package.json')
|
|
141
142
|
const LOCAL_VERSION = pkg.version
|
|
142
143
|
|
|
143
|
-
// 📖
|
|
144
|
+
// 📖 sendBugReport → imported from ../src/telemetry.js
|
|
144
145
|
|
|
145
146
|
// 📖 parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig → imported from ../src/telemetry.js
|
|
146
147
|
|
|
@@ -199,6 +200,80 @@ async function main() {
|
|
|
199
200
|
process.exit(0)
|
|
200
201
|
}
|
|
201
202
|
|
|
203
|
+
// 📖 CLI subcommand: free-coding-models daemon <action>
|
|
204
|
+
const daemonSubcmd = process.argv[2] === 'daemon' ? (process.argv[3] || 'status') : null
|
|
205
|
+
if (daemonSubcmd) {
|
|
206
|
+
const dm = await import('../src/daemon-manager.js')
|
|
207
|
+
if (daemonSubcmd === 'status') {
|
|
208
|
+
const s = await dm.getDaemonStatus()
|
|
209
|
+
console.log()
|
|
210
|
+
if (s.status === 'running') {
|
|
211
|
+
console.log(chalk.greenBright(` 📡 FCM Proxy V2: Running`))
|
|
212
|
+
console.log(chalk.dim(` PID: ${s.info.pid} • Port: ${s.info.port} • Accounts: ${s.info.accountCount} • Version: ${s.info.version}`))
|
|
213
|
+
console.log(chalk.dim(` Started: ${s.info.startedAt}`))
|
|
214
|
+
} else if (s.status === 'stopped') {
|
|
215
|
+
console.log(chalk.yellow(` 📡 FCM Proxy V2: Stopped (service installed but not running)`))
|
|
216
|
+
} else if (s.status === 'stale') {
|
|
217
|
+
console.log(chalk.red(` 📡 FCM Proxy V2: Stale (crashed — PID ${s.info?.pid} no longer alive)`))
|
|
218
|
+
} else if (s.status === 'unhealthy') {
|
|
219
|
+
console.log(chalk.red(` 📡 FCM Proxy V2: Unhealthy (PID alive but health check failed)`))
|
|
220
|
+
} else {
|
|
221
|
+
console.log(chalk.dim(` 📡 FCM Proxy V2: Not installed`))
|
|
222
|
+
console.log(chalk.dim(` Install via: free-coding-models daemon install`))
|
|
223
|
+
}
|
|
224
|
+
console.log()
|
|
225
|
+
process.exit(0)
|
|
226
|
+
}
|
|
227
|
+
if (daemonSubcmd === 'install') {
|
|
228
|
+
const result = dm.installDaemon()
|
|
229
|
+
console.log()
|
|
230
|
+
if (result.success) {
|
|
231
|
+
console.log(chalk.greenBright(' ✅ FCM Proxy V2 background service installed and started!'))
|
|
232
|
+
console.log(chalk.dim(' The proxy will now run automatically at login.'))
|
|
233
|
+
} else {
|
|
234
|
+
console.log(chalk.red(` ❌ Install failed: ${result.error}`))
|
|
235
|
+
}
|
|
236
|
+
console.log()
|
|
237
|
+
process.exit(result.success ? 0 : 1)
|
|
238
|
+
}
|
|
239
|
+
if (daemonSubcmd === 'uninstall') {
|
|
240
|
+
const result = dm.uninstallDaemon()
|
|
241
|
+
console.log()
|
|
242
|
+
if (result.success) {
|
|
243
|
+
console.log(chalk.greenBright(' ✅ FCM Proxy V2 background service uninstalled.'))
|
|
244
|
+
} else {
|
|
245
|
+
console.log(chalk.red(` ❌ Uninstall failed: ${result.error}`))
|
|
246
|
+
}
|
|
247
|
+
console.log()
|
|
248
|
+
process.exit(result.success ? 0 : 1)
|
|
249
|
+
}
|
|
250
|
+
if (daemonSubcmd === 'restart') {
|
|
251
|
+
const result = dm.restartDaemon()
|
|
252
|
+
console.log()
|
|
253
|
+
if (result.success) {
|
|
254
|
+
console.log(chalk.greenBright(' ✅ FCM Proxy V2 service restarted.'))
|
|
255
|
+
} else {
|
|
256
|
+
console.log(chalk.red(` ❌ Restart failed: ${result.error}`))
|
|
257
|
+
}
|
|
258
|
+
console.log()
|
|
259
|
+
process.exit(result.success ? 0 : 1)
|
|
260
|
+
}
|
|
261
|
+
if (daemonSubcmd === 'logs') {
|
|
262
|
+
const logPath = dm.getDaemonLogPath()
|
|
263
|
+
console.log(chalk.dim(` Log file: ${logPath}`))
|
|
264
|
+
try {
|
|
265
|
+
const { execSync } = await import('child_process')
|
|
266
|
+
execSync(`tail -50 "${logPath}"`, { stdio: 'inherit' })
|
|
267
|
+
} catch {
|
|
268
|
+
console.log(chalk.dim(' (no logs yet)'))
|
|
269
|
+
}
|
|
270
|
+
process.exit(0)
|
|
271
|
+
}
|
|
272
|
+
console.log(chalk.red(` Unknown command: ${daemonSubcmd}`))
|
|
273
|
+
console.log(chalk.dim(' Usage: free-coding-models daemon [status|install|uninstall|restart|logs]'))
|
|
274
|
+
process.exit(1)
|
|
275
|
+
}
|
|
276
|
+
|
|
202
277
|
// 📖 If --profile <name> was passed, load that profile into the live config
|
|
203
278
|
let startupProfileSettings = null
|
|
204
279
|
if (cliArgs.profileName) {
|
|
@@ -382,6 +457,7 @@ async function main() {
|
|
|
382
457
|
terminalCols: process.stdout.columns || 80, // 📖 Current terminal width
|
|
383
458
|
widthWarningStartedAt: (process.stdout.columns || 80) < 166 ? now : null, // 📖 Start the narrow-terminal countdown immediately when booting in a small viewport.
|
|
384
459
|
widthWarningDismissed: false, // 📖 Esc hides the narrow-terminal warning early for the current narrow-width session.
|
|
460
|
+
widthWarningShowCount: 0, // 📖 Counter for how many times the narrow-terminal warning has been shown (max 2 per session).
|
|
385
461
|
// 📖 Settings screen state (P key opens it)
|
|
386
462
|
settingsOpen: false, // 📖 Whether settings overlay is active
|
|
387
463
|
settingsCursor: 0, // 📖 Which provider row is selected in settings
|
|
@@ -396,6 +472,13 @@ async function main() {
|
|
|
396
472
|
settingsUpdateError: null, // 📖 Last update-check error message for maintenance row
|
|
397
473
|
settingsProxyPortEditMode: false, // 📖 Whether Settings is editing the preferred proxy port field.
|
|
398
474
|
settingsProxyPortBuffer: '', // 📖 Inline input buffer for the preferred proxy port (0 = auto).
|
|
475
|
+
daemonStatus: 'not-installed', // 📖 Background daemon status: 'running'|'stopped'|'stale'|'unhealthy'|'not-installed'
|
|
476
|
+
daemonInfo: null, // 📖 daemon.json contents when daemon is running
|
|
477
|
+
// 📖 Proxy & Daemon overlay state (opened from Settings)
|
|
478
|
+
proxyDaemonOpen: false, // 📖 Whether the dedicated Proxy & Daemon overlay is active
|
|
479
|
+
proxyDaemonCursor: 0, // 📖 Selected row in the proxy/daemon overlay
|
|
480
|
+
proxyDaemonScrollOffset: 0, // 📖 Vertical scroll offset for the proxy/daemon overlay
|
|
481
|
+
proxyDaemonMessage: null, // 📖 Feedback message { type: 'success'|'warning'|'error', msg: string, ts: number }
|
|
399
482
|
config, // 📖 Live reference to the config object (updated on save)
|
|
400
483
|
visibleSorted: [], // 📖 Cached visible+sorted models — shared between render loop and key handlers
|
|
401
484
|
helpVisible: false, // 📖 Whether the help overlay (K key) is active
|
|
@@ -429,14 +512,9 @@ async function main() {
|
|
|
429
512
|
activeProfile: getActiveProfileName(config), // 📖 Currently loaded profile name (or null)
|
|
430
513
|
profileSaveMode: false, // 📖 Whether the inline "Save profile" name input is active
|
|
431
514
|
profileSaveBuffer: '', // 📖 Typed characters for the profile name being saved
|
|
432
|
-
// 📖
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
featureRequestStatus: 'idle', // 📖 'idle'|'sending'|'success'|'error' — webhook send status
|
|
436
|
-
featureRequestError: null, // 📖 Last webhook error message
|
|
437
|
-
// 📖 Bug Report state (I key opens it)
|
|
438
|
-
bugReportOpen: false, // 📖 Whether the bug report overlay is active
|
|
439
|
-
bugReportBuffer: '', // 📖 Typed characters for the bug report message
|
|
515
|
+
// 📖 Feedback state (J/I keys open it)
|
|
516
|
+
feedbackOpen: false, // 📖 Whether the feedback overlay is active
|
|
517
|
+
bugReportBuffer: '', // 📖 Typed characters for the feedback message
|
|
440
518
|
bugReportStatus: 'idle', // 📖 'idle'|'sending'|'success'|'error' — webhook send status
|
|
441
519
|
bugReportError: null, // 📖 Last webhook error message
|
|
442
520
|
// 📖 OpenCode sync status (S key in settings)
|
|
@@ -468,6 +546,7 @@ async function main() {
|
|
|
468
546
|
if (prevCols >= 166 || state.widthWarningDismissed) {
|
|
469
547
|
state.widthWarningStartedAt = Date.now()
|
|
470
548
|
state.widthWarningDismissed = false
|
|
549
|
+
state.widthWarningShowCount++ // 📖 Increment counter when showing the warning again
|
|
471
550
|
} else if (!state.widthWarningStartedAt) {
|
|
472
551
|
state.widthWarningStartedAt = Date.now()
|
|
473
552
|
}
|
|
@@ -726,7 +805,7 @@ async function main() {
|
|
|
726
805
|
ENV_VAR_NAMES,
|
|
727
806
|
ensureProxyRunning,
|
|
728
807
|
syncToOpenCode,
|
|
729
|
-
|
|
808
|
+
cleanupToolConfig,
|
|
730
809
|
restoreOpenCodeBackup,
|
|
731
810
|
checkForUpdateDetailed,
|
|
732
811
|
runUpdate,
|
|
@@ -740,7 +819,6 @@ async function main() {
|
|
|
740
819
|
getToolModeOrder,
|
|
741
820
|
startRecommendAnalysis: overlays.startRecommendAnalysis,
|
|
742
821
|
stopRecommendAnalysis: overlays.stopRecommendAnalysis,
|
|
743
|
-
sendFeatureRequest,
|
|
744
822
|
sendBugReport,
|
|
745
823
|
stopUi,
|
|
746
824
|
ping,
|
|
@@ -788,27 +866,27 @@ async function main() {
|
|
|
788
866
|
refreshAutoPingMode()
|
|
789
867
|
state.frame++
|
|
790
868
|
// 📖 Cache visible+sorted models each frame so Enter handler always matches the display
|
|
791
|
-
if (!state.settingsOpen && !state.installEndpointsOpen && !state.recommendOpen && !state.
|
|
869
|
+
if (!state.settingsOpen && !state.installEndpointsOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen && !state.proxyDaemonOpen) {
|
|
792
870
|
const visible = state.results.filter(r => !r.hidden)
|
|
793
871
|
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
794
872
|
}
|
|
795
873
|
const content = state.settingsOpen
|
|
796
874
|
? overlays.renderSettings()
|
|
875
|
+
: state.proxyDaemonOpen
|
|
876
|
+
? overlays.renderProxyDaemon()
|
|
797
877
|
: state.installEndpointsOpen
|
|
798
878
|
? overlays.renderInstallEndpoints()
|
|
799
879
|
: state.recommendOpen
|
|
800
880
|
? overlays.renderRecommend()
|
|
801
|
-
: state.
|
|
802
|
-
? overlays.
|
|
803
|
-
|
|
804
|
-
? overlays.renderBugReport()
|
|
805
|
-
: state.helpVisible
|
|
881
|
+
: state.feedbackOpen
|
|
882
|
+
? overlays.renderFeedback()
|
|
883
|
+
: state.helpVisible
|
|
806
884
|
? overlays.renderHelp()
|
|
807
885
|
: state.logVisible
|
|
808
886
|
? overlays.renderLog()
|
|
809
887
|
: state.changelogOpen
|
|
810
888
|
? overlays.renderChangelog()
|
|
811
|
-
: renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.settingsUpdateState, state.settingsUpdateLatestVersion, getProxySettings(state.config).enabled === true, state.isOutdated, state.latestVersion)
|
|
889
|
+
: renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, getProxySettings(state.config).enabled === true, state.isOutdated, state.latestVersion)
|
|
812
890
|
process.stdout.write(ALT_HOME + content)
|
|
813
891
|
}, Math.round(1000 / FPS))
|
|
814
892
|
|
|
@@ -816,7 +894,7 @@ async function main() {
|
|
|
816
894
|
const initialVisible = state.results.filter(r => !r.hidden)
|
|
817
895
|
state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
|
|
818
896
|
|
|
819
|
-
process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.settingsUpdateState, state.settingsUpdateLatestVersion, getProxySettings(state.config).enabled === true, state.isOutdated, state.latestVersion))
|
|
897
|
+
process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer, state.proxyStartupStatus, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, getProxySettings(state.config).enabled === true, state.isOutdated, state.latestVersion))
|
|
820
898
|
|
|
821
899
|
// 📖 If --recommend was passed, auto-open the Smart Recommend overlay on start
|
|
822
900
|
if (cliArgs.recommendMode) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nvidia",
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"type": "module",
|
|
37
37
|
"main": "bin/free-coding-models.js",
|
|
38
38
|
"bin": {
|
|
39
|
-
"free-coding-models": "./bin/free-coding-models.js"
|
|
39
|
+
"free-coding-models": "./bin/free-coding-models.js",
|
|
40
|
+
"fcm-proxy-daemon": "./bin/fcm-proxy-daemon.js"
|
|
40
41
|
},
|
|
41
42
|
"files": [
|
|
42
43
|
"bin/",
|
package/src/account-manager.js
CHANGED
|
@@ -39,6 +39,12 @@ const UNKNOWN_COOLDOWN_BASE_MS = 15 * 60 * 1000 // 15m initial cooldown
|
|
|
39
39
|
const UNKNOWN_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1000 // 6h max cooldown
|
|
40
40
|
const KNOWN_FALLBACK_COOLDOWN_MS = 5 * 60 * 1000 // 5m fallback for known providers
|
|
41
41
|
|
|
42
|
+
// 📖 Generic consecutive-failure cooldown constants.
|
|
43
|
+
// When an account accumulates FAILURE_COOLDOWN_THRESHOLD consecutive non-429 failures,
|
|
44
|
+
// it enters a graduated cooldown (30s → 60s → 120s) so the proxy routes around it.
|
|
45
|
+
const FAILURE_COOLDOWN_THRESHOLD = 3
|
|
46
|
+
const FAILURE_COOLDOWN_STEPS_MS = [30_000, 60_000, 120_000]
|
|
47
|
+
|
|
42
48
|
// ─── Internal: per-account health state ──────────────────────────────────────
|
|
43
49
|
|
|
44
50
|
class AccountHealth {
|
|
@@ -85,6 +91,15 @@ class AccountHealth {
|
|
|
85
91
|
cooldownUntilMs: 0,
|
|
86
92
|
probeInFlight: false,
|
|
87
93
|
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 📖 Generic consecutive-failure tracking for non-429 errors.
|
|
97
|
+
* When consecutiveFailures >= FAILURE_COOLDOWN_THRESHOLD, the account enters
|
|
98
|
+
* a graduated cooldown to avoid wasting requests on a failing endpoint.
|
|
99
|
+
*/
|
|
100
|
+
this.consecutiveFailures = 0
|
|
101
|
+
this.failureCooldownUntilMs = 0
|
|
102
|
+
this.failureCooldownLevel = 0
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
/**
|
|
@@ -265,6 +280,9 @@ export class AccountManager {
|
|
|
265
280
|
const retryAfterTs = this._retryAfterMap.get(acct.id)
|
|
266
281
|
if (retryAfterTs && Date.now() < retryAfterTs) return false
|
|
267
282
|
|
|
283
|
+
// 📖 Generic failure cooldown — blocks account after consecutive non-429 failures
|
|
284
|
+
if (health.failureCooldownUntilMs > 0 && Date.now() < health.failureCooldownUntilMs) return false
|
|
285
|
+
|
|
268
286
|
// Only exclude when quota is known to be nearly exhausted.
|
|
269
287
|
// When quotaSignal is 'unknown' (null quotaPercent), we remain available.
|
|
270
288
|
if (health.quotaSignal === 'known' && health.quotaPercent !== null && health.quotaPercent <= 5) return false
|
|
@@ -455,6 +473,17 @@ export class AccountManager {
|
|
|
455
473
|
if (classifiedError?.retryAfterSec) {
|
|
456
474
|
this._retryAfterMap.set(accountId, Date.now() + classifiedError.retryAfterSec * 1000)
|
|
457
475
|
}
|
|
476
|
+
|
|
477
|
+
// 📖 Generic consecutive-failure cooldown: when an account hits FAILURE_COOLDOWN_THRESHOLD
|
|
478
|
+
// consecutive non-429 failures, put it in graduated cooldown (30s → 60s → 120s)
|
|
479
|
+
// so the proxy routes around it instead of wasting requests.
|
|
480
|
+
health.consecutiveFailures++
|
|
481
|
+
if (health.consecutiveFailures >= FAILURE_COOLDOWN_THRESHOLD) {
|
|
482
|
+
const stepIdx = Math.min(health.failureCooldownLevel, FAILURE_COOLDOWN_STEPS_MS.length - 1)
|
|
483
|
+
health.failureCooldownUntilMs = Date.now() + FAILURE_COOLDOWN_STEPS_MS[stepIdx]
|
|
484
|
+
health.failureCooldownLevel++
|
|
485
|
+
health.consecutiveFailures = 0
|
|
486
|
+
}
|
|
458
487
|
}
|
|
459
488
|
|
|
460
489
|
/**
|
|
@@ -532,6 +561,11 @@ export class AccountManager {
|
|
|
532
561
|
|
|
533
562
|
// Clear retry-after from the retryAfterMap (no longer cooling down)
|
|
534
563
|
this._retryAfterMap.delete(accountId)
|
|
564
|
+
|
|
565
|
+
// 📖 Reset generic failure cooldown state on success
|
|
566
|
+
health.consecutiveFailures = 0
|
|
567
|
+
health.failureCooldownUntilMs = 0
|
|
568
|
+
health.failureCooldownLevel = 0
|
|
535
569
|
}
|
|
536
570
|
|
|
537
571
|
/**
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/anthropic-translator.js
|
|
3
|
+
* @description Bidirectional wire format translation between Anthropic Messages API
|
|
4
|
+
* and OpenAI Chat Completions API.
|
|
5
|
+
*
|
|
6
|
+
* 📖 This is the key module that enables Claude Code to work natively through the
|
|
7
|
+
* FCM proxy without needing the external "free-claude-code" Python proxy.
|
|
8
|
+
* Claude Code sends requests in Anthropic format (POST /v1/messages) and this
|
|
9
|
+
* module translates them to OpenAI format for the upstream providers, then
|
|
10
|
+
* translates the responses back.
|
|
11
|
+
*
|
|
12
|
+
* 📖 Supports both JSON and SSE streaming modes.
|
|
13
|
+
*
|
|
14
|
+
* @functions
|
|
15
|
+
* → translateAnthropicToOpenAI(body) — Convert Anthropic Messages request → OpenAI chat completions
|
|
16
|
+
* → translateOpenAIToAnthropic(openaiResponse, requestModel) — Convert OpenAI JSON response → Anthropic
|
|
17
|
+
* → createAnthropicSSETransformer(requestModel) — Create a Transform stream for SSE translation
|
|
18
|
+
*
|
|
19
|
+
* @exports translateAnthropicToOpenAI, translateOpenAIToAnthropic, createAnthropicSSETransformer
|
|
20
|
+
* @see src/proxy-server.js — routes /v1/messages through this translator
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Transform } from 'node:stream'
|
|
24
|
+
import { randomUUID } from 'node:crypto'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 📖 Translate an Anthropic Messages API request body to OpenAI Chat Completions format.
|
|
28
|
+
*
|
|
29
|
+
* Anthropic format:
|
|
30
|
+
* { model, messages: [{role, content}], system, max_tokens, stream, temperature, top_p, stop_sequences }
|
|
31
|
+
*
|
|
32
|
+
* OpenAI format:
|
|
33
|
+
* { model, messages: [{role, content}], max_tokens, stream, temperature, top_p, stop }
|
|
34
|
+
*
|
|
35
|
+
* @param {object} body — Anthropic request body
|
|
36
|
+
* @returns {object} — OpenAI-compatible request body
|
|
37
|
+
*/
|
|
38
|
+
export function translateAnthropicToOpenAI(body) {
|
|
39
|
+
// 📖 Guard against null/undefined/non-object input
|
|
40
|
+
if (!body || typeof body !== 'object') return { model: '', messages: [], stream: false }
|
|
41
|
+
if (!Array.isArray(body.messages)) body = { ...body, messages: [] }
|
|
42
|
+
|
|
43
|
+
const openaiMessages = []
|
|
44
|
+
|
|
45
|
+
// 📖 Anthropic "system" field → OpenAI system message
|
|
46
|
+
if (body.system) {
|
|
47
|
+
if (typeof body.system === 'string') {
|
|
48
|
+
openaiMessages.push({ role: 'system', content: body.system })
|
|
49
|
+
} else if (Array.isArray(body.system)) {
|
|
50
|
+
// 📖 Anthropic supports system as array of content blocks
|
|
51
|
+
const text = body.system
|
|
52
|
+
.filter(b => b.type === 'text')
|
|
53
|
+
.map(b => b.text)
|
|
54
|
+
.join('\n\n')
|
|
55
|
+
if (text) openaiMessages.push({ role: 'system', content: text })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 📖 Convert Anthropic messages to OpenAI format
|
|
60
|
+
if (Array.isArray(body.messages)) {
|
|
61
|
+
for (const msg of body.messages) {
|
|
62
|
+
const role = msg.role === 'assistant' ? 'assistant' : 'user'
|
|
63
|
+
|
|
64
|
+
if (typeof msg.content === 'string') {
|
|
65
|
+
openaiMessages.push({ role, content: msg.content })
|
|
66
|
+
} else if (Array.isArray(msg.content)) {
|
|
67
|
+
// 📖 Anthropic content blocks: [{type: "text", text: "..."}, {type: "tool_result", ...}]
|
|
68
|
+
const textParts = msg.content
|
|
69
|
+
.filter(b => b.type === 'text')
|
|
70
|
+
.map(b => b.text)
|
|
71
|
+
const toolResults = msg.content.filter(b => b.type === 'tool_result')
|
|
72
|
+
const toolUses = msg.content.filter(b => b.type === 'tool_use')
|
|
73
|
+
|
|
74
|
+
// 📖 Tool use blocks (assistant) → OpenAI tool_calls
|
|
75
|
+
if (toolUses.length > 0 && role === 'assistant') {
|
|
76
|
+
const toolCalls = toolUses.map(tu => ({
|
|
77
|
+
id: tu.id || randomUUID(),
|
|
78
|
+
type: 'function',
|
|
79
|
+
function: {
|
|
80
|
+
name: tu.name,
|
|
81
|
+
arguments: typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input || {}),
|
|
82
|
+
}
|
|
83
|
+
}))
|
|
84
|
+
openaiMessages.push({
|
|
85
|
+
role: 'assistant',
|
|
86
|
+
content: textParts.join('\n') || null,
|
|
87
|
+
tool_calls: toolCalls,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
// 📖 Tool result blocks (user) → OpenAI tool messages
|
|
91
|
+
else if (toolResults.length > 0) {
|
|
92
|
+
// 📖 First push any text parts as user message
|
|
93
|
+
if (textParts.length > 0) {
|
|
94
|
+
openaiMessages.push({ role: 'user', content: textParts.join('\n') })
|
|
95
|
+
}
|
|
96
|
+
for (const tr of toolResults) {
|
|
97
|
+
const content = typeof tr.content === 'string'
|
|
98
|
+
? tr.content
|
|
99
|
+
: Array.isArray(tr.content)
|
|
100
|
+
? tr.content.filter(b => b.type === 'text').map(b => b.text).join('\n')
|
|
101
|
+
: JSON.stringify(tr.content || '')
|
|
102
|
+
openaiMessages.push({
|
|
103
|
+
role: 'tool',
|
|
104
|
+
tool_call_id: tr.tool_use_id || tr.id || '',
|
|
105
|
+
content,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// 📖 Plain text content blocks → join into single message
|
|
110
|
+
else if (textParts.length > 0) {
|
|
111
|
+
openaiMessages.push({ role, content: textParts.join('\n') })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result = {
|
|
118
|
+
model: body.model,
|
|
119
|
+
messages: openaiMessages,
|
|
120
|
+
stream: body.stream === true,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 📖 Map Anthropic parameters to OpenAI equivalents
|
|
124
|
+
if (body.max_tokens != null) result.max_tokens = body.max_tokens
|
|
125
|
+
if (body.temperature != null) result.temperature = body.temperature
|
|
126
|
+
if (body.top_p != null) result.top_p = body.top_p
|
|
127
|
+
if (Array.isArray(body.stop_sequences) && body.stop_sequences.length > 0) {
|
|
128
|
+
result.stop = body.stop_sequences
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 📖 Map Anthropic tools to OpenAI function tools
|
|
132
|
+
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
133
|
+
result.tools = body.tools.map(tool => ({
|
|
134
|
+
type: 'function',
|
|
135
|
+
function: {
|
|
136
|
+
name: tool.name,
|
|
137
|
+
description: tool.description || '',
|
|
138
|
+
parameters: tool.input_schema || {},
|
|
139
|
+
}
|
|
140
|
+
}))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return result
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 📖 Translate an OpenAI Chat Completions JSON response to Anthropic Messages format.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} openaiResponse — parsed OpenAI response
|
|
150
|
+
* @param {string} requestModel — model name from the original request
|
|
151
|
+
* @returns {object} — Anthropic Messages response
|
|
152
|
+
*/
|
|
153
|
+
export function translateOpenAIToAnthropic(openaiResponse, requestModel) {
|
|
154
|
+
const choice = openaiResponse.choices?.[0]
|
|
155
|
+
const message = choice?.message || {}
|
|
156
|
+
const content = []
|
|
157
|
+
|
|
158
|
+
// 📖 Text content → Anthropic text block
|
|
159
|
+
if (message.content) {
|
|
160
|
+
content.push({ type: 'text', text: message.content })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 📖 Tool calls → Anthropic tool_use blocks
|
|
164
|
+
if (Array.isArray(message.tool_calls)) {
|
|
165
|
+
for (const tc of message.tool_calls) {
|
|
166
|
+
let input = {}
|
|
167
|
+
try { input = JSON.parse(tc.function?.arguments || '{}') } catch { /* ignore */ }
|
|
168
|
+
content.push({
|
|
169
|
+
type: 'tool_use',
|
|
170
|
+
id: tc.id || randomUUID(),
|
|
171
|
+
name: tc.function?.name || '',
|
|
172
|
+
input,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 📖 Fallback: Anthropic requires at least one content block — provide empty text if none
|
|
178
|
+
if (content.length === 0) {
|
|
179
|
+
content.push({ type: 'text', text: '' })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 📖 Map OpenAI finish_reason → Anthropic stop_reason
|
|
183
|
+
let stopReason = 'end_turn'
|
|
184
|
+
if (choice?.finish_reason === 'stop') stopReason = 'end_turn'
|
|
185
|
+
else if (choice?.finish_reason === 'length') stopReason = 'max_tokens'
|
|
186
|
+
else if (choice?.finish_reason === 'tool_calls') stopReason = 'tool_use'
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
id: openaiResponse.id || `msg_${randomUUID().replace(/-/g, '')}`,
|
|
190
|
+
type: 'message',
|
|
191
|
+
role: 'assistant',
|
|
192
|
+
content,
|
|
193
|
+
model: requestModel || openaiResponse.model || '',
|
|
194
|
+
stop_reason: stopReason,
|
|
195
|
+
stop_sequence: null,
|
|
196
|
+
usage: {
|
|
197
|
+
input_tokens: openaiResponse.usage?.prompt_tokens || 0,
|
|
198
|
+
output_tokens: openaiResponse.usage?.completion_tokens || 0,
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 📖 Create a Transform stream that converts OpenAI SSE chunks to Anthropic SSE format.
|
|
205
|
+
*
|
|
206
|
+
* OpenAI SSE:
|
|
207
|
+
* data: {"choices":[{"delta":{"content":"Hello"}}]}
|
|
208
|
+
*
|
|
209
|
+
* Anthropic SSE:
|
|
210
|
+
* event: message_start
|
|
211
|
+
* data: {"type":"message_start","message":{...}}
|
|
212
|
+
*
|
|
213
|
+
* event: content_block_start
|
|
214
|
+
* data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
|
215
|
+
*
|
|
216
|
+
* event: content_block_delta
|
|
217
|
+
* data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
|
|
218
|
+
*
|
|
219
|
+
* event: message_stop
|
|
220
|
+
* data: {"type":"message_stop"}
|
|
221
|
+
*
|
|
222
|
+
* @param {string} requestModel — model from original request
|
|
223
|
+
* @returns {{ transform: Transform, getUsage: () => object }}
|
|
224
|
+
*/
|
|
225
|
+
// 📖 Max SSE buffer size to prevent memory exhaustion from malformed streams (1 MB)
|
|
226
|
+
const MAX_SSE_BUFFER = 1 * 1024 * 1024
|
|
227
|
+
|
|
228
|
+
export function createAnthropicSSETransformer(requestModel) {
|
|
229
|
+
let headerSent = false
|
|
230
|
+
// 📖 Track block indices for proper content_block_start/stop/delta indexing.
|
|
231
|
+
// nextBlockIndex increments for each new content block (text or tool_use).
|
|
232
|
+
// currentBlockIndex tracks the index of the most recently opened block.
|
|
233
|
+
let nextBlockIndex = 0
|
|
234
|
+
let currentBlockIndex = -1
|
|
235
|
+
let inputTokens = 0
|
|
236
|
+
let outputTokens = 0
|
|
237
|
+
let buffer = ''
|
|
238
|
+
|
|
239
|
+
const transform = new Transform({
|
|
240
|
+
transform(chunk, encoding, callback) {
|
|
241
|
+
buffer += chunk.toString()
|
|
242
|
+
// 📖 Guard against unbounded buffer growth from malformed SSE streams
|
|
243
|
+
if (buffer.length > MAX_SSE_BUFFER) {
|
|
244
|
+
buffer = ''
|
|
245
|
+
return callback(new Error('SSE buffer overflow'))
|
|
246
|
+
}
|
|
247
|
+
const lines = buffer.split('\n')
|
|
248
|
+
// 📖 Keep the last incomplete line in the buffer
|
|
249
|
+
buffer = lines.pop() || ''
|
|
250
|
+
|
|
251
|
+
for (const line of lines) {
|
|
252
|
+
if (!line.startsWith('data: ')) continue
|
|
253
|
+
const payload = line.slice(6).trim()
|
|
254
|
+
if (payload === '[DONE]') {
|
|
255
|
+
// 📖 End of stream — close any open block, then send message_delta + message_stop
|
|
256
|
+
if (currentBlockIndex >= 0) {
|
|
257
|
+
this.push(`event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: currentBlockIndex })}\n\n`)
|
|
258
|
+
currentBlockIndex = -1
|
|
259
|
+
}
|
|
260
|
+
this.push(`event: message_delta\ndata: ${JSON.stringify({
|
|
261
|
+
type: 'message_delta',
|
|
262
|
+
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
|
263
|
+
usage: { output_tokens: outputTokens },
|
|
264
|
+
})}\n\n`)
|
|
265
|
+
this.push(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let parsed
|
|
270
|
+
try { parsed = JSON.parse(payload) } catch { continue }
|
|
271
|
+
|
|
272
|
+
// 📖 Send message_start header on first chunk
|
|
273
|
+
if (!headerSent) {
|
|
274
|
+
headerSent = true
|
|
275
|
+
this.push(`event: message_start\ndata: ${JSON.stringify({
|
|
276
|
+
type: 'message_start',
|
|
277
|
+
message: {
|
|
278
|
+
id: parsed.id || `msg_${randomUUID().replace(/-/g, '')}`,
|
|
279
|
+
type: 'message',
|
|
280
|
+
role: 'assistant',
|
|
281
|
+
content: [],
|
|
282
|
+
model: requestModel || parsed.model || '',
|
|
283
|
+
stop_reason: null,
|
|
284
|
+
stop_sequence: null,
|
|
285
|
+
usage: { input_tokens: inputTokens, output_tokens: 0 },
|
|
286
|
+
},
|
|
287
|
+
})}\n\n`)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const choice = parsed.choices?.[0]
|
|
291
|
+
if (!choice) continue
|
|
292
|
+
const delta = choice.delta || {}
|
|
293
|
+
|
|
294
|
+
// 📖 Track usage if present
|
|
295
|
+
if (parsed.usage) {
|
|
296
|
+
inputTokens = parsed.usage.prompt_tokens || inputTokens
|
|
297
|
+
outputTokens = parsed.usage.completion_tokens || outputTokens
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 📖 Text delta
|
|
301
|
+
if (delta.content) {
|
|
302
|
+
if (currentBlockIndex < 0 || nextBlockIndex === 0) {
|
|
303
|
+
// 📖 Open first text block
|
|
304
|
+
currentBlockIndex = nextBlockIndex++
|
|
305
|
+
this.push(`event: content_block_start\ndata: ${JSON.stringify({
|
|
306
|
+
type: 'content_block_start',
|
|
307
|
+
index: currentBlockIndex,
|
|
308
|
+
content_block: { type: 'text', text: '' },
|
|
309
|
+
})}\n\n`)
|
|
310
|
+
}
|
|
311
|
+
this.push(`event: content_block_delta\ndata: ${JSON.stringify({
|
|
312
|
+
type: 'content_block_delta',
|
|
313
|
+
index: currentBlockIndex,
|
|
314
|
+
delta: { type: 'text_delta', text: delta.content },
|
|
315
|
+
})}\n\n`)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 📖 Tool call deltas (if model supports tool use)
|
|
319
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
320
|
+
for (const tc of delta.tool_calls) {
|
|
321
|
+
if (tc.function?.name) {
|
|
322
|
+
// 📖 New tool call — close previous block if open, then start new one
|
|
323
|
+
if (currentBlockIndex >= 0) {
|
|
324
|
+
this.push(`event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: currentBlockIndex })}\n\n`)
|
|
325
|
+
}
|
|
326
|
+
currentBlockIndex = nextBlockIndex++
|
|
327
|
+
this.push(`event: content_block_start\ndata: ${JSON.stringify({
|
|
328
|
+
type: 'content_block_start',
|
|
329
|
+
index: currentBlockIndex,
|
|
330
|
+
content_block: {
|
|
331
|
+
type: 'tool_use',
|
|
332
|
+
id: tc.id || randomUUID(),
|
|
333
|
+
name: tc.function.name,
|
|
334
|
+
input: {},
|
|
335
|
+
},
|
|
336
|
+
})}\n\n`)
|
|
337
|
+
}
|
|
338
|
+
if (tc.function?.arguments) {
|
|
339
|
+
this.push(`event: content_block_delta\ndata: ${JSON.stringify({
|
|
340
|
+
type: 'content_block_delta',
|
|
341
|
+
index: currentBlockIndex >= 0 ? currentBlockIndex : 0,
|
|
342
|
+
delta: { type: 'input_json_delta', partial_json: tc.function.arguments },
|
|
343
|
+
})}\n\n`)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 📖 Handle finish_reason for tool_calls — close ALL open blocks
|
|
349
|
+
if (choice.finish_reason === 'tool_calls' && currentBlockIndex >= 0) {
|
|
350
|
+
this.push(`event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: currentBlockIndex })}\n\n`)
|
|
351
|
+
currentBlockIndex = -1
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
callback()
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
flush(callback) {
|
|
358
|
+
// 📖 Process any remaining buffer
|
|
359
|
+
if (buffer.trim()) {
|
|
360
|
+
// ignore incomplete data
|
|
361
|
+
}
|
|
362
|
+
callback()
|
|
363
|
+
},
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
transform,
|
|
368
|
+
getUsage: () => ({ input_tokens: inputTokens, output_tokens: outputTokens }),
|
|
369
|
+
}
|
|
370
|
+
}
|