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.
@@ -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, sendFeatureRequest, sendBugReport } from '../src/telemetry.js'
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
- // 📖 sendFeatureRequest, sendBugReport → imported from ../src/telemetry.js
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
- // 📖 Feature Request state (J key opens it)
433
- featureRequestOpen: false, // 📖 Whether the feature request overlay is active
434
- featureRequestBuffer: '', // 📖 Typed characters for the feature request message
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
- cleanupOpenCodeProxyConfig,
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.featureRequestOpen && !state.bugReportOpen && !state.changelogOpen) {
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.featureRequestOpen
802
- ? overlays.renderFeatureRequest()
803
- : state.bugReportOpen
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.2.17",
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/",
@@ -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
+ }