free-coding-models 0.5.95 → 0.5.97
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/README.md +15 -11
- package/bin/free-coding-models.js +2 -0
- package/changelog/v0.5.96.md +22 -0
- package/changelog/v0.5.97.md +24 -0
- package/package.json +1 -1
- package/sources.js +197 -91
- package/src/core/config.js +28 -3
- package/src/core/i18n/index.js +49 -0
- package/src/core/i18n/locales/en.js +991 -0
- package/src/core/i18n/locales/zh-CN.js +991 -0
- package/src/core/opencode.js +15 -8
- package/src/core/ping.js +31 -1
- package/src/core/playground.js +20 -19
- package/src/core/provider-key-tester.js +1 -0
- package/src/core/provider-metadata.js +11 -2
- package/src/core/quota-capabilities.js +2 -0
- package/src/core/router-daemon.js +58 -8
- package/src/core/router-dashboard.js +15 -14
- package/src/core/router-v2/tui-dashboard.js +27 -26
- package/src/core/shell-env.js +6 -0
- package/src/core/utils.js +22 -0
- package/src/tui/app.js +2 -0
- package/src/tui/cli-help.js +45 -21
- package/src/tui/command-palette.js +19 -1
- package/src/tui/key-handler.js +21 -1
- package/src/tui/overlays.js +113 -107
- package/src/tui/render-helpers.js +4 -3
- package/src/tui/render-table.js +137 -132
- package/src/tui/theme.js +2 -0
- package/web/dist/assets/index-C2lIYp0O.js +45 -0
- package/web/dist/assets/index-DZFdxy2N.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +58 -6
- package/web/dist/assets/index-BOpGJzSO.js +0 -45
- package/web/dist/assets/index-CCaxIOti.css +0 -1
package/src/core/opencode.js
CHANGED
|
@@ -165,6 +165,11 @@ async function createZaiProxy(apiKey) {
|
|
|
165
165
|
// ─── Shared OpenCode spawn helper ─────────────────────────────────────────────
|
|
166
166
|
|
|
167
167
|
// 📖 spawnOpenCode: Resolve API keys + spawn opencode CLI with correct env.
|
|
168
|
+
// 📖 Never pass --model on the command line: opencode v2 rejects flags its
|
|
169
|
+
// 📖 entry points don't know ("Unrecognized flag: --model", issue #194) and
|
|
170
|
+
// 📖 `opencode web` never accepted it. Every caller writes `config.model`
|
|
171
|
+
// 📖 into opencode.json first, so a bare launch picks the right model on
|
|
172
|
+
// 📖 both v1 and v2.
|
|
168
173
|
async function spawnOpenCode(args, providerKey, fcmConfig, existingZaiProxy = null) {
|
|
169
174
|
const envVarName = ENV_VAR_NAMES[providerKey]
|
|
170
175
|
const resolvedKey = getApiKey(fcmConfig, providerKey)
|
|
@@ -295,7 +300,7 @@ export async function startOpenCode(model, fcmConfig) {
|
|
|
295
300
|
console.log(chalk.dim(' Starting OpenCode...'))
|
|
296
301
|
console.log()
|
|
297
302
|
|
|
298
|
-
await spawnOpenCode([
|
|
303
|
+
await spawnOpenCode([], providerKey, fcmConfig)
|
|
299
304
|
return
|
|
300
305
|
}
|
|
301
306
|
|
|
@@ -359,7 +364,7 @@ export async function startOpenCode(model, fcmConfig) {
|
|
|
359
364
|
console.log(chalk.dim(' Starting OpenCode...'))
|
|
360
365
|
console.log()
|
|
361
366
|
|
|
362
|
-
await spawnOpenCode([
|
|
367
|
+
await spawnOpenCode([], providerKey, fcmConfig, zaiProxyServer)
|
|
363
368
|
return
|
|
364
369
|
}
|
|
365
370
|
|
|
@@ -394,7 +399,7 @@ export async function startOpenCode(model, fcmConfig) {
|
|
|
394
399
|
}
|
|
395
400
|
console.log()
|
|
396
401
|
|
|
397
|
-
await spawnOpenCode([
|
|
402
|
+
await spawnOpenCode([], providerKey, fcmConfig)
|
|
398
403
|
return
|
|
399
404
|
}
|
|
400
405
|
|
|
@@ -584,7 +589,7 @@ export async function startOpenCode(model, fcmConfig) {
|
|
|
584
589
|
console.log(chalk.dim(' Starting OpenCode...'))
|
|
585
590
|
console.log()
|
|
586
591
|
|
|
587
|
-
await spawnOpenCode([
|
|
592
|
+
await spawnOpenCode([], providerKey, fcmConfig)
|
|
588
593
|
}
|
|
589
594
|
|
|
590
595
|
// ─── Start OpenCode Web ───────────────────────────────────────────────────────
|
|
@@ -640,7 +645,9 @@ export async function startOpenCodeWeb(model, fcmConfig) {
|
|
|
640
645
|
console.log(chalk.dim(' Starting OpenCode Web...'))
|
|
641
646
|
console.log()
|
|
642
647
|
|
|
643
|
-
|
|
648
|
+
// 📖 `opencode web` accepts no --model flag; the default written in
|
|
649
|
+
// 📖 opencode.json above is what the web UI starts with.
|
|
650
|
+
await spawnOpenCode(['web'], providerKey, fcmConfig)
|
|
644
651
|
}
|
|
645
652
|
|
|
646
653
|
// ─── Start OpenCode Desktop ───────────────────────────────────────────────────
|
|
@@ -672,9 +679,9 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
|
|
|
672
679
|
execFile(cmd, args, (err) => resolve(!err))
|
|
673
680
|
})
|
|
674
681
|
const launched =
|
|
675
|
-
(await tryExecFile('opencode-desktop', [
|
|
676
|
-
(await tryExecFile('flatpak', ['run', 'ai.opencode.OpenCode'
|
|
677
|
-
(await tryExecFile('snap', ['run', 'opencode'
|
|
682
|
+
(await tryExecFile('opencode-desktop', [])) ||
|
|
683
|
+
(await tryExecFile('flatpak', ['run', 'ai.opencode.OpenCode'])) ||
|
|
684
|
+
(await tryExecFile('snap', ['run', 'opencode'])) ||
|
|
678
685
|
(await tryExecFile('xdg-open', ['/usr/share/applications/opencode.desktop']))
|
|
679
686
|
if (!launched) printDesktopLaunchError()
|
|
680
687
|
return
|
package/src/core/ping.js
CHANGED
|
@@ -200,6 +200,30 @@ async function isDisabledThinkingRejected(resp, req) {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
// 📖 Pollinations paid-wall sniffing (issue #190): models that left the free
|
|
204
|
+
// 📖 tier answer HTTP 200 with the error embedded in the completion content
|
|
205
|
+
// ("The account behind this API key doesn't have enough credits. This model
|
|
206
|
+
// needs paid Pollen..."). Status codes alone mark them healthy, so sniff the
|
|
207
|
+
// body and surface 402 (Payment Required) instead. Scoped to pollinations to
|
|
208
|
+
// avoid false positives on other providers.
|
|
209
|
+
const POLLINATIONS_PAID_WALL_MARKERS = [
|
|
210
|
+
// 📖 Apostrophe-free fragments: the API may return straight ' or typographic ’
|
|
211
|
+
'have enough credits',
|
|
212
|
+
'needs paid pollen',
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
async function isPollinationsPaidWall(resp) {
|
|
216
|
+
try {
|
|
217
|
+
const body = await resp.clone().json()
|
|
218
|
+
const content = body?.choices?.[0]?.message?.content
|
|
219
|
+
if (typeof content !== 'string') return false
|
|
220
|
+
const lower = content.toLowerCase()
|
|
221
|
+
return POLLINATIONS_PAID_WALL_MARKERS.some((marker) => lower.includes(marker))
|
|
222
|
+
} catch {
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
203
227
|
// 📖 ping: Send a single chat completion request to measure model availability and latency.
|
|
204
228
|
// 📖 providerKey and url determine provider-specific request format.
|
|
205
229
|
// 📖 apiKey can be null — in that case no Authorization header is sent.
|
|
@@ -234,7 +258,13 @@ export async function ping(apiKey, modelId, providerKey, url) {
|
|
|
234
258
|
if (retryMs > 0) pauseProviderQuota(providerKey, retryMs)
|
|
235
259
|
}
|
|
236
260
|
// 📖 Normalize all HTTP 2xx statuses to "200" so existing verdict/avg logic still works.
|
|
237
|
-
|
|
261
|
+
let code = resp.status >= 200 && resp.status < 300 ? '200' : String(resp.status)
|
|
262
|
+
// 📖 Pollinations paid-wall (issue #190): a 200 whose completion content is
|
|
263
|
+
// 📖 the credits error means the model is NOT usable on a free key. Report
|
|
264
|
+
// 📖 402 so rows show a failure instead of a fake-healthy green.
|
|
265
|
+
if (code === '200' && providerKey === 'pollinations' && (await isPollinationsPaidWall(resp))) {
|
|
266
|
+
code = '402'
|
|
267
|
+
}
|
|
238
268
|
// 📖 Passive quota tracker (t2): parse the response headers via the shared module.
|
|
239
269
|
// 📖 1) Write to the passive snapshot map so /stats + TUI footer see live quota.
|
|
240
270
|
// 📖 2) Return the percent number for the per-call consumer below.
|
package/src/core/playground.js
CHANGED
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
* @see ../tui/overlays.js — overlay factory that mounts this renderer
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { displayWidth, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
|
|
24
|
+
import { displayWidth, sliceOverlayLines, tintOverlayLines, truncateAnsiWidth } from '../tui/render-helpers.js'
|
|
25
25
|
import { ROUTER_PORT_PATH, getRouterPortPath } from './router-daemon.js'
|
|
26
26
|
import { existsSync, readFileSync } from 'node:fs'
|
|
27
27
|
import { themeColors } from '../tui/theme.js'
|
|
28
|
+
import { t } from './i18n/index.js'
|
|
28
29
|
|
|
29
30
|
// 📖 Width budget for the wrapped input + transcript columns inside the
|
|
30
31
|
// 📖 overlay. Slightly tighter than the full terminal so borders have room
|
|
@@ -162,7 +163,7 @@ export async function playgroundSubmit(state, deps = {}) {
|
|
|
162
163
|
|
|
163
164
|
const port = await readDaemonPort()
|
|
164
165
|
if (!port) {
|
|
165
|
-
PLAYGROUND_OVERLAY_STATE.lastError = '
|
|
166
|
+
PLAYGROUND_OVERLAY_STATE.lastError = t('playground.routerStartError')
|
|
166
167
|
return
|
|
167
168
|
}
|
|
168
169
|
|
|
@@ -173,7 +174,7 @@ export async function playgroundSubmit(state, deps = {}) {
|
|
|
173
174
|
PLAYGROUND_OVERLAY_STATE.cursor = 0
|
|
174
175
|
PLAYGROUND_OVERLAY_STATE.busy = true
|
|
175
176
|
PLAYGROUND_OVERLAY_STATE.lastError = null
|
|
176
|
-
PLAYGROUND_OVERLAY_STATE.statusMessage = '
|
|
177
|
+
PLAYGROUND_OVERLAY_STATE.statusMessage = t('playground.sending')
|
|
177
178
|
|
|
178
179
|
const controller = new AbortController()
|
|
179
180
|
PLAYGROUND_OVERLAY_STATE.abortController = controller
|
|
@@ -403,22 +404,22 @@ export function renderPlayground(state, terminalRows, terminalCols) {
|
|
|
403
404
|
const innerWidth = Math.max(40, terminalCols - 8)
|
|
404
405
|
|
|
405
406
|
// 📖 Header
|
|
406
|
-
lines.push(themeColors.accentBold(
|
|
407
|
-
lines.push(themeColors.dim(
|
|
407
|
+
lines.push(themeColors.accentBold(` 💬 ${t('playground.title')} — ${t('playground.chatViaRouter')}`))
|
|
408
|
+
lines.push(themeColors.dim(` ${t('playground.tuiHotkeys')}`))
|
|
408
409
|
lines.push('')
|
|
409
410
|
|
|
410
411
|
// 📖 Persona pill (one liner preview of the pre-prompt)
|
|
411
412
|
const pre = PLAYGROUND_OVERLAY_STATE.prePrompt
|
|
412
413
|
if (pre && pre.enabled && pre.text) {
|
|
413
|
-
const preview = pre.text
|
|
414
|
-
lines.push(themeColors.dim(`
|
|
414
|
+
const preview = displayWidth(pre.text) > innerWidth - 16 ? truncateAnsiWidth(pre.text.replace(/\n+/g, ' '), innerWidth - 16, { ellipsis: '…' }) : pre.text.replace(/\n+/g, ' ')
|
|
415
|
+
lines.push(themeColors.dim(` ${t('playground.persona')}: ${preview}`))
|
|
415
416
|
} else {
|
|
416
|
-
lines.push(themeColors.dim('
|
|
417
|
+
lines.push(themeColors.dim(` ${t('playground.persona')}: ${t('common.none')}`))
|
|
417
418
|
}
|
|
418
419
|
|
|
419
420
|
// 📖 Model + mode row
|
|
420
|
-
const mode = PLAYGROUND_OVERLAY_STATE.streamOn ? '
|
|
421
|
-
lines.push(themeColors.dim(`
|
|
421
|
+
const mode = PLAYGROUND_OVERLAY_STATE.streamOn ? t('playground.streamOn') : t('playground.streamOff')
|
|
422
|
+
lines.push(themeColors.dim(` ${t('playground.model')}: ${PLAYGROUND_OVERLAY_STATE.model} · ${mode}`))
|
|
422
423
|
if (PLAYGROUND_OVERLAY_STATE.lastError) {
|
|
423
424
|
lines.push(themeColors.errorBold(` ⚠ ${PLAYGROUND_OVERLAY_STATE.lastError}`))
|
|
424
425
|
}
|
|
@@ -428,7 +429,7 @@ export function renderPlayground(state, terminalRows, terminalCols) {
|
|
|
428
429
|
const transcriptLines = []
|
|
429
430
|
for (const msg of PLAYGROUND_OVERLAY_STATE.messages) {
|
|
430
431
|
const role = msg.role === 'user'
|
|
431
|
-
? themeColors.accentBold(
|
|
432
|
+
? themeColors.accentBold(` ❯ ${t('playground.you')}`)
|
|
432
433
|
: msg.role === 'assistant'
|
|
433
434
|
? themeColors.dim(' ✦ fcm')
|
|
434
435
|
: themeColors.dim(` · ${msg.role}`)
|
|
@@ -439,18 +440,18 @@ export function renderPlayground(state, terminalRows, terminalCols) {
|
|
|
439
440
|
}
|
|
440
441
|
if (msg.role === 'assistant' && msg.meta) {
|
|
441
442
|
const metaChips = []
|
|
442
|
-
if (msg.meta.provider) metaChips.push(
|
|
443
|
+
if (msg.meta.provider) metaChips.push(`${t('playground.routed')} ${msg.meta.provider}/${msg.meta.model || '?'}`)
|
|
443
444
|
if (msg.meta.latencyMs != null) metaChips.push(`${msg.meta.latencyMs}ms`)
|
|
444
|
-
if (msg.meta.tokens) metaChips.push(`${msg.meta.tokens}
|
|
445
|
-
if (msg.meta.fallbackAttempts) metaChips.push(`${msg.meta.fallbackAttempts}
|
|
445
|
+
if (msg.meta.tokens) metaChips.push(`${msg.meta.tokens} ${t('playground.tokensShort')}`)
|
|
446
|
+
if (msg.meta.fallbackAttempts) metaChips.push(`${msg.meta.fallbackAttempts} ${t('playground.fallbackShort')}`)
|
|
446
447
|
if (metaChips.length) {
|
|
447
448
|
transcriptLines.push(themeColors.dim(` [ ${metaChips.join(' · ')} ]`))
|
|
448
449
|
}
|
|
449
450
|
if (msg.meta.aborted) {
|
|
450
|
-
transcriptLines.push(themeColors.dim(
|
|
451
|
+
transcriptLines.push(themeColors.dim(` [ ${t('playground.stopped')} ]`))
|
|
451
452
|
}
|
|
452
453
|
if (msg.meta.error) {
|
|
453
|
-
transcriptLines.push(themeColors.error(` [ error: ${msg.meta.error} ]`))
|
|
454
|
+
transcriptLines.push(themeColors.error(` [ ${t('common.error')}: ${msg.meta.error} ]`))
|
|
454
455
|
}
|
|
455
456
|
}
|
|
456
457
|
transcriptLines.push('')
|
|
@@ -465,16 +466,16 @@ export function renderPlayground(state, terminalRows, terminalCols) {
|
|
|
465
466
|
// 📖 Input box
|
|
466
467
|
lines.push(themeColors.dim(' ─'.repeat(Math.max(8, Math.floor(innerWidth / 4)))))
|
|
467
468
|
if (PLAYGROUND_OVERLAY_STATE.busy) {
|
|
468
|
-
lines.push(themeColors.accent(
|
|
469
|
+
lines.push(themeColors.accent(` ⏳ ${t('playground.waitingResponse')}`))
|
|
469
470
|
} else {
|
|
470
|
-
const draft = PLAYGROUND_OVERLAY_STATE.draft || '
|
|
471
|
+
const draft = PLAYGROUND_OVERLAY_STATE.draft || t('playground.draftPlaceholder')
|
|
471
472
|
const draftDisplay = PLAYGROUND_OVERLAY_STATE.draft ? draft : themeColors.dim(draft)
|
|
472
473
|
const wrappedDraft = wrapMessage(draftDisplay, innerWidth - 4)
|
|
473
474
|
for (const line of wrappedDraft) {
|
|
474
475
|
lines.push(` ❯ ${line}`)
|
|
475
476
|
}
|
|
476
477
|
if (!PLAYGROUND_OVERLAY_STATE.draft) {
|
|
477
|
-
lines.push(themeColors.dim(
|
|
478
|
+
lines.push(themeColors.dim(` (${t('playground.enterToSend')} · Shift+Tab ${t('playground.pinModel')})`))
|
|
478
479
|
}
|
|
479
480
|
}
|
|
480
481
|
|
|
@@ -70,6 +70,7 @@ export const PROVIDER_AUTH_ENDPOINTS = {
|
|
|
70
70
|
requesty: null,
|
|
71
71
|
orcarouter: { url: 'https://api.orcarouter.ai/v1/models', method: 'GET' },
|
|
72
72
|
'vercel-gateway': { url: 'https://ai-gateway.vercel.sh/v1/models', method: 'GET' },
|
|
73
|
+
onomeo: { url: 'https://onomeo.com/v1/models', method: 'GET' },
|
|
73
74
|
together: { url: 'https://api.together.xyz/v1/models', method: 'GET' },
|
|
74
75
|
perplexity: { url: 'https://api.perplexity.ai/v1/models', method: 'GET' },
|
|
75
76
|
chutes: { url: 'https://chutes.ai/v1/models', method: 'GET' },
|
|
@@ -67,6 +67,7 @@ export const ENV_VAR_NAMES = {
|
|
|
67
67
|
requesty: 'REQUESTY_API_KEY',
|
|
68
68
|
orcarouter: 'ORCAROUTER_API_KEY',
|
|
69
69
|
'vercel-gateway': 'VERCEL_AI_GATEWAY_API_KEY',
|
|
70
|
+
onomeo: 'ONOMEO_API_KEY',
|
|
70
71
|
// 📖 Providers whose OpenCode/Kilo config writers reference {env:VAR} - names
|
|
71
72
|
// 📖 must match those tables exactly (see opencode.js / kilo.js).
|
|
72
73
|
huggingface: 'HUGGINGFACE_API_KEY',
|
|
@@ -347,8 +348,8 @@ export const PROVIDER_METADATA = {
|
|
|
347
348
|
color: chalk.rgb(255, 138, 64),
|
|
348
349
|
signupUrl: 'https://www.orcarouter.ai',
|
|
349
350
|
signupHint: 'Register (GitHub OAuth, no credit card) → API keys',
|
|
350
|
-
rateLimits: 'Free
|
|
351
|
-
detailedLimits: 'Zero-markup AI gateway: token prices are passed through at provider rates.\nFree
|
|
351
|
+
rateLimits: 'Free · 10 req/min · 50 req/day · zero token markup',
|
|
352
|
+
detailedLimits: 'Zero-markup AI gateway: token prices are passed through at provider rates.\nFree tier: 10 req/min, 50 req/day, GitHub account required, hidden per-request prompt cap.\nOnly the explicit $-0 models are listed in this catalog (verified against /api/free-package/public, 2026-09-29). The orcarouter/fusion family bills the sum of its paid panel legs, it is not free.',
|
|
352
353
|
},
|
|
353
354
|
'vercel-gateway': {
|
|
354
355
|
label: 'Vercel AI Gateway',
|
|
@@ -358,6 +359,14 @@ export const PROVIDER_METADATA = {
|
|
|
358
359
|
rateLimits: 'Free tier · $5 credits/30 days + $0 models',
|
|
359
360
|
detailedLimits: 'Official Vercel gateway at list prices, zero markup.\nEvery account gets $5 of credits every 30 days, no card required.\nThe monthly credit covers a subset of the catalog; the explicit $0 models stay free.\nCaution: purchasing credits once permanently moves the account to the paid tier and the monthly free credit stops.',
|
|
360
361
|
},
|
|
362
|
+
onomeo: {
|
|
363
|
+
label: 'onomeo',
|
|
364
|
+
color: chalk.rgb(240, 120, 60),
|
|
365
|
+
signupUrl: 'https://onomeo.com/dashboard',
|
|
366
|
+
signupHint: 'Sign in (email, Google or GitHub, no card) → check in daily → Dashboard → API key',
|
|
367
|
+
rateLimits: 'Free models, no credits · 12 req/min per key · 60 req/5h per account',
|
|
368
|
+
detailedLimits: 'Public beta: not every feature is guaranteed to work; feedback is welcome at https://onomeo.com/feedback.\nThe listed models are free models: they spend no credits and are limited by calls. No card needed.\nLimits: 12 req/min per key, 60 calls per 5 hours per account (a long request counts as several), 120 per 5 hours per IP, 450 per 5 hours shared site-wide by all accounts. Health probes count against them.\nPremium models spend credits from a daily check-in (20,000 on day 1, rising to 50,000/day from day 7 of a streak); each account that has not paid can spend up to 50,000 credits/day on them. Optional: $5/month buys 3,000,000 credits a month.\n31 of the 47 models may train on prompts; each model page on onomeo.com says which.',
|
|
369
|
+
},
|
|
361
370
|
'ollama-cloud': {
|
|
362
371
|
label: 'Ollama Cloud',
|
|
363
372
|
color: chalk.rgb(230, 230, 230),
|
|
@@ -67,6 +67,8 @@ export const PROVIDER_CAPABILITIES = {
|
|
|
67
67
|
orcarouter: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'unknown' },
|
|
68
68
|
// 📖 Vercel AI Gateway exposes GET /v1/credits but no fetcher is wired yet; treat as monthly reset.
|
|
69
69
|
'vercel-gateway': { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'monthly' },
|
|
70
|
+
// 📖 onomeo: daily check-in credits; balance is at GET /api/me but no fetcher is wired yet.
|
|
71
|
+
onomeo: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
/** Fallback for unrecognized providers */
|
|
@@ -55,6 +55,8 @@ import { TIER_ORDER } from './utils.js'
|
|
|
55
55
|
import { atomicWriteJson, safeJsonParse, sleep, maskApiKey, isRouteableProvider } from './shared-helpers.js'
|
|
56
56
|
import { normalizeRequestBody } from './schema-normalizer.js'
|
|
57
57
|
import { pickNextCandidate } from './model-family.js'
|
|
58
|
+
// 📖 Shared install-target list (same order the TUI Z-cycle and web/server.js use)
|
|
59
|
+
import { getInstallTargetModes } from './endpoint-installer.js'
|
|
58
60
|
import {
|
|
59
61
|
loadCache as loadProbeCache,
|
|
60
62
|
flushCache as flushProbeCache,
|
|
@@ -308,7 +310,10 @@ export function isPrivateNetworkHostname(hostname) {
|
|
|
308
310
|
}
|
|
309
311
|
|
|
310
312
|
// 📖 Cache the FCM_ALLOWED_ORIGINS env var split into a Set on first access.
|
|
311
|
-
// 📖 Format: comma-separated origin URLs, e.g. "http://mybox:19280,http://10.0.0.5:19280"
|
|
313
|
+
// 📖 Format: comma-separated origin URLs, e.g. "http://mybox:19280,http://10.0.0.5:19280".
|
|
314
|
+
// 📖 The special entry "*" allowlists every origin: the user explicitly opted
|
|
315
|
+
// 📖 out of origin checking, which is handy for LAN/Docker setups where the
|
|
316
|
+
// 📖 dashboard hostname changes (mDNS names, DHCP IPs).
|
|
312
317
|
let _allowedOriginsCache = null
|
|
313
318
|
function getAllowedOrigins() {
|
|
314
319
|
if (_allowedOriginsCache === null) {
|
|
@@ -321,6 +326,16 @@ function getAllowedOrigins() {
|
|
|
321
326
|
return _allowedOriginsCache
|
|
322
327
|
}
|
|
323
328
|
|
|
329
|
+
function isWildcardOriginsEnabled() {
|
|
330
|
+
return getAllowedOrigins().includes('*')
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 📖 Test-only: drop the cached FCM_ALLOWED_ORIGINS list so a test that
|
|
334
|
+
// 📖 mutates process.env sees fresh parsing instead of the first-read value.
|
|
335
|
+
export function resetAllowedOriginsCacheForTest() {
|
|
336
|
+
_allowedOriginsCache = null
|
|
337
|
+
}
|
|
338
|
+
|
|
324
339
|
// 📖 v2: CORS for loopback (and explicitly allowed) origins so a browser
|
|
325
340
|
// dashboard served from another local port (web dashboard 3333) can call
|
|
326
341
|
// this daemon directly without a proxy.
|
|
@@ -333,7 +348,8 @@ function applyCors(req, res) {
|
|
|
333
348
|
} catch {
|
|
334
349
|
return
|
|
335
350
|
}
|
|
336
|
-
const allowed =
|
|
351
|
+
const allowed =
|
|
352
|
+
isWildcardOriginsEnabled() || isLoopbackHostname(hostname) || getAllowedOrigins().includes(origin)
|
|
337
353
|
if (!allowed) return
|
|
338
354
|
res.setHeader('Access-Control-Allow-Origin', origin)
|
|
339
355
|
res.setHeader('Vary', 'Origin')
|
|
@@ -361,6 +377,11 @@ export function isSameOriginOrLocal(req) {
|
|
|
361
377
|
// 📖 No Origin/Referer → non-browser caller (curl, native app). Allow.
|
|
362
378
|
if (candidates.length === 0) return true
|
|
363
379
|
|
|
380
|
+
// 📖 FCM_ALLOWED_ORIGINS="*" is an explicit operator decision to trust any
|
|
381
|
+
// 📖 origin (LAN/Docker setups with changing hostnames). Host-header
|
|
382
|
+
// 📖 validation still applies to every request.
|
|
383
|
+
if (isWildcardOriginsEnabled()) return true
|
|
384
|
+
|
|
364
385
|
let hostHeader = typeof req.headers.host === 'string' ? req.headers.host.toLowerCase() : ''
|
|
365
386
|
for (const c of candidates) {
|
|
366
387
|
try {
|
|
@@ -3686,12 +3707,41 @@ export class RouterRuntime {
|
|
|
3686
3707
|
sendJson(res, 200, getWebModelsPayload(this), { 'x-request-id': requestId })
|
|
3687
3708
|
return
|
|
3688
3709
|
}
|
|
3689
|
-
// 📖
|
|
3690
|
-
//
|
|
3691
|
-
//
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3710
|
+
// 📖 Web dashboard hooks (useToolMode, useFavorites, useUpdateChecker).
|
|
3711
|
+
// These were 404 before - minimal shapes that match what the dashboard
|
|
3712
|
+
// hooks expect. See PR #108 for context.
|
|
3713
|
+
// 📖 /api/tool-mode must handle POST too: useUrlState re-persists
|
|
3714
|
+
// `?toolMode=` from the URL on load, and a POST falling through to the
|
|
3715
|
+
// OpenAI-style 404 catch-all showed "[object Object]" toasts (issue #189).
|
|
3716
|
+
if (url.pathname === '/api/tool-mode') {
|
|
3717
|
+
const webToolModes = getInstallTargetModes()
|
|
3718
|
+
const normalizeToolMode = (m) => (typeof m === 'string' && webToolModes.includes(m) ? m : 'opencode')
|
|
3719
|
+
if (req.method === 'GET') {
|
|
3720
|
+
sendJson(res, 200, { mode: normalizeToolMode(this.config?.settings?.preferredToolMode), tools: webToolModes }, { 'x-request-id': requestId })
|
|
3721
|
+
return
|
|
3722
|
+
}
|
|
3723
|
+
if (req.method === 'POST') {
|
|
3724
|
+
if (!isSameOriginOrLocal(req)) {
|
|
3725
|
+
sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
|
|
3726
|
+
return
|
|
3727
|
+
}
|
|
3728
|
+
const body = await readJsonBody(req)
|
|
3729
|
+
if (!body || typeof body.mode !== 'string' || !webToolModes.includes(body.mode)) {
|
|
3730
|
+
sendError(res, 422, 'Invalid tool mode', 'invalid_request_error', 'invalid_tool_mode', requestId)
|
|
3731
|
+
return
|
|
3732
|
+
}
|
|
3733
|
+
const mode = normalizeToolMode(body.mode)
|
|
3734
|
+
if (!this.config || typeof this.config !== 'object') this.config = {}
|
|
3735
|
+
if (!this.config.settings || typeof this.config.settings !== 'object') this.config.settings = {}
|
|
3736
|
+
this.config.settings.preferredToolMode = mode
|
|
3737
|
+
const saveResult = this.saveRouterConfig()
|
|
3738
|
+
if (!saveResult?.success) {
|
|
3739
|
+
sendError(res, 500, saveResult?.error || 'Failed to save tool mode', 'api_error', 'save_failed', requestId)
|
|
3740
|
+
return
|
|
3741
|
+
}
|
|
3742
|
+
sendJson(res, 200, { mode }, { 'x-request-id': requestId })
|
|
3743
|
+
return
|
|
3744
|
+
}
|
|
3695
3745
|
}
|
|
3696
3746
|
if (req.method === 'GET' && (url.pathname === '/api/favorites')) {
|
|
3697
3747
|
const cfg = this.config || {}
|
|
@@ -44,6 +44,7 @@ import chalk from 'chalk'
|
|
|
44
44
|
import { existsSync, readFileSync } from 'node:fs'
|
|
45
45
|
import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
|
|
46
46
|
import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, getRouterPidPath, getRouterPortPath, getRouterPortRange } from './router-daemon.js'
|
|
47
|
+
import { t } from './i18n/index.js'
|
|
47
48
|
import { themeColors, getTierRgb } from '../tui/theme.js'
|
|
48
49
|
import { formatTokenTotalCompact } from './token-usage-reader.js'
|
|
49
50
|
import { sendUsageTelemetry } from './telemetry.js'
|
|
@@ -871,12 +872,12 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
871
872
|
const { defaultPort: currentDefaultPort } = getRouterPortRange()
|
|
872
873
|
const port = snapshot.port || state.routerDashboardPort || currentDefaultPort
|
|
873
874
|
const baseUrl = `http://localhost:${port}/v1`
|
|
874
|
-
lines.push(` ${themeColors.textBold('
|
|
875
|
+
lines.push(` ${themeColors.textBold(t('router.quickSetup'))} ${themeColors.dim(`— ${t('router.pasteIntoToolConfig')}`)}`)
|
|
875
876
|
lines.push(` ${themeColors.dim('URL')} ${themeColors.infoBold(baseUrl)}`)
|
|
876
|
-
lines.push(` ${themeColors.dim('
|
|
877
|
+
lines.push(` ${themeColors.dim(t('router.model'))} ${themeColors.infoBold('fcm')}`)
|
|
877
878
|
lines.push(` ${themeColors.dim('API Key')} ${themeColors.infoBold('fcm-local')}`)
|
|
878
879
|
if (isRunning) {
|
|
879
|
-
lines.push(` ${themeColors.dim('
|
|
880
|
+
lines.push(` ${themeColors.dim(t('router.uptime'))} ${themeColors.success(formatRouterDuration(snapshot.uptimeSeconds))} ${themeColors.dim(`${t('router.requests')}:`)} ${themeColors.info(String(snapshot.requestsRouted))}`)
|
|
880
881
|
} else {
|
|
881
882
|
lines.push(` ${themeColors.dim('Hint')} ${themeColors.dim('Start the daemon to enable routing')}`)
|
|
882
883
|
}
|
|
@@ -886,7 +887,7 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
886
887
|
// ── Favorites / Router Fallback Models ──────────────────────────────────────
|
|
887
888
|
// 📖 Instead of the old "sets" system, show the user's favorites from the main
|
|
888
889
|
// 📖 table as the router fallback chain. #1 = tried first, #2 = next, etc.
|
|
889
|
-
lines.push(` ${themeColors.textBold('
|
|
890
|
+
lines.push(` ${themeColors.textBold(t('router.routerModels'))} ${themeColors.dim(`— ${t('router.favoritesFallbackOrder')}`)}`)
|
|
890
891
|
lines.push(` ${themeColors.dim('Star models with F in the main table. Shift+↑↓ to reorder here.')}`)
|
|
891
892
|
lines.push('')
|
|
892
893
|
|
|
@@ -895,7 +896,7 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
895
896
|
const cursor = state.routerDashboardCursorIndex ?? 0
|
|
896
897
|
|
|
897
898
|
if (favorites.length === 0) {
|
|
898
|
-
lines.push(` ${themeColors.warning('
|
|
899
|
+
lines.push(` ${themeColors.warning(t('router.noFavoritesTui'))}`)
|
|
899
900
|
lines.push(` ${themeColors.dim('Favorites become your router fallback chain — #1 is tried first.')}`)
|
|
900
901
|
} else {
|
|
901
902
|
// 📖 Priority keycap glyphs for the fallback order
|
|
@@ -1010,7 +1011,7 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1010
1011
|
lines.push('')
|
|
1011
1012
|
const btnCursor = favorites.length
|
|
1012
1013
|
const isBtnCursor = cursor === btnCursor
|
|
1013
|
-
const btnText = isStopped ?
|
|
1014
|
+
const btnText = isStopped ? `▶ ${t('router.start')}` : `⏹ ${t('router.stop')}`
|
|
1014
1015
|
const btnRowText = ` [ ${btnText} ]`
|
|
1015
1016
|
|
|
1016
1017
|
if (isBtnCursor) {
|
|
@@ -1022,7 +1023,7 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1022
1023
|
// 📖 Install Endpoint Button
|
|
1023
1024
|
const installBtnCursor = favorites.length + 1
|
|
1024
1025
|
const isInstallBtnCursor = cursor === installBtnCursor
|
|
1025
|
-
const installBtnText =
|
|
1026
|
+
const installBtnText = `🔌 ${t('router.installEndpointCli')}`
|
|
1026
1027
|
const installBtnRowText = ` [ ${installBtnText} ]`
|
|
1027
1028
|
|
|
1028
1029
|
if (isInstallBtnCursor) {
|
|
@@ -1036,11 +1037,11 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1036
1037
|
lines.push('')
|
|
1037
1038
|
|
|
1038
1039
|
// ── Token Summary (compact, visual) ─────────────────────────────────────────
|
|
1039
|
-
lines.push(` ${themeColors.textBold('📊
|
|
1040
|
+
lines.push(` ${themeColors.textBold('📊 Token')} ${themeColors.dim(`${t('analytics.today')}:`)} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim(t('analytics.requestsShort', { count: snapshot.tokens.today.requests }))} ${themeColors.dim(`${t('router.lifetime')}:`)} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} ${themeColors.dim(t('analytics.requestsShort', { count: snapshot.tokens.all_time.requests }))}`)
|
|
1040
1041
|
|
|
1041
1042
|
// ── Active Requests ────────────────────────────────────────────────────────
|
|
1042
1043
|
lines.push('')
|
|
1043
|
-
lines.push(` ${themeColors.warningBold(
|
|
1044
|
+
lines.push(` ${themeColors.warningBold(`⚡ ${t('router.activeRequests')}`)} ${themeColors.dim(`(${snapshot.activeRequests?.length || 0})`)}`)
|
|
1044
1045
|
if (snapshot.activeRequests?.length > 0) {
|
|
1045
1046
|
for (const req of snapshot.activeRequests) {
|
|
1046
1047
|
const model = compactText(req.model, 24)
|
|
@@ -1052,15 +1053,15 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1052
1053
|
lines.push(` ${themeColors.dim('•')} ${shortId}${model} → ${themeColors.info(current)} ${status}${tokens} ${themeColors.dim(duration)}`)
|
|
1053
1054
|
}
|
|
1054
1055
|
} else {
|
|
1055
|
-
lines.push(` ${themeColors.dim('
|
|
1056
|
+
lines.push(` ${themeColors.dim(t('router.noActiveRequests'))}`)
|
|
1056
1057
|
}
|
|
1057
1058
|
|
|
1058
1059
|
// ── Live Request Log (compact) ──────────────────────────────────────────────
|
|
1059
1060
|
const requestRows = requestLogRows(state, snapshot)
|
|
1060
1061
|
lines.push('')
|
|
1061
|
-
lines.push(` ${themeColors.textBold('
|
|
1062
|
+
lines.push(` ${themeColors.textBold(t('router.recentRequests'))}`)
|
|
1062
1063
|
if (requestRows.length > 0) {
|
|
1063
|
-
const header = ` ${padEndDisplay('ID', 5)} ${padEndDisplay('
|
|
1064
|
+
const header = ` ${padEndDisplay('ID', 5)} ${padEndDisplay(t('router.time'), 10)} ${padEndDisplay(t('router.model'), 34)} ${padEndDisplay(t('router.state'), 8)} ${padEndDisplay(t('router.lastLatency'), 9)} ${t('router.detail')}`
|
|
1064
1065
|
lines.push(themeColors.dim(header))
|
|
1065
1066
|
for (const row of requestRows.slice(0, 6)) {
|
|
1066
1067
|
const atMs = Date.parse(row.at)
|
|
@@ -1086,7 +1087,7 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1086
1087
|
)
|
|
1087
1088
|
}
|
|
1088
1089
|
} else {
|
|
1089
|
-
lines.push(` ${themeColors.dim('
|
|
1090
|
+
lines.push(` ${themeColors.dim(t('router.noRequests'))}`)
|
|
1090
1091
|
}
|
|
1091
1092
|
|
|
1092
1093
|
// ── Health check speed ──────────────────────────────────────────────────────
|
|
@@ -1111,7 +1112,7 @@ export function renderRouterDashboard(state, deps = {}) {
|
|
|
1111
1112
|
// ── Footer ──────────────────────────────────────────────────────────────────
|
|
1112
1113
|
lines.push('')
|
|
1113
1114
|
lines.push(` ${separator}`)
|
|
1114
|
-
lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('
|
|
1115
|
+
lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim(t('settings.navigate'))} ${themeColors.dim('•')} ${themeColors.hotkey('Shift+↑↓')} ${themeColors.dim(t('router.reorder'))} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? t('router.start') : t('router.stop'))} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`${t('router.healthCheck')}: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim(t('router.clearLog'))} ${themeColors.dim('•')} ${themeColors.hotkey('R')} ${themeColors.dim(t('router.syncBest'))} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim(t('common.back'))}`)
|
|
1115
1116
|
|
|
1116
1117
|
const { visible, offset } = sliceOverlayLines(lines, state.routerDashboardScrollOffset || 0, state.terminalRows || 24)
|
|
1117
1118
|
state.routerDashboardScrollOffset = offset
|