free-coding-models 0.2.17 → 0.3.1
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 +71 -0
- package/README.md +118 -44
- package/bin/fcm-proxy-daemon.js +239 -0
- package/bin/free-coding-models.js +146 -37
- package/package.json +3 -2
- package/src/account-manager.js +34 -0
- package/src/anthropic-translator.js +440 -0
- package/src/cli-help.js +108 -0
- package/src/config.js +25 -1
- package/src/daemon-manager.js +527 -0
- package/src/endpoint-installer.js +45 -19
- package/src/key-handler.js +324 -148
- package/src/opencode.js +47 -44
- package/src/overlays.js +282 -207
- package/src/proxy-server.js +746 -10
- package/src/proxy-sync.js +564 -0
- package/src/proxy-topology.js +80 -0
- package/src/render-helpers.js +4 -2
- package/src/render-table.js +56 -49
- package/src/responses-translator.js +423 -0
- package/src/tool-launchers.js +343 -26
- package/src/utils.js +31 -8
package/src/render-table.js
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* - Emoji-aware padding via padEndDisplay for aligned verdict/status cells
|
|
14
14
|
* - Viewport clipping with above/below indicators
|
|
15
15
|
* - Smart badges (mode, tier filter, origin filter, profile)
|
|
16
|
-
* - Proxy
|
|
16
|
+
* - Footer J badge: green "Proxy On" / red "Proxy Off" indicator with direct overlay access
|
|
17
17
|
* - Install-endpoints shortcut surfaced directly in the footer hints
|
|
18
|
+
* - Full-width red outdated-version banner when a newer npm release is known
|
|
18
19
|
* - Distinct auth-failure vs missing-key health labels so configured providers stay honest
|
|
19
20
|
*
|
|
20
21
|
* → Functions:
|
|
@@ -40,7 +41,7 @@ import { TIER_COLOR } from './tier-colors.js'
|
|
|
40
41
|
import { getAvg, getVerdict, getUptime, getStabilityScore, getVersionStatusInfo } from './utils.js'
|
|
41
42
|
import { usagePlaceholderForProvider } from './ping.js'
|
|
42
43
|
import { formatTokenTotalCompact } from './token-usage-reader.js'
|
|
43
|
-
import { calculateViewport, sortResultsWithPinnedFavorites,
|
|
44
|
+
import { calculateViewport, sortResultsWithPinnedFavorites, padEndDisplay, displayWidth } from './render-helpers.js'
|
|
44
45
|
import { getToolMeta } from './tool-metadata.js'
|
|
45
46
|
|
|
46
47
|
const ACTIVE_FILTER_BG_BY_TIER = {
|
|
@@ -92,7 +93,7 @@ export function setActiveProxy(proxyInstance) {
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
// ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
|
|
95
|
-
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, proxyEnabled = false,
|
|
96
|
+
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '', proxyStartupStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, proxyEnabled = false, startupLatestVersion = null, versionAlertsEnabled = true) {
|
|
96
97
|
// 📖 Filter out hidden models for display
|
|
97
98
|
const visibleResults = results.filter(r => !r.hidden)
|
|
98
99
|
|
|
@@ -140,7 +141,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
140
141
|
: chalk.bold.rgb(0, 200, 255)
|
|
141
142
|
const modeBadge = toolBadgeColor(' [ ') + chalk.yellow.bold('Z') + toolBadgeColor(` Tool : ${toolMeta.label} ]`)
|
|
142
143
|
const activeHeaderBadge = (text, bg = [57, 255, 20], fg = [0, 0, 0]) => chalk.bgRgb(...bg).rgb(...fg).bold(` ${text} `)
|
|
143
|
-
const versionStatus = getVersionStatusInfo(settingsUpdateState, settingsUpdateLatestVersion)
|
|
144
|
+
const versionStatus = getVersionStatusInfo(settingsUpdateState, settingsUpdateLatestVersion, startupLatestVersion, versionAlertsEnabled)
|
|
144
145
|
|
|
145
146
|
// 📖 Tier filter badge shown when filtering is active (shows exact tier name)
|
|
146
147
|
const TIER_CYCLE_NAMES = [null, 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
@@ -193,23 +194,25 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
193
194
|
const W_TOKENS = 7
|
|
194
195
|
const W_USAGE = 7
|
|
195
196
|
const MIN_TABLE_WIDTH = 166
|
|
196
|
-
const warningDurationMs =
|
|
197
|
+
const warningDurationMs = 4_000
|
|
197
198
|
const elapsed = widthWarningStartedAt ? Math.max(0, Date.now() - widthWarningStartedAt) : warningDurationMs
|
|
198
199
|
const remainingMs = Math.max(0, warningDurationMs - elapsed)
|
|
199
|
-
const showWidthWarning = terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH && !widthWarningDismissed && remainingMs > 0
|
|
200
|
+
const showWidthWarning = terminalCols > 0 && terminalCols < MIN_TABLE_WIDTH && !widthWarningDismissed && widthWarningShowCount < 2 && remainingMs > 0
|
|
200
201
|
|
|
201
202
|
if (showWidthWarning) {
|
|
202
203
|
const lines = []
|
|
203
|
-
const blankLines = Math.max(0, Math.floor(((terminalRows || 24) -
|
|
204
|
-
const warning = 'Please maximize your terminal for optimal use.'
|
|
205
|
-
const warning2 = 'The current terminal is too small.'
|
|
206
|
-
const warning3 = 'Reduce font size or maximize width of terminal.'
|
|
204
|
+
const blankLines = Math.max(0, Math.floor(((terminalRows || 24) - 7) / 2))
|
|
205
|
+
const warning = '🖥️ Please maximize your terminal for optimal use.'
|
|
206
|
+
const warning2 = '⚠️ The current terminal is too small.'
|
|
207
|
+
const warning3 = '📏 Reduce font size or maximize width of terminal.'
|
|
207
208
|
const padLeft = Math.max(0, Math.floor((terminalCols - warning.length) / 2))
|
|
208
209
|
const padLeft2 = Math.max(0, Math.floor((terminalCols - warning2.length) / 2))
|
|
209
210
|
const padLeft3 = Math.max(0, Math.floor((terminalCols - warning3.length) / 2))
|
|
210
211
|
for (let i = 0; i < blankLines; i++) lines.push('')
|
|
211
212
|
lines.push(' '.repeat(padLeft) + chalk.red.bold(warning))
|
|
213
|
+
lines.push('')
|
|
212
214
|
lines.push(' '.repeat(padLeft2) + chalk.red(warning2))
|
|
215
|
+
lines.push('')
|
|
213
216
|
lines.push(' '.repeat(padLeft3) + chalk.red(warning3))
|
|
214
217
|
lines.push('')
|
|
215
218
|
lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 34) / 2))) + chalk.yellow(`this message will hide in ${(remainingMs / 1000).toFixed(1)}s`))
|
|
@@ -334,7 +337,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
334
337
|
}
|
|
335
338
|
|
|
336
339
|
// 📖 Viewport clipping: only render models that fit on screen
|
|
337
|
-
const
|
|
340
|
+
const extraFooterLines = versionStatus.isOutdated ? 1 : 0
|
|
341
|
+
const vp = calculateViewport(terminalRows, scrollOffset, sorted.length, extraFooterLines)
|
|
338
342
|
|
|
339
343
|
if (vp.hasAbove) {
|
|
340
344
|
lines.push(chalk.dim(` ... ${vp.startIdx} more above ...`))
|
|
@@ -620,7 +624,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
620
624
|
const activeHotkey = (keyLabel, text, bg = [57, 255, 20], fg = [0, 0, 0]) => chalk.bgRgb(...bg).rgb(...fg)(` ${keyLabel}${text} `)
|
|
621
625
|
// 📖 Line 1: core navigation + filtering shortcuts
|
|
622
626
|
lines.push(
|
|
623
|
-
|
|
627
|
+
(proxyEnabled ? activeHotkey('J', ' 📡 FCM Proxy V2 On') : activeHotkey('J', ' 📡 FCM Proxy V2 Off', [180, 30, 30], [255, 255, 255])) +
|
|
628
|
+
chalk.dim(` • `) +
|
|
624
629
|
hotkey('F', ' Toggle Favorite') +
|
|
625
630
|
chalk.dim(` • `) +
|
|
626
631
|
(tierFilterMode > 0
|
|
@@ -631,7 +636,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
631
636
|
? activeHotkey('D', ` Provider (${activeOriginLabel})`, [0, 0, 0], PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
|
|
632
637
|
: hotkey('D', ' Provider')) +
|
|
633
638
|
chalk.dim(` • `) +
|
|
634
|
-
(hideUnconfiguredModels ? activeHotkey('E', ' Configured Only') : hotkey('E', ' Configured Only')) +
|
|
639
|
+
(hideUnconfiguredModels ? activeHotkey('E', ' Configured Models Only') : hotkey('E', ' Configured Models Only')) +
|
|
635
640
|
chalk.dim(` • `) +
|
|
636
641
|
hotkey('X', ' Token Logs') +
|
|
637
642
|
chalk.dim(` • `) +
|
|
@@ -639,44 +644,46 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
|
|
|
639
644
|
chalk.dim(` • `) +
|
|
640
645
|
hotkey('K', ' Help')
|
|
641
646
|
)
|
|
642
|
-
// 📖 Line 2: profiles, install flow, recommend,
|
|
643
|
-
lines.push(
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
647
|
+
// 📖 Line 2: profiles, install flow, recommend, proxy shortcut, feedback, and extended hints.
|
|
648
|
+
lines.push(
|
|
649
|
+
chalk.dim(` `) +
|
|
650
|
+
hotkey('⇧P', ' Cycle profile') + chalk.dim(` • `) +
|
|
651
|
+
hotkey('⇧S', ' Save profile') + chalk.dim(` • `) +
|
|
652
|
+
hotkey('Y', ' Install endpoints') + chalk.dim(` • `) +
|
|
653
|
+
hotkey('Q', ' Smart Recommend') + chalk.dim(` • `) +
|
|
654
|
+
hotkey('I', ' Feedback, bugs & requests')
|
|
655
|
+
)
|
|
656
|
+
// 📖 Proxy status is now shown via the J badge in line 2 above — no need for a dedicated line
|
|
657
|
+
const footerLine =
|
|
658
|
+
chalk.rgb(255, 150, 200)(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
|
|
659
|
+
chalk.dim(' • ') +
|
|
660
|
+
'⭐ ' +
|
|
661
|
+
chalk.yellow('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
|
|
662
|
+
chalk.dim(' • ') +
|
|
663
|
+
'🤝 ' +
|
|
664
|
+
chalk.rgb(255, 165, 0)('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
|
|
665
|
+
chalk.dim(' • ') +
|
|
666
|
+
'☕ ' +
|
|
667
|
+
chalk.rgb(255, 200, 100)('\x1b]8;;https://buymeacoffee.com/vavanessadev\x1b\\Buy me a coffee\x1b]8;;\x1b\\') +
|
|
668
|
+
chalk.dim(' • ') +
|
|
669
|
+
'💬 ' +
|
|
670
|
+
chalk.rgb(200, 150, 255)('\x1b]8;;https://discord.gg/ZTNFHvvCkU\x1b\\Discord\x1b]8;;\x1b\\') +
|
|
671
|
+
chalk.dim(' → ') +
|
|
672
|
+
chalk.rgb(200, 150, 255)('https://discord.gg/ZTNFHvvCkU') +
|
|
673
|
+
chalk.dim(' • ') +
|
|
674
|
+
chalk.yellow('N') + chalk.dim(' Changelog') +
|
|
675
|
+
chalk.dim(' • ') +
|
|
676
|
+
chalk.dim('Ctrl+C Exit')
|
|
677
|
+
lines.push(footerLine)
|
|
651
678
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
chalk.rgb(255, 150, 200)(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
|
|
660
|
-
chalk.dim(' • ') +
|
|
661
|
-
'⭐ ' +
|
|
662
|
-
chalk.yellow('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
|
|
663
|
-
chalk.dim(' • ') +
|
|
664
|
-
'🤝 ' +
|
|
665
|
-
chalk.rgb(255, 165, 0)('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
|
|
666
|
-
chalk.dim(' • ') +
|
|
667
|
-
'☕ ' +
|
|
668
|
-
chalk.rgb(255, 200, 100)('\x1b]8;;https://buymeacoffee.com/vavanessadev\x1b\\Buy me a coffee\x1b]8;;\x1b\\') +
|
|
669
|
-
chalk.dim(' • ') +
|
|
670
|
-
'💬 ' +
|
|
671
|
-
chalk.rgb(200, 150, 255)('\x1b]8;;https://discord.gg/ZTNFHvvCkU\x1b\\Discord\x1b]8;;\x1b\\') +
|
|
672
|
-
chalk.dim(' → ') +
|
|
673
|
-
chalk.rgb(200, 150, 255)('https://discord.gg/ZTNFHvvCkU') +
|
|
674
|
-
chalk.dim(' • ') +
|
|
675
|
-
chalk.yellow('N') + chalk.dim(' Changelog') +
|
|
676
|
-
chalk.dim(' • ') +
|
|
677
|
-
chalk.dim('Ctrl+C Exit')
|
|
679
|
+
if (versionStatus.isOutdated) {
|
|
680
|
+
const outdatedMessage = ` ⚠ Update available: v${LOCAL_VERSION} -> v${versionStatus.latestVersion}. If auto-update did not complete, run: npm install -g free-coding-models@latest`
|
|
681
|
+
const paddedBanner = terminalCols > 0
|
|
682
|
+
? outdatedMessage + ' '.repeat(Math.max(0, terminalCols - displayWidth(outdatedMessage)))
|
|
683
|
+
: outdatedMessage
|
|
684
|
+
// 📖 Reserve a dedicated full-width red row so the warning cannot blend into the footer links.
|
|
685
|
+
lines.push(chalk.bgRed.white.bold(paddedBanner))
|
|
678
686
|
}
|
|
679
|
-
lines.push(footerLine)
|
|
680
687
|
|
|
681
688
|
// 📖 Append \x1b[K (erase to EOL) to each line so leftover chars from previous
|
|
682
689
|
// 📖 frames are cleared. Then pad with blank cleared lines to fill the terminal,
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/responses-translator.js
|
|
3
|
+
* @description Bidirectional translation between the OpenAI Responses API wire format
|
|
4
|
+
* and the older OpenAI Chat Completions wire used by the upstream free providers.
|
|
5
|
+
*
|
|
6
|
+
* @details
|
|
7
|
+
* 📖 Codex CLI can speak either `responses` or `chat` depending on provider config.
|
|
8
|
+
* 📖 Our upstream accounts still expose `/chat/completions`, so this module converts:
|
|
9
|
+
* - Responses requests → Chat Completions requests
|
|
10
|
+
* - Chat Completions JSON/SSE responses → Responses JSON/SSE responses
|
|
11
|
+
*
|
|
12
|
+
* 📖 The implementation focuses on the items Codex actually uses:
|
|
13
|
+
* - `instructions` / `input` message history
|
|
14
|
+
* - function tools + function-call outputs
|
|
15
|
+
* - assistant text deltas
|
|
16
|
+
* - function call argument deltas
|
|
17
|
+
* - final `response.completed` payload with usage
|
|
18
|
+
*
|
|
19
|
+
* @functions
|
|
20
|
+
* → `translateResponsesToOpenAI` — convert a Responses request body to chat completions
|
|
21
|
+
* → `translateOpenAIToResponses` — convert a chat completions JSON response to Responses JSON
|
|
22
|
+
* → `createResponsesSSETransformer` — convert chat-completions SSE chunks to Responses SSE
|
|
23
|
+
*
|
|
24
|
+
* @exports translateResponsesToOpenAI, translateOpenAIToResponses, createResponsesSSETransformer
|
|
25
|
+
* @see src/proxy-server.js
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { randomUUID } from 'node:crypto'
|
|
29
|
+
import { Transform } from 'node:stream'
|
|
30
|
+
|
|
31
|
+
const MAX_SSE_BUFFER = 1 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
function serializeJsonish(value) {
|
|
34
|
+
if (typeof value === 'string') return value
|
|
35
|
+
try {
|
|
36
|
+
return JSON.stringify(value ?? '')
|
|
37
|
+
} catch {
|
|
38
|
+
return String(value ?? '')
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeResponseContent(content) {
|
|
43
|
+
if (typeof content === 'string') return [{ type: 'input_text', text: content }]
|
|
44
|
+
if (!Array.isArray(content)) return []
|
|
45
|
+
return content
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function contentPartToText(part) {
|
|
49
|
+
if (!part || typeof part !== 'object') return ''
|
|
50
|
+
if (typeof part.text === 'string') return part.text
|
|
51
|
+
if (part.type === 'reasoning' && typeof part.summary === 'string') return part.summary
|
|
52
|
+
return ''
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function pushTextMessage(messages, role, textParts) {
|
|
56
|
+
const text = textParts.join('\n').trim()
|
|
57
|
+
if (!text && role !== 'assistant') return
|
|
58
|
+
messages.push({ role, content: text || '' })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeFunctionToolCall(entry = {}) {
|
|
62
|
+
const callId = entry.call_id || entry.id || `call_${randomUUID().replace(/-/g, '')}`
|
|
63
|
+
return {
|
|
64
|
+
id: callId,
|
|
65
|
+
type: 'function',
|
|
66
|
+
function: {
|
|
67
|
+
name: entry.name || entry.function?.name || '',
|
|
68
|
+
arguments: typeof entry.arguments === 'string'
|
|
69
|
+
? entry.arguments
|
|
70
|
+
: serializeJsonish(entry.arguments || entry.function?.arguments || {}),
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function translateResponsesToOpenAI(body) {
|
|
76
|
+
if (!body || typeof body !== 'object') return { model: '', messages: [], stream: false }
|
|
77
|
+
|
|
78
|
+
const messages = []
|
|
79
|
+
|
|
80
|
+
if (typeof body.instructions === 'string' && body.instructions.trim()) {
|
|
81
|
+
messages.push({ role: 'system', content: body.instructions.trim() })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const inputItems = Array.isArray(body.input)
|
|
85
|
+
? body.input
|
|
86
|
+
: body.input != null
|
|
87
|
+
? [body.input]
|
|
88
|
+
: []
|
|
89
|
+
|
|
90
|
+
for (const item of inputItems) {
|
|
91
|
+
if (typeof item === 'string') {
|
|
92
|
+
messages.push({ role: 'user', content: item })
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (!item || typeof item !== 'object') continue
|
|
96
|
+
|
|
97
|
+
if (item.type === 'function_call') {
|
|
98
|
+
messages.push({ role: 'assistant', content: null, tool_calls: [makeFunctionToolCall(item)] })
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (item.type === 'function_call_output') {
|
|
103
|
+
messages.push({
|
|
104
|
+
role: 'tool',
|
|
105
|
+
tool_call_id: item.call_id || item.id || '',
|
|
106
|
+
content: serializeJsonish(item.output),
|
|
107
|
+
})
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (item.type === 'input_text' && typeof item.text === 'string') {
|
|
112
|
+
messages.push({ role: 'user', content: item.text })
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (item.type !== 'message') continue
|
|
117
|
+
|
|
118
|
+
const role = item.role === 'assistant'
|
|
119
|
+
? 'assistant'
|
|
120
|
+
: (item.role === 'developer' || item.role === 'system')
|
|
121
|
+
? 'system'
|
|
122
|
+
: 'user'
|
|
123
|
+
|
|
124
|
+
const textParts = []
|
|
125
|
+
const toolCalls = []
|
|
126
|
+
for (const part of normalizeResponseContent(item.content)) {
|
|
127
|
+
if (part.type === 'function_call') {
|
|
128
|
+
toolCalls.push(makeFunctionToolCall(part))
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
if (part.type === 'function_call_output') {
|
|
132
|
+
messages.push({
|
|
133
|
+
role: 'tool',
|
|
134
|
+
tool_call_id: part.call_id || part.id || '',
|
|
135
|
+
content: serializeJsonish(part.output),
|
|
136
|
+
})
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
const text = contentPartToText(part)
|
|
140
|
+
if (text) textParts.push(text)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (toolCalls.length > 0) {
|
|
144
|
+
messages.push({
|
|
145
|
+
role: 'assistant',
|
|
146
|
+
content: textParts.length > 0 ? textParts.join('\n') : null,
|
|
147
|
+
tool_calls: toolCalls,
|
|
148
|
+
})
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
pushTextMessage(messages, role, textParts)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const result = {
|
|
156
|
+
model: body.model,
|
|
157
|
+
messages,
|
|
158
|
+
stream: body.stream === true,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (body.max_output_tokens != null) result.max_tokens = body.max_output_tokens
|
|
162
|
+
if (body.temperature != null) result.temperature = body.temperature
|
|
163
|
+
if (body.top_p != null) result.top_p = body.top_p
|
|
164
|
+
|
|
165
|
+
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
166
|
+
result.tools = body.tools
|
|
167
|
+
.filter(tool => tool && typeof tool === 'object' && (tool.type === 'function' || typeof tool.name === 'string'))
|
|
168
|
+
.map(tool => ({
|
|
169
|
+
type: 'function',
|
|
170
|
+
function: {
|
|
171
|
+
name: tool.name || tool.function?.name || '',
|
|
172
|
+
description: tool.description || tool.function?.description || '',
|
|
173
|
+
parameters: tool.parameters || tool.input_schema || tool.function?.parameters || {},
|
|
174
|
+
},
|
|
175
|
+
}))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return result
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildResponsesOutput(message = {}) {
|
|
182
|
+
const output = []
|
|
183
|
+
const text = typeof message.content === 'string' ? message.content : ''
|
|
184
|
+
if (text || !Array.isArray(message.tool_calls) || message.tool_calls.length === 0) {
|
|
185
|
+
output.push({
|
|
186
|
+
id: `msg_${randomUUID().replace(/-/g, '')}`,
|
|
187
|
+
type: 'message',
|
|
188
|
+
status: 'completed',
|
|
189
|
+
role: 'assistant',
|
|
190
|
+
content: [{ type: 'output_text', text: text || '', annotations: [] }],
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (Array.isArray(message.tool_calls)) {
|
|
195
|
+
for (const toolCall of message.tool_calls) {
|
|
196
|
+
const callId = toolCall?.id || `call_${randomUUID().replace(/-/g, '')}`
|
|
197
|
+
output.push({
|
|
198
|
+
id: callId,
|
|
199
|
+
type: 'function_call',
|
|
200
|
+
status: 'completed',
|
|
201
|
+
call_id: callId,
|
|
202
|
+
name: toolCall?.function?.name || '',
|
|
203
|
+
arguments: toolCall?.function?.arguments || '{}',
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return output
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function translateOpenAIToResponses(openaiResponse, requestModel) {
|
|
212
|
+
const choice = openaiResponse?.choices?.[0] || {}
|
|
213
|
+
const message = choice?.message || {}
|
|
214
|
+
const inputTokens = openaiResponse?.usage?.prompt_tokens || 0
|
|
215
|
+
const outputTokens = openaiResponse?.usage?.completion_tokens || 0
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
id: openaiResponse?.id || `resp_${randomUUID().replace(/-/g, '')}`,
|
|
219
|
+
object: 'response',
|
|
220
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
221
|
+
status: 'completed',
|
|
222
|
+
model: requestModel || openaiResponse?.model || '',
|
|
223
|
+
output: buildResponsesOutput(message),
|
|
224
|
+
usage: {
|
|
225
|
+
input_tokens: inputTokens,
|
|
226
|
+
output_tokens: outputTokens,
|
|
227
|
+
total_tokens: inputTokens + outputTokens,
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function createResponseSseEvent(type, payload) {
|
|
233
|
+
return `event: ${type}\ndata: ${JSON.stringify({ type, ...payload })}\n\n`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function createResponsesSSETransformer(requestModel) {
|
|
237
|
+
let buffer = ''
|
|
238
|
+
let responseId = `resp_${randomUUID().replace(/-/g, '')}`
|
|
239
|
+
let messageItemId = `msg_${randomUUID().replace(/-/g, '')}`
|
|
240
|
+
let createdAt = Math.floor(Date.now() / 1000)
|
|
241
|
+
let createdSent = false
|
|
242
|
+
let messageAdded = false
|
|
243
|
+
let messageText = ''
|
|
244
|
+
let promptTokens = 0
|
|
245
|
+
let completionTokens = 0
|
|
246
|
+
const functionCalls = new Map()
|
|
247
|
+
|
|
248
|
+
const ensureStarted = (stream) => {
|
|
249
|
+
if (createdSent) return
|
|
250
|
+
createdSent = true
|
|
251
|
+
stream.push(createResponseSseEvent('response.created', {
|
|
252
|
+
response: {
|
|
253
|
+
id: responseId,
|
|
254
|
+
object: 'response',
|
|
255
|
+
created_at: createdAt,
|
|
256
|
+
status: 'in_progress',
|
|
257
|
+
model: requestModel || '',
|
|
258
|
+
output: [],
|
|
259
|
+
},
|
|
260
|
+
}))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const ensureMessageItem = (stream) => {
|
|
264
|
+
if (messageAdded) return
|
|
265
|
+
messageAdded = true
|
|
266
|
+
stream.push(createResponseSseEvent('response.output_item.added', {
|
|
267
|
+
output_index: 0,
|
|
268
|
+
item: {
|
|
269
|
+
id: messageItemId,
|
|
270
|
+
type: 'message',
|
|
271
|
+
status: 'in_progress',
|
|
272
|
+
role: 'assistant',
|
|
273
|
+
content: [{ type: 'output_text', text: '', annotations: [] }],
|
|
274
|
+
},
|
|
275
|
+
}))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const transform = new Transform({
|
|
279
|
+
transform(chunk, _encoding, callback) {
|
|
280
|
+
buffer += chunk.toString()
|
|
281
|
+
if (buffer.length > MAX_SSE_BUFFER) {
|
|
282
|
+
buffer = ''
|
|
283
|
+
return callback(new Error('Responses SSE buffer overflow'))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const lines = buffer.split('\n')
|
|
287
|
+
buffer = lines.pop() || ''
|
|
288
|
+
|
|
289
|
+
for (const line of lines) {
|
|
290
|
+
if (!line.startsWith('data: ')) continue
|
|
291
|
+
const payload = line.slice(6).trim()
|
|
292
|
+
|
|
293
|
+
if (payload === '[DONE]') {
|
|
294
|
+
ensureStarted(this)
|
|
295
|
+
ensureMessageItem(this)
|
|
296
|
+
|
|
297
|
+
const output = [{
|
|
298
|
+
id: messageItemId,
|
|
299
|
+
type: 'message',
|
|
300
|
+
status: 'completed',
|
|
301
|
+
role: 'assistant',
|
|
302
|
+
content: [{ type: 'output_text', text: messageText, annotations: [] }],
|
|
303
|
+
}]
|
|
304
|
+
this.push(createResponseSseEvent('response.output_item.done', {
|
|
305
|
+
output_index: 0,
|
|
306
|
+
item: output[0],
|
|
307
|
+
}))
|
|
308
|
+
|
|
309
|
+
const sortedCalls = [...functionCalls.entries()].sort((a, b) => a[0] - b[0])
|
|
310
|
+
for (const [index, call] of sortedCalls) {
|
|
311
|
+
const item = {
|
|
312
|
+
id: call.id,
|
|
313
|
+
type: 'function_call',
|
|
314
|
+
status: 'completed',
|
|
315
|
+
call_id: call.id,
|
|
316
|
+
name: call.name,
|
|
317
|
+
arguments: call.arguments,
|
|
318
|
+
}
|
|
319
|
+
output.push(item)
|
|
320
|
+
this.push(createResponseSseEvent('response.output_item.done', {
|
|
321
|
+
output_index: index + 1,
|
|
322
|
+
item,
|
|
323
|
+
}))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.push(createResponseSseEvent('response.completed', {
|
|
327
|
+
response: {
|
|
328
|
+
id: responseId,
|
|
329
|
+
object: 'response',
|
|
330
|
+
created_at: createdAt,
|
|
331
|
+
status: 'completed',
|
|
332
|
+
model: requestModel || '',
|
|
333
|
+
output,
|
|
334
|
+
usage: {
|
|
335
|
+
input_tokens: promptTokens,
|
|
336
|
+
output_tokens: completionTokens,
|
|
337
|
+
total_tokens: promptTokens + completionTokens,
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
}))
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let parsed
|
|
345
|
+
try {
|
|
346
|
+
parsed = JSON.parse(payload)
|
|
347
|
+
} catch {
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (typeof parsed.id === 'string' && parsed.id.length > 0) responseId = parsed.id
|
|
352
|
+
if (typeof parsed.model === 'string' && parsed.model.length > 0 && !requestModel) {
|
|
353
|
+
requestModel = parsed.model
|
|
354
|
+
}
|
|
355
|
+
if (parsed.usage) {
|
|
356
|
+
promptTokens = parsed.usage.prompt_tokens || promptTokens
|
|
357
|
+
completionTokens = parsed.usage.completion_tokens || completionTokens
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
ensureStarted(this)
|
|
361
|
+
const choice = parsed.choices?.[0]
|
|
362
|
+
if (!choice) continue
|
|
363
|
+
const delta = choice.delta || {}
|
|
364
|
+
|
|
365
|
+
if (typeof delta.content === 'string' && delta.content.length > 0) {
|
|
366
|
+
ensureMessageItem(this)
|
|
367
|
+
messageText += delta.content
|
|
368
|
+
this.push(createResponseSseEvent('response.output_text.delta', {
|
|
369
|
+
output_index: 0,
|
|
370
|
+
item_id: messageItemId,
|
|
371
|
+
content_index: 0,
|
|
372
|
+
delta: delta.content,
|
|
373
|
+
}))
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
377
|
+
for (const toolCallDelta of delta.tool_calls) {
|
|
378
|
+
const callIndex = Number.isInteger(toolCallDelta.index) ? toolCallDelta.index : functionCalls.size
|
|
379
|
+
const existing = functionCalls.get(callIndex) || {
|
|
380
|
+
id: toolCallDelta.id || `call_${randomUUID().replace(/-/g, '')}`,
|
|
381
|
+
name: '',
|
|
382
|
+
arguments: '',
|
|
383
|
+
added: false,
|
|
384
|
+
}
|
|
385
|
+
if (typeof toolCallDelta.id === 'string' && toolCallDelta.id.length > 0) {
|
|
386
|
+
existing.id = toolCallDelta.id
|
|
387
|
+
}
|
|
388
|
+
if (typeof toolCallDelta.function?.name === 'string' && toolCallDelta.function.name.length > 0) {
|
|
389
|
+
existing.name = toolCallDelta.function.name
|
|
390
|
+
}
|
|
391
|
+
if (!existing.added) {
|
|
392
|
+
existing.added = true
|
|
393
|
+
this.push(createResponseSseEvent('response.output_item.added', {
|
|
394
|
+
output_index: callIndex + 1,
|
|
395
|
+
item: {
|
|
396
|
+
id: existing.id,
|
|
397
|
+
type: 'function_call',
|
|
398
|
+
status: 'in_progress',
|
|
399
|
+
call_id: existing.id,
|
|
400
|
+
name: existing.name,
|
|
401
|
+
arguments: existing.arguments,
|
|
402
|
+
},
|
|
403
|
+
}))
|
|
404
|
+
}
|
|
405
|
+
if (typeof toolCallDelta.function?.arguments === 'string' && toolCallDelta.function.arguments.length > 0) {
|
|
406
|
+
existing.arguments += toolCallDelta.function.arguments
|
|
407
|
+
this.push(createResponseSseEvent('response.function_call_arguments.delta', {
|
|
408
|
+
output_index: callIndex + 1,
|
|
409
|
+
item_id: existing.id,
|
|
410
|
+
delta: toolCallDelta.function.arguments,
|
|
411
|
+
}))
|
|
412
|
+
}
|
|
413
|
+
functionCalls.set(callIndex, existing)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
callback()
|
|
419
|
+
},
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
return { transform }
|
|
423
|
+
}
|