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.
- package/CHANGELOG.md +18 -0
- package/README.md +30 -20
- package/bin/free-coding-models.js +50 -19
- package/package.json +1 -1
- package/src/anthropic-translator.js +78 -8
- package/src/cli-help.js +108 -0
- package/src/config.js +2 -1
- package/src/endpoint-installer.js +5 -4
- package/src/key-handler.js +31 -34
- package/src/opencode.js +17 -12
- package/src/overlays.js +40 -53
- package/src/proxy-server.js +258 -4
- package/src/proxy-sync.js +16 -4
- package/src/render-helpers.js +4 -2
- package/src/render-table.js +34 -36
- package/src/responses-translator.js +423 -0
- package/src/tool-launchers.js +216 -19
- package/src/utils.js +31 -8
package/src/proxy-server.js
CHANGED
|
@@ -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' &&
|
|
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
|
-
|
|
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
|
|
362
|
-
if (!
|
|
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
|
|
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()
|
package/src/render-helpers.js
CHANGED
|
@@ -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
|
|
package/src/render-table.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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,
|