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.
- package/README.md +86 -0
- package/changelog/v0.5.58.md +42 -0
- package/changelog/v0.5.59.md +52 -0
- package/package.json +4 -4
- package/src/core/ping.js +7 -2
- package/src/core/probe-cache.js +515 -0
- package/src/core/provider-quota-fetchers.js +254 -2
- package/src/core/router-daemon.js +81 -4
- package/src/core/utils.js +24 -1
- package/src/tui/app.js +132 -4
- package/src/tui/cli-help.js +3 -0
- package/src/tui/key-handler.js +19 -0
- package/src/tui/render-table.js +50 -2
- package/src/tui/tui-state.js +11 -0
- package/web/dist/assets/{index-BoJ4r2gC.js → index-C_ZdUGrS.js} +2 -2
- package/web/dist/assets/{index-BkK1gdJN.css → index-wI9xrm0w.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/components/router/RouterView.jsx +34 -0
- package/web/src/components/router/RouterView.module.css +53 -0
|
@@ -1,23 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file lib/provider-quota-fetchers.js
|
|
3
|
-
* @description Provider endpoint quota pollers
|
|
3
|
+
* @description Provider endpoint quota pollers + passive rate-limit header parser.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
|