free-coding-models 0.5.57 → 0.5.59

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.
@@ -1,23 +1,42 @@
1
1
  /**
2
2
  * @file lib/provider-quota-fetchers.js
3
- * @description Provider endpoint quota pollers for publicly available endpoints.
3
+ * @description Provider endpoint quota pollers + passive rate-limit header parser.
4
4
  *
5
- * Supported providers:
5
+ * Active fetchers (existing):
6
6
  * - openrouter: GET https://openrouter.ai/api/v1/key
7
7
  * derives percent from limit_remaining/limit (with fallback field names)
8
8
  * - siliconflow: GET https://api.siliconflow.cn/v1/user/info
9
9
  * returns balance info; percent is null (no limit field to derive from)
10
10
  *
11
+ * Passive tracker (t2):
12
+ * - Every chat-completion response carries rate-limit headers (x-ratelimit-*).
13
+ * - processResponseHeaders() parses those headers in 6 priority variants and
14
+ * writes to an in-memory map, kept fresh per `STALENESS_MS` (5 min default).
15
+ * - getQuota() merges the passive snapshot with the latest active fetch,
16
+ * returning whichever is freshest — so quota is *always* live when traffic
17
+ * flows, with the active fetcher as a safety net for idle periods.
18
+ * - Zero extra network requests: the headers are already on every response.
19
+ *
11
20
  * Features:
12
21
  * - TTL cache (default 60s) prevents hammering endpoints
13
22
  * - Error backoff (default 15s) after failures
14
23
  * - Injectable fetch + time for testing
15
24
  * - API keys are never logged
25
+ * - Case-insensitive header parsing (some proxies vary casing)
16
26
  *
17
27
  * @exports parseOpenRouterResponse(data) → number|null
18
28
  * @exports parseSiliconFlowResponse(data) → { balance, chargeBalance, totalBalance }|null
19
29
  * @exports createProviderQuotaFetcher(options) → fetcher(providerKey, apiKey) → Promise<number|null>
20
30
  * @exports fetchProviderQuota(providerKey, apiKey, options) → Promise<number|null>
31
+ * @exports extractQuota(headers) → { remaining, limit, percent, source, windowType }|null
32
+ * @exports processResponseHeaders(providerKey, headers, opts?) → boolean
33
+ * @exports getQuota(providerKey, opts?) → QuotaSnapshot|null
34
+ * @exports getAllQuotas(opts?) → ReadonlyMap<string, QuotaSnapshot>
35
+ * @exports formatQuotaStatus(providerKey, opts?) → string|undefined
36
+ * @exports resetPassiveQuota() — clear the in-memory passive map (tests)
37
+ * @exports HEADER_PAIRS — readonly array of [remainingKey, limitKey] pairs in priority order
38
+ * @exports STALENESS_MS — passive snapshots older than this are considered stale
39
+ * @exports QUOTA_WINDOW_LABELS — map of windowType → short label for tooltips
21
40
  */
22
41
 
23
42
  // ─── Response parsers (pure, no I/O) ─────────────────────────────────────────
@@ -317,3 +336,236 @@ export async function fetchProviderQuota(providerKey, apiKey, options = {}) {
317
336
 
318
337
  return pendingPromise
319
338
  }
339
+
340
+ // ─── Passive rate-limit header tracker (t2) ───────────────────────────────────
341
+
342
+ /**
343
+ * 📖 STALENESS_MS: how long a passive snapshot stays "fresh" before we prefer
344
+ * 📖 the active fetcher result (or hide the chip entirely if neither is fresh).
345
+ * 📖 Mirrors pi-free's 5-minute window. Override per call via opts.now - opts.maxAgeMs.
346
+ */
347
+ export const STALENESS_MS = 5 * 60 * 1000
348
+
349
+ /**
350
+ * 📖 HEADER_PAIRS: ordered list of [remainingKey, limitKey] pairs to try when
351
+ * 📖 parsing a response's rate-limit headers. First pair where both values parse
352
+ * 📖 as finite numbers AND limit > 0 wins. Order matters: most-specific (day,
353
+ * 📖 tokens) comes before generic (requests) where applicable.
354
+ *
355
+ * 📖 Provenance:
356
+ * 📖 - x-ratelimit-remaining-requests / x-ratelimit-limit-requests → SambaNova
357
+ * 📖 - x-ratelimit-remaining / x-ratelimit-limit → Mistral / generic
358
+ * 📖 - ratelimit-remaining-requests / ratelimit-limit-requests → proxies that strip 'x-' prefix
359
+ * 📖 - ratelimit-remaining / ratelimit-limit → same, generic
360
+ * 📖 - x-ratelimit-remaining-requests-day / x-ratelimit-limit-requests-day → SambaNova daily window
361
+ * 📖 - x-ratelimit-remaining-day / x-ratelimit-limit-day → generic daily
362
+ */
363
+ export const HEADER_PAIRS = [
364
+ ['x-ratelimit-remaining-requests', 'x-ratelimit-limit-requests'],
365
+ ['x-ratelimit-remaining', 'x-ratelimit-limit'],
366
+ ['ratelimit-remaining-requests', 'ratelimit-limit-requests'],
367
+ ['ratelimit-remaining', 'ratelimit-limit'],
368
+ ['x-ratelimit-remaining-requests-day', 'x-ratelimit-limit-requests-day'],
369
+ ['x-ratelimit-remaining-day', 'x-ratelimit-limit-day'],
370
+ ['x-ratelimit-remaining-tokens', 'x-ratelimit-limit-tokens'],
371
+ ['x-ratelimit-remaining-tokens-minute', 'x-ratelimit-limit-tokens-minute'],
372
+ ]
373
+
374
+ /**
375
+ * 📖 QUOTA_WINDOW_LABELS: short tooltip labels keyed by windowType suffix
376
+ * 📖 detected in the matched header pair name. Used by formatQuotaStatus to
377
+ * 📖 indicate whether the user is looking at a per-minute or per-day window.
378
+ */
379
+ export const QUOTA_WINDOW_LABELS = {
380
+ day: 'day',
381
+ requests: 'min',
382
+ tokens: 'tok',
383
+ 'tokens-minute': 'tok/min',
384
+ }
385
+
386
+ /**
387
+ * 📖 Internal: in-memory map of latest passive quota snapshot per provider.
388
+ * 📖 Keyed by providerKey; never persisted to disk (passive tracking is local-only).
389
+ */
390
+ const _passiveQuota = new Map() // providerKey -> QuotaSnapshot
391
+
392
+ /**
393
+ * 📖 Case-insensitive header lookup. Accepts both Fetch `Headers` objects and
394
+ * 📖 plain object literals (some test doubles pass plain objects).
395
+ */
396
+ function readHeader(headers, key) {
397
+ if (!headers) return null
398
+ if (typeof headers.get === 'function') {
399
+ return headers.get(key) ?? headers.get(key.toLowerCase()) ?? null
400
+ }
401
+ if (typeof headers === 'object') {
402
+ if (key in headers) return headers[key]
403
+ const lower = key.toLowerCase()
404
+ if (lower in headers) return headers[lower]
405
+ // 📖 Iterate as a last resort — some servers use unusual casings.
406
+ for (const k of Object.keys(headers)) {
407
+ if (k.toLowerCase() === lower) return headers[k]
408
+ }
409
+ }
410
+ return null
411
+ }
412
+
413
+ /**
414
+ * 📖 Parse rate-limit headers and extract a structured quota snapshot.
415
+ * 📖 Returns null when no header pair matches (caller decides to keep stale).
416
+ *
417
+ * @param {Headers | Record<string, string> | null | undefined} headers
418
+ * @returns {{ remaining: number, limit: number, percent: number, source: string, windowType: string } | null}
419
+ */
420
+ export function extractQuota(headers) {
421
+ for (const [remainingKey, limitKey] of HEADER_PAIRS) {
422
+ const remainingRaw = readHeader(headers, remainingKey)
423
+ const limitRaw = readHeader(headers, limitKey)
424
+ if (remainingRaw == null || limitRaw == null) continue
425
+ const remaining = Number.parseFloat(remainingRaw)
426
+ const limit = Number.parseFloat(limitRaw)
427
+ if (!Number.isFinite(remaining) || !Number.isFinite(limit) || limit <= 0) continue
428
+ const percent = Math.max(0, Math.min(100, Math.round((remaining / limit) * 100)))
429
+ // 📖 Derive windowType from the matching pair's key suffix.
430
+ let windowType = 'requests'
431
+ if (remainingKey.endsWith('-day')) windowType = 'day'
432
+ else if (remainingKey.endsWith('-tokens-minute')) windowType = 'tokens-minute'
433
+ else if (remainingKey.endsWith('-tokens')) windowType = 'tokens'
434
+ return { remaining, limit, percent, source: remainingKey, windowType }
435
+ }
436
+ return null
437
+ }
438
+
439
+ /**
440
+ * 📖 Internal: build a QuotaSnapshot from extractQuota() output + timestamp.
441
+ */
442
+ function makeSnapshot(extracted, source = 'header', now = Date.now()) {
443
+ return {
444
+ remaining: extracted.remaining,
445
+ limit: extracted.limit,
446
+ percent: extracted.percent,
447
+ windowType: extracted.windowType,
448
+ headerSource: extracted.source,
449
+ source, // 'header' (passive) or 'endpoint' (active fetcher)
450
+ lastUpdated: now,
451
+ }
452
+ }
453
+
454
+ /**
455
+ * 📖 processResponseHeaders: hook for the daemon reverse-proxy + ping responses.
456
+ * 📖 Parses the response headers, writes the snapshot to the passive map, and
457
+ * 📖 returns true if a snapshot was stored (so callers can decide to log).
458
+ *
459
+ * @param {string} providerKey
460
+ * @param {Headers | Record<string, string> | null | undefined} headers
461
+ * @param {object} [opts]
462
+ * @param {number} [opts.now=Date.now()]
463
+ * @returns {boolean} true if a snapshot was written
464
+ */
465
+ export function processResponseHeaders(providerKey, headers, opts = {}) {
466
+ if (!providerKey || typeof providerKey !== 'string') return false
467
+ const now = opts.now ?? Date.now()
468
+ const extracted = extractQuota(headers)
469
+ if (!extracted) return false
470
+ _passiveQuota.set(providerKey, makeSnapshot(extracted, 'header', now))
471
+ return true
472
+ }
473
+
474
+ /**
475
+ * 📖 Internal: read the latest active-fetcher snapshot for a provider. The active
476
+ * 📖 fetcher uses a per-key Map of { value, expiresAt } entries; we synthesise a
477
+ * 📖 QuotaSnapshot from that. Returns null if the active cache is empty/expired.
478
+ */
479
+ function getActiveSnapshot(providerKey, now = Date.now()) {
480
+ for (const [cacheKey, entry] of _defaultCache.entries()) {
481
+ if (!cacheKey.startsWith(`${providerKey}:`)) continue
482
+ if (!entry || typeof entry !== 'object') continue
483
+ if (typeof entry.expiresAt !== 'number' || entry.expiresAt <= now) continue
484
+ const value = entry.value
485
+ if (typeof value !== 'number' || !Number.isFinite(value)) continue
486
+ return makeSnapshot(
487
+ { remaining: value, limit: 100, percent: value, windowType: 'unknown', source: 'active_fetcher' },
488
+ 'endpoint',
489
+ // 📖 active fetcher stores wall-clock ms at fetch time; expose it so
490
+ // 📖 getQuota's freshest-wins logic works on real timestamps.
491
+ now,
492
+ )
493
+ }
494
+ return null
495
+ }
496
+
497
+ /**
498
+ * 📖 getQuota: merge passive + active snapshots, return the freshest.
499
+ * 📖 A snapshot is "stale" when older than STALENESS_MS. If both are stale,
500
+ * 📖 returns null (caller should hide the chip).
501
+ *
502
+ * @param {string} providerKey
503
+ * @param {object} [opts]
504
+ * @param {number} [opts.now=Date.now()]
505
+ * @param {number} [opts.maxAgeMs=STALENESS_MS]
506
+ * @returns {QuotaSnapshot | null}
507
+ */
508
+ export function getQuota(providerKey, opts = {}) {
509
+ if (!providerKey) return null
510
+ const now = opts.now ?? Date.now()
511
+ const maxAgeMs = opts.maxAgeMs ?? STALENESS_MS
512
+ const passive = _passiveQuota.get(providerKey) || null
513
+ const active = getActiveSnapshot(providerKey, now)
514
+
515
+ // 📖 Drop stale snapshots.
516
+ const candidates = []
517
+ if (passive && now - passive.lastUpdated <= maxAgeMs) candidates.push(passive)
518
+ if (active && now - active.lastUpdated <= maxAgeMs) candidates.push(active)
519
+
520
+ if (candidates.length === 0) return null
521
+ // 📖 Freshest wins — tied timestamps prefer passive (it's the live signal).
522
+ return candidates.reduce((a, b) => (a.lastUpdated >= b.lastUpdated ? a : b))
523
+ }
524
+
525
+ /**
526
+ * 📖 getAllQuotas: snapshot of every provider we know about, merged passive+active.
527
+ * 📖 Stale entries (older than maxAgeMs) are excluded. Used by /stats and the TUI footer.
528
+ *
529
+ * @param {object} [opts]
530
+ * @returns {ReadonlyMap<string, QuotaSnapshot>}
531
+ */
532
+ export function getAllQuotas(opts = {}) {
533
+ const now = opts.now ?? Date.now()
534
+ const maxAgeMs = opts.maxAgeMs ?? STALENESS_MS
535
+ const out = new Map()
536
+ // 📖 Union the keys from both passive and active stores so we don't miss a
537
+ // 📖 provider whose latest signal only exists in one.
538
+ const allKeys = new Set([..._passiveQuota.keys()])
539
+ for (const cacheKey of _defaultCache.keys()) {
540
+ const colon = cacheKey.indexOf(':')
541
+ if (colon > 0) allKeys.add(cacheKey.slice(0, colon))
542
+ }
543
+ for (const providerKey of allKeys) {
544
+ const q = getQuota(providerKey, { now, maxAgeMs })
545
+ if (q) out.set(providerKey, q)
546
+ }
547
+ return out
548
+ }
549
+
550
+ /**
551
+ * 📖 formatQuotaStatus: human-readable "⚠️ groq: 12/100 (12%) [day]" string.
552
+ * 📖 Returns undefined when the snapshot is missing or stale (caller hides the chip).
553
+ *
554
+ * @param {string} providerKey
555
+ * @param {object} [opts]
556
+ * @returns {string | undefined}
557
+ */
558
+ export function formatQuotaStatus(providerKey, opts = {}) {
559
+ const snapshot = getQuota(providerKey, opts)
560
+ if (!snapshot) return undefined
561
+ const window = QUOTA_WINDOW_LABELS[snapshot.windowType] || snapshot.windowType
562
+ const icon = snapshot.percent <= 10 ? '🚨' : snapshot.percent <= 25 ? '⚠️ ' : '📊'
563
+ return `${icon} ${providerKey}: ${snapshot.remaining}/${snapshot.limit} (${snapshot.percent}%) [${window}]`
564
+ }
565
+
566
+ /**
567
+ * 📖 resetPassiveQuota: clear the in-memory passive map. Test-only utility.
568
+ */
569
+ export function resetPassiveQuota() {
570
+ _passiveQuota.clear()
571
+ }
@@ -54,6 +54,20 @@ import { sendUsageTelemetry } from './telemetry.js'
54
54
  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
+ import {
58
+ loadCache as loadProbeCache,
59
+ flushCache as flushProbeCache,
60
+ recordProbeResults as recordProbeCacheResults,
61
+ getCacheStats as getProbeCacheStats,
62
+ getModelsDueForProbe,
63
+ isCacheFresh as isProbeCacheFresh,
64
+ pruneStaleEntries as pruneProbeCacheStaleEntries,
65
+ } from './probe-cache.js'
66
+ import {
67
+ processResponseHeaders as processPassiveQuotaHeaders,
68
+ getAllQuotas as getAllPassiveQuotas,
69
+ STALENESS_MS as PASSIVE_QUOTA_STALENESS_MS,
70
+ } from './provider-quota-fetchers.js'
57
71
 
58
72
  export const ROUTER_DEFAULT_PORT = 19280
59
73
  export const ROUTER_MAX_PORT = 19289
@@ -466,11 +480,16 @@ function serveWebStaticFile(res, pathname, requestId) {
466
480
  serveStaticFromDist(res, candidate)
467
481
  }
468
482
 
469
- function buildUpstreamMeta(response, text = '') {
483
+ function buildUpstreamMeta(response, text = '', providerKey = '') {
470
484
  // 📖 Keep quota diagnostics structural only: headers and retry timing are safe,
471
485
  // 📖 while upstream response bodies stay out of logs and telemetry.
472
486
  const rateLimitHeaders = extractRateLimitHeaders(response.headers)
473
487
  const retryAfterMs = parseRetryAfterMs(rateLimitHeaders['retry-after'])
488
+ // 📖 Passive quota tracker (t2): every upstream response carries rate-limit
489
+ // 📖 headers — we parse them once here and write to the in-memory snapshot
490
+ // 📖 map. Zero extra network requests; works on providers with no quota
491
+ // 📖 endpoint. See src/core/provider-quota-fetchers.js for the 8 header pairs.
492
+ if (providerKey) processPassiveQuotaHeaders(providerKey, response.headers)
474
493
  const quotaExhausted = response.status === 429
475
494
  || hasZeroRemainingQuota(rateLimitHeaders)
476
495
  || /\b(quota|rate[_ -]?limit|too many requests)\b/i.test(text || '')
@@ -870,6 +889,12 @@ class RouterRuntime {
870
889
  this.webGlobalBenchmarkRunning = false
871
890
  this.webGlobalBenchmarkTotal = 0
872
891
  this.webGlobalBenchmarkCompleted = 0
892
+ // 📖 Probe-cache (t1): load the persistent probe-cache on boot so the daemon
893
+ // 📖 can skip fresh healthy models during its health-probe loop and write
894
+ // 📖 results back to the same file the CLI TUI uses. See src/core/probe-cache.js.
895
+ this.probeCache = loadProbeCache()
896
+ this.probeCacheDirty = false
897
+ this.probeCacheFlushTimer = null
873
898
  this.refreshRouteState()
874
899
  }
875
900
 
@@ -1023,6 +1048,39 @@ class RouterRuntime {
1023
1048
  latency_ms: result.latencyMs ?? null,
1024
1049
  circuit_state: this.circuit.get(key)?.state || 'UNKNOWN',
1025
1050
  })
1051
+
1052
+ // 📖 Probe-cache (t1): mirror the result into the persistent cross-session
1053
+ // 📖 cache so the CLI TUI can skip fresh healthy models and auto-hide broken
1054
+ // 📖 ones. key is `provider/modelId` — split on the first slash.
1055
+ const slashIdx = key.indexOf('/')
1056
+ if (slashIdx > 0) {
1057
+ const providerKey = key.slice(0, slashIdx)
1058
+ const modelId = key.slice(slashIdx + 1)
1059
+ recordProbeCacheResults(providerKey, [{
1060
+ modelId,
1061
+ status: result.ok ? 'ok' : 'broken',
1062
+ latencyMs: result.latencyMs ?? null,
1063
+ lastError: result.ok ? null : (result.code != null ? String(result.code) : 'error'),
1064
+ }])
1065
+ this.probeCacheDirty = true
1066
+ this.scheduleProbeCacheFlush()
1067
+ }
1068
+ }
1069
+
1070
+ /**
1071
+ * 📖 scheduleProbeCacheFlush — debounced write to disk so we don't thrash the
1072
+ * 📖 filesystem when a probe burst records dozens of results at once.
1073
+ */
1074
+ scheduleProbeCacheFlush() {
1075
+ if (this.probeCacheFlushTimer) return
1076
+ this.probeCacheFlushTimer = setTimeout(() => {
1077
+ this.probeCacheFlushTimer = null
1078
+ if (this.probeCacheDirty) {
1079
+ flushProbeCache()
1080
+ this.probeCacheDirty = false
1081
+ }
1082
+ }, 2000)
1083
+ if (typeof this.probeCacheFlushTimer.unref === 'function') this.probeCacheFlushTimer.unref()
1026
1084
  }
1027
1085
 
1028
1086
  markAuthError(key, detail = 'authentication failed') {
@@ -1435,6 +1493,16 @@ class RouterRuntime {
1435
1493
  configPath: CONFIG_PATH,
1436
1494
  tokenStatsPath: ROUTER_TOKENS_PATH,
1437
1495
  logPath: ROUTER_LOG_PATH,
1496
+ // 📖 Probe-cache (t1): live aggregates from the persistent probe-cache.
1497
+ // 📖 Surfaced so the Web Dashboard + CLI can show cache hit rate + how many
1498
+ // 📖 broken models are currently hidden. Refreshed every /stats call.
1499
+ probeCache: getProbeCacheStats(),
1500
+ // 📖 Passive quota (t2): latest known rate-limit headers per provider, keyed
1501
+ // 📖 by providerKey. Each entry is { remaining, limit, percent, source,
1502
+ // 📖 lastUpdated } — source can be 'header' (live) or 'endpoint' (active
1503
+ // 📖 fetcher fallback). Stale entries (older than PASSIVE_QUOTA_STALENESS_MS)
1504
+ // 📖 are excluded so the consumer only sees fresh data.
1505
+ quota: Object.fromEntries(getAllPassiveQuotas()),
1438
1506
  }
1439
1507
  }
1440
1508
 
@@ -1529,7 +1597,14 @@ class RouterRuntime {
1529
1597
  if (!set) return
1530
1598
  const candidates = this.scoreCandidates(set)
1531
1599
  .filter((candidate) => candidate.catalog?.routeable && !candidate.circuit?.stale)
1532
- await Promise.allSettled(candidates.map((candidate) => this.probeCandidate(candidate, {
1600
+ // 📖 Probe-cache (t1): skip models that are still fresh + ok in the persistent
1601
+ // 📖 cache. Broken models naturally pass through (isProbeCacheFresh returns false
1602
+ // 📖 for them), so recovery detection keeps working unchanged.
1603
+ const filtered = candidates.filter((c) => {
1604
+ if (!c.catalog) return true
1605
+ return !isProbeCacheFresh(c.catalog.providerKey, c.catalog.modelId)
1606
+ })
1607
+ await Promise.allSettled(filtered.map((candidate) => this.probeCandidate(candidate, {
1533
1608
  eco: this.routerConfig().probeMode === 'eco',
1534
1609
  })))
1535
1610
  }
@@ -1992,7 +2067,7 @@ class RouterRuntime {
1992
2067
  clearTimeout(timeout)
1993
2068
  const latencyMs = Math.round(performance.now() - started)
1994
2069
  const text = await response.text()
1995
- const upstreamMeta = buildUpstreamMeta(response, text)
2070
+ const upstreamMeta = buildUpstreamMeta(response, text, candidate.provider)
1996
2071
 
1997
2072
  if (isLikelyHtmlResponse(response.headers, text)) {
1998
2073
  this.markFailure(key, 'upstream_html_maintenance', 503, upstreamMeta)
@@ -2134,7 +2209,7 @@ class RouterRuntime {
2134
2209
  })
2135
2210
  clearTimeout(timeout)
2136
2211
  const latencyMs = Math.round(performance.now() - started)
2137
- const upstreamMeta = buildUpstreamMeta(response)
2212
+ const upstreamMeta = buildUpstreamMeta(response, '', candidate.provider)
2138
2213
  if (isLikelyHtmlResponse(response.headers)) {
2139
2214
  this.markFailure(key, 'upstream_html_maintenance', 503, upstreamMeta)
2140
2215
  this.recordRouterError('upstream_html_maintenance', requestId, { model: key, status: response.status, stream: true })
@@ -3101,12 +3176,14 @@ class RouterRuntime {
3101
3176
  if (this.probeTimer) clearInterval(this.probeTimer)
3102
3177
  if (this.configReloadTimer) clearInterval(this.configReloadTimer)
3103
3178
  if (this.tokenFlushTimer) clearInterval(this.tokenFlushTimer)
3179
+ if (this.probeCacheFlushTimer) clearInterval(this.probeCacheFlushTimer)
3104
3180
  for (const timeout of this.probeTimeouts) clearTimeout(timeout)
3105
3181
  const started = Date.now()
3106
3182
  while (this.inFlight > 0 && Date.now() - started < 30000) {
3107
3183
  await sleep(100)
3108
3184
  }
3109
3185
  this.tokenTracker.flush({ force: true })
3186
+ flushProbeCache() // 📖 t1: persist any pending probe-cache deltas before exit
3110
3187
  try { this.server?.close() } catch {}
3111
3188
  try { unlinkSync(ROUTER_PID_PATH) } catch {}
3112
3189
  try { unlinkSync(ROUTER_PORT_PATH) } catch {}
package/src/core/utils.js CHANGED
@@ -452,11 +452,16 @@ export function findBestModel(results) {
452
452
  // --daemon-status, --no-telemetry, --json, --help/-h (case-insensitive)
453
453
  // --playground / playground subcommand (open the in-TUI chat playground)
454
454
  // - Value flag: --tier <letter> (the next non-flag arg is the tier value)
455
+ // - Probe-cache flags (t1):
456
+ // --reprobe / --no-cache (boolean) — force-rebuild the probe cache this run
457
+ // --probe-ttl <ms> (value) — override the 24h default TTL
458
+ // --show-broken (boolean) — don't auto-hide broken models (one-shot)
455
459
  //
456
460
  // Returns:
457
461
  // { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openCodeWebMode, openClawMode,
458
462
  // aiderMode, crushMode, gooseMode, qwenMode, openHandsMode, ampMode,
459
- // piMode, jcodeMode, copilotMode, forgecodeMode, zcodeMode, noTelemetry, jsonMode, helpMode, tierFilter }
463
+ // piMode, jcodeMode, copilotMode, forgecodeMode, zcodeMode, noTelemetry, jsonMode, helpMode, tierFilter,
464
+ // reprobeMode, probeTtlMs, showBrokenMode }
460
465
  //
461
466
  // 📖 Note: apiKey may be null here — the main CLI falls back to env vars and saved config.
462
467
  export function parseArgs(argv) {
@@ -486,6 +491,12 @@ export function parseArgs(argv) {
486
491
  ? pingIntervalIdx + 1
487
492
  : -1
488
493
 
494
+ // 📖 --probe-ttl <ms> — override the 24h probe-cache TTL (power users / debugging)
495
+ const probeTtlIdx = args.findIndex(a => a.toLowerCase() === '--probe-ttl')
496
+ const probeTtlValueIdx = (probeTtlIdx !== -1 && args[probeTtlIdx + 1] && !args[probeTtlIdx + 1].startsWith('--'))
497
+ ? probeTtlIdx + 1
498
+ : -1
499
+
489
500
  // 📖 --sync-set [name] — auto-discover and live-probe models into a named router set
490
501
  const syncSetIdx = args.findIndex(a => a.toLowerCase() === '--sync-set')
491
502
  const syncSetValueIdx = (syncSetIdx !== -1 && args[syncSetIdx + 1] && !args[syncSetIdx + 1].startsWith('--'))
@@ -499,6 +510,7 @@ export function parseArgs(argv) {
499
510
  if (originValueIdx !== -1) skipIndices.add(originValueIdx)
500
511
  if (pingIntervalValueIdx !== -1) skipIndices.add(pingIntervalValueIdx)
501
512
  if (syncSetValueIdx !== -1) skipIndices.add(syncSetValueIdx)
513
+ if (probeTtlValueIdx !== -1) skipIndices.add(probeTtlValueIdx)
502
514
 
503
515
  for (const [i, arg] of args.entries()) {
504
516
  if (arg.startsWith('--') || arg === '-h') {
@@ -574,6 +586,13 @@ export function parseArgs(argv) {
574
586
  // 📖 --recommend — launch directly into Smart Recommend mode (Q key equivalent)
575
587
  const recommendMode = flags.includes('--recommend')
576
588
 
589
+ // 📖 Probe-cache flags (t1): --reprobe / --no-cache force a fresh probe pass;
590
+ // 📖 --probe-ttl overrides the 24h default; --show-broken un-hides broken models for this run.
591
+ const reprobeMode = flags.includes('--reprobe') || flags.includes('--no-cache')
592
+ const showBrokenMode = flags.includes('--show-broken')
593
+ const probeTtlRaw = probeTtlValueIdx !== -1 ? args[probeTtlValueIdx] : null
594
+ const probeTtlMs = probeTtlRaw !== null ? parseInt(probeTtlRaw, 10) : null
595
+
577
596
  return {
578
597
  apiKey,
579
598
  bestMode,
@@ -621,6 +640,10 @@ export function parseArgs(argv) {
621
640
  devMode,
622
641
  syncSetMode,
623
642
  syncSetName,
643
+ // 📖 Probe-cache flags (t1) — see src/core/probe-cache.js
644
+ reprobeMode,
645
+ probeTtlMs: Number.isFinite(probeTtlMs) && probeTtlMs > 0 ? probeTtlMs : null,
646
+ showBrokenMode,
624
647
  }
625
648
  }
626
649