free-coding-models 0.3.0 → 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.
@@ -25,7 +25,13 @@ import {
25
25
  translateAnthropicToOpenAI,
26
26
  translateOpenAIToAnthropic,
27
27
  createAnthropicSSETransformer,
28
+ estimateAnthropicTokens,
28
29
  } from './anthropic-translator.js'
30
+ import {
31
+ translateResponsesToOpenAI,
32
+ translateOpenAIToResponses,
33
+ createResponsesSSETransformer,
34
+ } from './responses-translator.js'
29
35
 
30
36
  // ─── Helpers ─────────────────────────────────────────────────────────────────
31
37
 
@@ -227,7 +233,21 @@ export class ProxyServer {
227
233
  const msg = err.statusCode === 413 ? 'Request body too large' : 'Internal server error'
228
234
  sendJson(res, status, { error: msg })
229
235
  })
230
- } else if (req.method === 'POST' && (req.url === '/v1/completions' || req.url === '/v1/responses')) {
236
+ } else if (req.method === 'POST' && req.url === '/v1/messages/count_tokens') {
237
+ this._handleAnthropicCountTokens(req, res).catch(err => {
238
+ console.error('[proxy] Internal error:', err)
239
+ const status = err.statusCode === 413 ? 413 : 500
240
+ const msg = err.statusCode === 413 ? 'Request body too large' : 'Internal server error'
241
+ sendJson(res, status, { error: msg })
242
+ })
243
+ } else if (req.method === 'POST' && req.url === '/v1/responses') {
244
+ this._handleResponses(req, res).catch(err => {
245
+ console.error('[proxy] Internal error:', err)
246
+ const status = err.statusCode === 413 ? 413 : 500
247
+ const msg = err.statusCode === 413 ? 'Request body too large' : 'Internal server error'
248
+ sendJson(res, status, { error: msg })
249
+ })
250
+ } else if (req.method === 'POST' && req.url === '/v1/completions') {
231
251
  // These legacy/alternative OpenAI endpoints are not supported by the proxy.
232
252
  // Return 501 (not 404) so callers get a clear signal instead of silently failing.
233
253
  sendJson(res, 501, {
@@ -244,19 +264,24 @@ export class ProxyServer {
244
264
  _handleModels(res) {
245
265
  const seen = new Set()
246
266
  const data = []
267
+ const models = []
247
268
  for (const acct of this._accounts) {
248
269
  const publicModelId = acct.proxyModelId || acct.modelId
249
270
  if (!seen.has(publicModelId)) {
250
271
  seen.add(publicModelId)
251
- data.push({
272
+ const modelEntry = {
252
273
  id: publicModelId,
274
+ slug: publicModelId,
275
+ name: publicModelId,
253
276
  object: 'model',
254
277
  created: Math.floor(Date.now() / 1000),
255
278
  owned_by: 'proxy',
256
- })
279
+ }
280
+ data.push(modelEntry)
281
+ models.push(modelEntry)
257
282
  }
258
283
  }
259
- sendJson(res, 200, { object: 'list', data })
284
+ sendJson(res, 200, { object: 'list', data, models })
260
285
  }
261
286
 
262
287
  // ── POST /v1/chat/completions ──────────────────────────────────────────────
@@ -730,6 +755,146 @@ export class ProxyServer {
730
755
  }
731
756
  }
732
757
 
758
+ /**
759
+ * 📖 Count tokens for Anthropic Messages requests without calling upstream.
760
+ * 📖 Claude Code uses this endpoint for budgeting / UI hints, so a fast local
761
+ * 📖 estimate is enough to keep the flow working through the proxy.
762
+ */
763
+ async _handleAnthropicCountTokens(clientReq, clientRes) {
764
+ const rawBody = await readBody(clientReq)
765
+ let anthropicBody
766
+ try {
767
+ anthropicBody = JSON.parse(rawBody)
768
+ } catch {
769
+ return sendJson(clientRes, 400, { error: { type: 'invalid_request_error', message: 'Invalid JSON body' } })
770
+ }
771
+
772
+ sendJson(clientRes, 200, {
773
+ input_tokens: estimateAnthropicTokens(anthropicBody),
774
+ })
775
+ }
776
+
777
+ /**
778
+ * 📖 Handle OpenAI Responses API requests by translating them to chat
779
+ * 📖 completions, forwarding through the existing proxy path, then converting
780
+ * 📖 the result back to the Responses wire format.
781
+ */
782
+ async _handleResponses(clientReq, clientRes) {
783
+ const rawBody = await readBody(clientReq)
784
+ let responsesBody
785
+ try {
786
+ responsesBody = JSON.parse(rawBody)
787
+ } catch {
788
+ return sendJson(clientRes, 400, { error: 'Invalid JSON body' })
789
+ }
790
+
791
+ const isStreaming = responsesBody.stream === true || String(clientReq.headers.accept || '').includes('text/event-stream')
792
+ const openaiBody = translateResponsesToOpenAI({ ...responsesBody, stream: isStreaming })
793
+
794
+ if (isStreaming) {
795
+ await this._handleResponsesStreaming(openaiBody, responsesBody.model, clientRes)
796
+ } else {
797
+ await this._handleResponsesJson(openaiBody, responsesBody.model, clientRes)
798
+ }
799
+ }
800
+
801
+ async _handleResponsesJson(openaiBody, requestModel, clientRes) {
802
+ const capturedChunks = []
803
+ let capturedStatusCode = 200
804
+ let capturedHeaders = {}
805
+
806
+ const fakeRes = {
807
+ headersSent: false,
808
+ destroyed: false,
809
+ socket: null,
810
+ writeHead(statusCode, headers) {
811
+ capturedStatusCode = statusCode
812
+ capturedHeaders = headers || {}
813
+ this.headersSent = true
814
+ },
815
+ write(chunk) { capturedChunks.push(chunk) },
816
+ end(data) {
817
+ if (data) capturedChunks.push(data)
818
+ },
819
+ on() { return this },
820
+ once() { return this },
821
+ emit() { return false },
822
+ destroy() { this.destroyed = true },
823
+ removeListener() { return this },
824
+ }
825
+
826
+ await this._handleChatCompletionsInternal(openaiBody, fakeRes)
827
+
828
+ const responseBody = capturedChunks.join('')
829
+ if (capturedStatusCode >= 200 && capturedStatusCode < 300) {
830
+ try {
831
+ const openaiResponse = JSON.parse(responseBody)
832
+ const responsesResponse = translateOpenAIToResponses(openaiResponse, requestModel)
833
+ sendJson(clientRes, 200, responsesResponse)
834
+ } catch {
835
+ sendJson(clientRes, capturedStatusCode, responseBody)
836
+ }
837
+ return
838
+ }
839
+
840
+ // 📖 Forward upstream-style JSON errors unchanged for OpenAI-compatible clients.
841
+ sendJson(clientRes, capturedStatusCode, responseBody)
842
+ }
843
+
844
+ async _handleResponsesStreaming(openaiBody, requestModel, clientRes) {
845
+ const { transform } = createResponsesSSETransformer(requestModel)
846
+ await this._handleResponsesStreamDirect(openaiBody, clientRes, transform)
847
+ }
848
+
849
+ async _handleResponsesStreamDirect(openaiBody, clientRes, sseTransform) {
850
+ const fingerprint = createHash('sha256')
851
+ .update(JSON.stringify(openaiBody.messages?.slice(-1) ?? []))
852
+ .digest('hex')
853
+ .slice(0, 16)
854
+
855
+ const requestedModel = typeof openaiBody.model === 'string'
856
+ ? openaiBody.model.replace(/^fcm-proxy\//, '')
857
+ : undefined
858
+
859
+ if (requestedModel && !this._accountManager.hasAccountsForModel(requestedModel)) {
860
+ return sendJson(clientRes, 404, {
861
+ error: 'Model not found',
862
+ message: `Model '${requestedModel}' is not available.`,
863
+ })
864
+ }
865
+
866
+ sseTransform.pipe(clientRes)
867
+
868
+ for (let attempt = 0; attempt < this._retries; attempt++) {
869
+ const delay = this._retryDelays[Math.min(attempt, this._retryDelays.length - 1)]
870
+ if (delay > 0) await new Promise(r => setTimeout(r, delay + Math.random() * 100))
871
+
872
+ const selectOpts = attempt === 0
873
+ ? { sessionFingerprint: fingerprint, requestedModel }
874
+ : { requestedModel }
875
+ const account = this._accountManager.selectAccount(selectOpts)
876
+ if (!account) break
877
+
878
+ const result = await this._forwardRequestForResponsesStream(account, openaiBody, sseTransform, clientRes)
879
+ if (result.done) return
880
+
881
+ const { statusCode, responseBody, responseHeaders, networkError } = result
882
+ const classified = classifyError(
883
+ networkError ? 0 : statusCode,
884
+ responseBody || '',
885
+ responseHeaders || {}
886
+ )
887
+ this._accountManager.recordFailure(account.id, classified, { providerKey: account.providerKey })
888
+ if (!classified.shouldRetry) {
889
+ sseTransform.end()
890
+ return sendJson(clientRes, statusCode || 500, responseBody || JSON.stringify({ error: 'Upstream error' }))
891
+ }
892
+ }
893
+
894
+ sseTransform.end()
895
+ sendJson(clientRes, 503, { error: 'All accounts exhausted or unavailable' })
896
+ }
897
+
733
898
  /**
734
899
  * 📖 Handle non-streaming Anthropic Messages by internally dispatching to
735
900
  * chat completions logic and translating the JSON response back.
@@ -1002,6 +1167,95 @@ export class ProxyServer {
1002
1167
  })
1003
1168
  }
1004
1169
 
1170
+ /**
1171
+ * 📖 Forward a streaming chat-completions request and translate the upstream
1172
+ * 📖 SSE stream into Responses API events on the fly.
1173
+ */
1174
+ _forwardRequestForResponsesStream(account, body, sseTransform, clientRes) {
1175
+ return new Promise(resolve => {
1176
+ const newBody = { ...body, model: account.modelId, stream: true }
1177
+ const bodyStr = JSON.stringify(newBody)
1178
+ const baseUrl = account.url.replace(/\/$/, '')
1179
+ let upstreamUrl
1180
+ try {
1181
+ upstreamUrl = new URL(baseUrl + '/chat/completions')
1182
+ } catch {
1183
+ return resolve({ done: false, statusCode: 0, responseBody: 'Invalid upstream URL', networkError: true })
1184
+ }
1185
+
1186
+ const client = selectClient(account.url)
1187
+ const startTime = Date.now()
1188
+ const requestOptions = {
1189
+ hostname: upstreamUrl.hostname,
1190
+ port: upstreamUrl.port || (upstreamUrl.protocol === 'https:' ? 443 : 80),
1191
+ path: upstreamUrl.pathname + (upstreamUrl.search || ''),
1192
+ method: 'POST',
1193
+ headers: {
1194
+ 'authorization': `Bearer ${account.apiKey}`,
1195
+ 'content-type': 'application/json',
1196
+ 'content-length': Buffer.byteLength(bodyStr),
1197
+ },
1198
+ }
1199
+
1200
+ const upstreamReq = client.request(requestOptions, upstreamRes => {
1201
+ const { statusCode } = upstreamRes
1202
+
1203
+ if (statusCode >= 200 && statusCode < 300) {
1204
+ if (!clientRes.headersSent) {
1205
+ clientRes.writeHead(200, {
1206
+ 'content-type': 'text/event-stream',
1207
+ 'cache-control': 'no-cache',
1208
+ })
1209
+ }
1210
+
1211
+ upstreamRes.on('error', err => { if (!clientRes.destroyed) clientRes.destroy(err) })
1212
+ clientRes.on('error', () => { if (!upstreamRes.destroyed) upstreamRes.destroy() })
1213
+
1214
+ upstreamRes.pipe(sseTransform, { end: true })
1215
+ upstreamRes.on('end', () => {
1216
+ this._accountManager.recordSuccess(account.id, Date.now() - startTime)
1217
+ })
1218
+
1219
+ clientRes.on('close', () => {
1220
+ if (!upstreamRes.destroyed) upstreamRes.destroy()
1221
+ if (!upstreamReq.destroyed) upstreamReq.destroy()
1222
+ })
1223
+
1224
+ resolve({ done: true })
1225
+ } else {
1226
+ const chunks = []
1227
+ upstreamRes.on('data', chunk => chunks.push(chunk))
1228
+ upstreamRes.on('end', () => {
1229
+ resolve({
1230
+ done: false,
1231
+ statusCode,
1232
+ responseBody: Buffer.concat(chunks).toString(),
1233
+ responseHeaders: upstreamRes.headers,
1234
+ networkError: false,
1235
+ })
1236
+ })
1237
+ }
1238
+ })
1239
+
1240
+ upstreamReq.on('error', err => {
1241
+ resolve({
1242
+ done: false,
1243
+ statusCode: 0,
1244
+ responseBody: err.message,
1245
+ responseHeaders: {},
1246
+ networkError: true,
1247
+ })
1248
+ })
1249
+
1250
+ upstreamReq.setTimeout(this._upstreamTimeoutMs, () => {
1251
+ upstreamReq.destroy(new Error(`Upstream request timed out after ${this._upstreamTimeoutMs}ms`))
1252
+ })
1253
+
1254
+ upstreamReq.write(bodyStr)
1255
+ upstreamReq.end()
1256
+ })
1257
+ }
1258
+
1005
1259
  /**
1006
1260
  * 📖 Internal version of chat completions handler that takes a pre-parsed body.
1007
1261
  * 📖 Used by the Anthropic JSON translation path to avoid re-parsing.
package/src/proxy-sync.js CHANGED
@@ -14,9 +14,10 @@
14
14
  * @functions
15
15
  * → syncProxyToTool(toolMode, proxyInfo, mergedModels) — write proxy endpoint to tool config
16
16
  * → cleanupToolConfig(toolMode) — remove all FCM entries from tool config
17
+ * → resolveProxySyncToolMode(toolMode) — normalize a live tool mode to a proxy-syncable target
17
18
  * → getProxySyncableTools() — list of tools that support proxy sync
18
19
  *
19
- * @exports syncProxyToTool, cleanupToolConfig, getProxySyncableTools, PROXY_SYNCABLE_TOOLS
20
+ * @exports syncProxyToTool, cleanupToolConfig, resolveProxySyncToolMode, getProxySyncableTools, PROXY_SYNCABLE_TOOLS
20
21
  *
21
22
  * @see src/endpoint-installer.js — per-provider direct install (Y key flow)
22
23
  * @see src/opencode-sync.js — OpenCode-specific sync (used internally by this module)
@@ -38,6 +39,8 @@ export const PROXY_SYNCABLE_TOOLS = [
38
39
  'aider', 'amp', 'qwen', 'claude-code', 'codex', 'openhands',
39
40
  ]
40
41
 
42
+ const PROXY_SYNCABLE_CANONICAL = new Set(PROXY_SYNCABLE_TOOLS.map(tool => tool === 'opencode-desktop' ? 'opencode' : tool))
43
+
41
44
  // ─── Shared helpers ──────────────────────────────────────────────────────────
42
45
 
43
46
  function getDefaultPaths() {
@@ -120,6 +123,12 @@ function getDefaultMaxTokens(contextWindow) {
120
123
  return Math.max(4096, Math.min(contextWindow, 32768))
121
124
  }
122
125
 
126
+ export function resolveProxySyncToolMode(toolMode) {
127
+ if (typeof toolMode !== 'string' || toolMode.length === 0) return null
128
+ const canonical = toolMode === 'opencode-desktop' ? 'opencode' : toolMode
129
+ return PROXY_SYNCABLE_CANONICAL.has(canonical) ? canonical : null
130
+ }
131
+
123
132
  // ─── Per-tool sync functions ─────────────────────────────────────────────────
124
133
  // 📖 Each writes a single `fcm-proxy` provider entry with ALL models
125
134
 
@@ -358,8 +367,8 @@ function syncEnvTool(proxyInfo, mergedModels, toolMode) {
358
367
  * @returns {{ success: boolean, path?: string, modelCount?: number, error?: string }}
359
368
  */
360
369
  export function syncProxyToTool(toolMode, proxyInfo, mergedModels) {
361
- const canonical = toolMode === 'opencode-desktop' ? 'opencode' : toolMode
362
- if (!PROXY_SYNCABLE_TOOLS.includes(toolMode) && !PROXY_SYNCABLE_TOOLS.includes(canonical)) {
370
+ const canonical = resolveProxySyncToolMode(toolMode)
371
+ if (!canonical) {
363
372
  return { success: false, error: `Tool '${toolMode}' does not support proxy sync` }
364
373
  }
365
374
 
@@ -415,7 +424,10 @@ export function syncProxyToTool(toolMode, proxyInfo, mergedModels) {
415
424
  * @returns {{ success: boolean, error?: string }}
416
425
  */
417
426
  export function cleanupToolConfig(toolMode) {
418
- const canonical = toolMode === 'opencode-desktop' ? 'opencode' : toolMode
427
+ const canonical = resolveProxySyncToolMode(toolMode)
428
+ if (!canonical) {
429
+ return { success: false, error: `Tool '${toolMode}' does not support proxy cleanup` }
430
+ }
419
431
 
420
432
  try {
421
433
  const paths = getDefaultPaths()
@@ -150,10 +150,12 @@ export function sliceOverlayLines(lines, offset, terminalRows) {
150
150
 
151
151
  // 📖 calculateViewport: Computes the visible slice of model rows that fits in the terminal.
152
152
  // 📖 When scroll indicators are needed, they each consume 1 line from the model budget.
153
+ // 📖 `extraFixedLines` lets callers reserve temporary footer rows without shrinking the
154
+ // 📖 viewport permanently for the normal case.
153
155
  // 📖 Returns { startIdx, endIdx, hasAbove, hasBelow } for rendering.
154
- export function calculateViewport(terminalRows, scrollOffset, totalModels) {
156
+ export function calculateViewport(terminalRows, scrollOffset, totalModels, extraFixedLines = 0) {
155
157
  if (terminalRows <= 0) return { startIdx: 0, endIdx: totalModels, hasAbove: false, hasBelow: false }
156
- let maxSlots = terminalRows - TABLE_FIXED_LINES
158
+ let maxSlots = terminalRows - TABLE_FIXED_LINES - extraFixedLines
157
159
  if (maxSlots < 1) maxSlots = 1
158
160
  if (totalModels <= maxSlots) return { startIdx: 0, endIdx: totalModels, hasAbove: false, hasBelow: false }
159
161
 
@@ -15,6 +15,7 @@
15
15
  * - Smart badges (mode, tier filter, origin filter, profile)
16
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, padEndDisplay } from './render-helpers.js'
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, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, proxyEnabled = false, isOutdated = false, latestVersion = null) {
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']
@@ -336,7 +337,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
336
337
  }
337
338
 
338
339
  // 📖 Viewport clipping: only render models that fit on screen
339
- const vp = calculateViewport(terminalRows, scrollOffset, sorted.length)
340
+ const extraFooterLines = versionStatus.isOutdated ? 1 : 0
341
+ const vp = calculateViewport(terminalRows, scrollOffset, sorted.length, extraFooterLines)
340
342
 
341
343
  if (vp.hasAbove) {
342
344
  lines.push(chalk.dim(` ... ${vp.startIdx} more above ...`))
@@ -652,40 +654,36 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
652
654
  hotkey('I', ' Feedback, bugs & requests')
653
655
  )
654
656
  // 📖 Proxy status is now shown via the J badge in line 2 above — no need for a dedicated line
655
- if (versionStatus.isOutdated) {
656
- const outdatedBadge = chalk.bgRed.bold.yellow(' This version is outdated . ')
657
- const latestLabel = chalk.redBright(` local v${LOCAL_VERSION} · latest v${versionStatus.latestVersion}`)
658
- lines.push(` ${outdatedBadge}${latestLabel}`)
659
- }
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)
660
678
 
661
- // 📖 Build footer line, with OUTDATED warning if isOutdated is true
662
- let footerLine = ''
663
- if (isOutdated) {
664
- // 📖 Show OUTDATED in red background, high contrast warning
665
- footerLine = chalk.bgRed.bold.white(' ⚠ OUTDATED version, please update with "npm i -g free-coding-models@latest" ')
666
- } else {
667
- footerLine =
668
- chalk.rgb(255, 150, 200)(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
669
- chalk.dim(' • ') +
670
- '⭐ ' +
671
- chalk.yellow('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
672
- chalk.dim(' • ') +
673
- '🤝 ' +
674
- chalk.rgb(255, 165, 0)('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
675
- chalk.dim(' • ') +
676
- '☕ ' +
677
- chalk.rgb(255, 200, 100)('\x1b]8;;https://buymeacoffee.com/vavanessadev\x1b\\Buy me a coffee\x1b]8;;\x1b\\') +
678
- chalk.dim(' • ') +
679
- '💬 ' +
680
- chalk.rgb(200, 150, 255)('\x1b]8;;https://discord.gg/ZTNFHvvCkU\x1b\\Discord\x1b]8;;\x1b\\') +
681
- chalk.dim(' → ') +
682
- chalk.rgb(200, 150, 255)('https://discord.gg/ZTNFHvvCkU') +
683
- chalk.dim(' • ') +
684
- chalk.yellow('N') + chalk.dim(' Changelog') +
685
- chalk.dim(' • ') +
686
- 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))
687
686
  }
688
- lines.push(footerLine)
689
687
 
690
688
  // 📖 Append \x1b[K (erase to EOL) to each line so leftover chars from previous
691
689
  // 📖 frames are cleared. Then pad with blank cleared lines to fill the terminal,