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.
@@ -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(['--model', modelRef], providerKey, fcmConfig)
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(['--model', modelRef], providerKey, fcmConfig, zaiProxyServer)
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(['--model', zenModelRef], providerKey, fcmConfig)
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(['--model', modelRef], providerKey, fcmConfig)
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
- await spawnOpenCode(['web', '--model', modelRef], providerKey, fcmConfig)
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', ['--model', modelRef])) ||
676
- (await tryExecFile('flatpak', ['run', 'ai.opencode.OpenCode', '--model', modelRef])) ||
677
- (await tryExecFile('snap', ['run', 'opencode', '--model', modelRef])) ||
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
- const code = resp.status >= 200 && resp.status < 300 ? '200' : String(resp.status)
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.
@@ -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 = 'Router is not running. Press R or run `free-coding-models --daemon-bg`.'
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 = 'Sending…'
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(' 💬 Playground — chat with the FCM router'))
407
- lines.push(themeColors.dim(' Press Enter to send · Shift+Tab cycles model · Ctrl+S toggles streaming · Esc closes · Ctrl+L clears'))
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.length > innerWidth - 16 ? `${pre.text.slice(0, innerWidth - 19)}…` : pre.text
414
- lines.push(themeColors.dim(` Persona: ${preview.replace(/\n+/g, ' ')}`))
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(' Persona: (none)'))
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 ? 'streaming' : 'one-shot'
421
- lines.push(themeColors.dim(` Model: ${PLAYGROUND_OVERLAY_STATE.model} · ${mode}`))
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(' ❯ you')
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(`routed ${msg.meta.provider}/${msg.meta.model || '?'}`)
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} tok`)
445
- if (msg.meta.fallbackAttempts) metaChips.push(`${msg.meta.fallbackAttempts} fallback`)
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(' [ stopped ]'))
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(' ⏳ waiting for response — Esc to stop'))
469
+ lines.push(themeColors.accent(` ⏳ ${t('playground.waitingResponse')}`))
469
470
  } else {
470
- const draft = PLAYGROUND_OVERLAY_STATE.draft || 'Type your message and press Enter…'
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(' (Enter to send · Shift+Tab for a pinned model)'))
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 Hacker tier · zero token markup · 3 API keys',
351
- detailedLimits: 'Zero-markup AI gateway: token prices are passed through at provider rates.\nFree Hacker plan: 3 API keys, adaptive routing + automatic failover + guardrails included.\nOnly the explicit $-0 models are listed in this catalog; the orcarouter/fusion family is pay-as-you-go.',
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 = isLoopbackHostname(hostname) || getAllowedOrigins().includes(origin)
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
- // 📖 Stub endpoints for the web dashboard's hooks (useToolMode, useFavorites,
3690
- // 📖 useUpdateChecker). These were 404 before - minimal shapes that match
3691
- // 📖 what the dashboard hooks expect. See PR #108 for context.
3692
- if (req.method === 'GET' && (url.pathname === '/api/tool-mode')) {
3693
- sendJson(res, 200, { mode: 'opencode', tools: ['opencode', 'openclaw', 'opencode-desktop', 'opencode-web'] }, { 'x-request-id': requestId })
3694
- return
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('Quick Setup')} ${themeColors.dim('— paste into your coding tool config')}`)
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('Model')} ${themeColors.infoBold('fcm')}`)
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('Uptime')} ${themeColors.success(formatRouterDuration(snapshot.uptimeSeconds))} ${themeColors.dim('Requests routed:')} ${themeColors.info(String(snapshot.requestsRouted))}`)
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('Router Models')} ${themeColors.dim('— your favorites, in fallback order')}`)
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('No favorites yet. Press Esc, then F on any model to add it.')}`)
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 ? '▶ Start Router Daemon' : '⏹ Stop Router Daemon'
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 = '🔌 Install Router Endpoint to CLI Tool'
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('📊 Tokens')} ${themeColors.dim('Today:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim(`(${snapshot.tokens.today.requests} req)`)} ${themeColors.dim('Lifetime:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} ${themeColors.dim(`(${snapshot.tokens.all_time.requests} req)`)}`)
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('⚡ Active Requests')} ${themeColors.dim(`(${snapshot.activeRequests?.length || 0})`)}`)
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('No active requests')}`)
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('Recent Requests')}`)
1062
+ lines.push(` ${themeColors.textBold(t('router.recentRequests'))}`)
1062
1063
  if (requestRows.length > 0) {
1063
- const header = ` ${padEndDisplay('ID', 5)} ${padEndDisplay('Time', 10)} ${padEndDisplay('Model', 34)} ${padEndDisplay('Status', 8)} ${padEndDisplay('Latency', 9)} Detail`
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('No requests routed yet')}`)
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('Navigate')} ${themeColors.dim('•')} ${themeColors.hotkey('Shift+↑↓')} ${themeColors.dim('Reorder')} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? 'Start daemon' : 'Stop daemon')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`Health check: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear log')} ${themeColors.dim('•')} ${themeColors.hotkey('R')} ${themeColors.dim('Sync best')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
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