free-coding-models 0.5.87 → 0.5.89
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 +7 -9
- package/bin/free-coding-models.js +33 -2
- package/changelog/v0.5.88.md +16 -0
- package/changelog/v0.5.89.md +20 -0
- package/package.json +7 -2
- package/src/core/router-daemon.js +1129 -500
- package/src/core/router-v2/anthropic-compat.js +473 -0
- package/src/core/router-v2/bench.js +171 -0
- package/src/core/router-v2/breaker-store.js +265 -0
- package/src/core/router-v2/constants.js +108 -0
- package/src/core/router-v2/decision-trace.js +134 -0
- package/src/core/router-v2/failure-classifier.js +231 -0
- package/src/core/router-v2/request-history.js +137 -0
- package/src/core/router-v2/response-gate.js +175 -0
- package/src/core/router-v2/tui-dashboard.js +632 -0
- package/src/core/schema-normalizer.js +23 -6
- package/src/core/security.js +170 -24
- package/src/core/utils.js +109 -0
- package/src/tui/app.js +7 -2
- package/src/tui/cli-help.js +4 -0
- package/src/tui/key-handler.js +117 -2
- package/src/tui/overlays.js +10 -0
- package/src/tui/tui-state.js +22 -0
- package/web/dist/assets/index-C5hgQLYN.js +48 -0
- package/web/dist/assets/index-CCaxIOti.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +82 -0
- package/web/dist/assets/index-BpTpPS0g.js +0 -44
- package/web/dist/assets/index-CAzFIt8P.css +0 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file breaker-store.js
|
|
3
|
+
* @description Persisted circuit breakers with a DEGRADED warning state for Router v2.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 v1 kept circuit breakers in memory only: every daemon restart wiped
|
|
7
|
+
* them, and because the probe burst skips models that are still "fresh" in
|
|
8
|
+
* the shared probe cache, a model that went bad right before a restart
|
|
9
|
+
* happily received live traffic again until it failed three more times.
|
|
10
|
+
*
|
|
11
|
+
* 📖 v2 persists breaker state to disk (atomic write, debounced flush) and
|
|
12
|
+
* restores it on boot, so cooldowns survive restarts. It also adds two
|
|
13
|
+
* refinements:
|
|
14
|
+
* - DEGRADED: a warning state at 60% of the failure threshold. A degraded
|
|
15
|
+
* model is still routed (ranked below CLOSED models) and the dashboards
|
|
16
|
+
* show it amber, so users see trouble BEFORE the breaker trips.
|
|
17
|
+
* - Escalating backoff: each model remembers its `tripCount`; the cooldown
|
|
18
|
+
* multiplies per trip (capped at 16x the initial cooldown), so a flapping
|
|
19
|
+
* model does not re-enter rotation every 30 seconds.
|
|
20
|
+
*
|
|
21
|
+
* @functions
|
|
22
|
+
* → new BreakerStore({ path, logger }) - Create + load persisted breakers
|
|
23
|
+
* → store.ensure(key, initialCooldownMs) - Get or create a breaker entry
|
|
24
|
+
* → store.markFailure(key, params) - Apply a failure; may trip the breaker
|
|
25
|
+
* → store.markSuccess(key, initialCooldownMs) - Fully reset a breaker
|
|
26
|
+
* → store.evaluate(key) - Lazily promote OPEN → HALF_OPEN after cooldown
|
|
27
|
+
* → store.snapshot() / store.scheduleFlush() / store.flush()
|
|
28
|
+
*
|
|
29
|
+
* @exports BreakerStore
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
33
|
+
import { atomicWriteJson, safeJsonParse } from '../shared-helpers.js'
|
|
34
|
+
|
|
35
|
+
const STATE_VERSION = 1
|
|
36
|
+
const FLUSH_DEBOUNCE_MS = 2000
|
|
37
|
+
// 📖 Entries untouched for 30 days are dropped so the file cannot grow forever.
|
|
38
|
+
const ENTRY_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
39
|
+
// 📖 cooldown escalation cap: initial * 2^4 = 16x.
|
|
40
|
+
const MAX_ESCALATION_STEPS = 4
|
|
41
|
+
|
|
42
|
+
function defaultBreaker(initialCooldownMs) {
|
|
43
|
+
return {
|
|
44
|
+
state: 'CLOSED',
|
|
45
|
+
consecutiveFailures: 0,
|
|
46
|
+
tripCount: 0,
|
|
47
|
+
cooldownMs: initialCooldownMs,
|
|
48
|
+
openedAt: null,
|
|
49
|
+
lastError: null,
|
|
50
|
+
authError: false,
|
|
51
|
+
updatedAt: Date.now(),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sanitizeEntry(raw, now) {
|
|
56
|
+
if (!raw || typeof raw !== 'object') return null
|
|
57
|
+
const state = ['CLOSED', 'DEGRADED', 'OPEN', 'HALF_OPEN'].includes(raw.state) ? raw.state : 'CLOSED'
|
|
58
|
+
const updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : now
|
|
59
|
+
if (now - updatedAt > ENTRY_TTL_MS) return null
|
|
60
|
+
return {
|
|
61
|
+
state,
|
|
62
|
+
consecutiveFailures: Number.isFinite(raw.consecutiveFailures) ? Math.max(0, raw.consecutiveFailures) : 0,
|
|
63
|
+
tripCount: Number.isFinite(raw.tripCount) ? Math.max(0, raw.tripCount) : 0,
|
|
64
|
+
cooldownMs: Number.isFinite(raw.cooldownMs) && raw.cooldownMs > 0 ? raw.cooldownMs : 30_000,
|
|
65
|
+
openedAt: Number.isFinite(raw.openedAt) ? raw.openedAt : null,
|
|
66
|
+
lastError: typeof raw.lastError === 'string' ? raw.lastError.slice(0, 300) : null,
|
|
67
|
+
authError: raw.authError === true,
|
|
68
|
+
updatedAt,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class BreakerStore {
|
|
73
|
+
/**
|
|
74
|
+
* @param {{ path: string, logger: object }} params
|
|
75
|
+
* `path` is the JSON state file; pass a per-test temp path to isolate.
|
|
76
|
+
*/
|
|
77
|
+
constructor({ path, logger }) {
|
|
78
|
+
this.path = path
|
|
79
|
+
this.logger = logger
|
|
80
|
+
this.breakers = new Map()
|
|
81
|
+
this.dirty = false
|
|
82
|
+
this.flushTimer = null
|
|
83
|
+
this.load()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
load() {
|
|
87
|
+
try {
|
|
88
|
+
if (!existsSync(this.path)) return
|
|
89
|
+
const parsed = safeJsonParse(readFileSync(this.path, 'utf8'), null)
|
|
90
|
+
if (!parsed || typeof parsed !== 'object') return
|
|
91
|
+
const now = Date.now()
|
|
92
|
+
const entries = parsed.breakers && typeof parsed.breakers === 'object' ? parsed.breakers : {}
|
|
93
|
+
for (const [key, raw] of Object.entries(entries)) {
|
|
94
|
+
const entry = sanitizeEntry(raw, now)
|
|
95
|
+
if (entry) this.breakers.set(key, entry)
|
|
96
|
+
}
|
|
97
|
+
this.logger?.debug?.(`Restored ${this.breakers.size} persisted breaker(s)`)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
this.logger?.warn?.('Breaker state load failed; starting fresh', { error: error?.message })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
ensure(key, initialCooldownMs = 30_000) {
|
|
104
|
+
let entry = this.breakers.get(key)
|
|
105
|
+
if (!entry) {
|
|
106
|
+
entry = defaultBreaker(initialCooldownMs)
|
|
107
|
+
this.breakers.set(key, entry)
|
|
108
|
+
}
|
|
109
|
+
return entry
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 📖 Lazily promote an OPEN breaker to HALF_OPEN once its cooldown elapsed.
|
|
114
|
+
* Called right before candidate scoring so no timer is needed.
|
|
115
|
+
*/
|
|
116
|
+
evaluate(key) {
|
|
117
|
+
const entry = this.breakers.get(key)
|
|
118
|
+
if (!entry || entry.state !== 'OPEN') return entry
|
|
119
|
+
const elapsed = Date.now() - (entry.openedAt || 0)
|
|
120
|
+
if (elapsed >= entry.cooldownMs) {
|
|
121
|
+
entry.state = 'HALF_OPEN'
|
|
122
|
+
entry.updatedAt = Date.now()
|
|
123
|
+
this.dirty = true
|
|
124
|
+
this.scheduleFlush()
|
|
125
|
+
}
|
|
126
|
+
return entry
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 📖 Apply a failure to a model breaker.
|
|
131
|
+
* @returns {{ state: string, opened: boolean, degraded: boolean }}
|
|
132
|
+
*/
|
|
133
|
+
markFailure(key, {
|
|
134
|
+
detail = 'unknown',
|
|
135
|
+
statusCode = null,
|
|
136
|
+
failureThreshold = 3,
|
|
137
|
+
initialCooldownMs = 30_000,
|
|
138
|
+
maxCooldownMs = 300_000,
|
|
139
|
+
backoffMultiplier = 2,
|
|
140
|
+
authError = false,
|
|
141
|
+
} = {}) {
|
|
142
|
+
const entry = this.ensure(key, initialCooldownMs)
|
|
143
|
+
const before = entry.state
|
|
144
|
+
if (authError) {
|
|
145
|
+
// 📖 Auth problems ride along on the breaker as a sticky flag but never
|
|
146
|
+
// trip it: a dead key is a config issue, not an unhealthy model.
|
|
147
|
+
entry.authError = true
|
|
148
|
+
entry.lastError = detail
|
|
149
|
+
entry.updatedAt = Date.now()
|
|
150
|
+
this.dirty = true
|
|
151
|
+
this.scheduleFlush()
|
|
152
|
+
return { state: entry.state, opened: false, degraded: false }
|
|
153
|
+
}
|
|
154
|
+
entry.authError = false
|
|
155
|
+
entry.consecutiveFailures += 1
|
|
156
|
+
entry.lastError = detail
|
|
157
|
+
entry.updatedAt = Date.now()
|
|
158
|
+
|
|
159
|
+
const degradedThreshold = Math.max(1, Math.ceil(failureThreshold * 0.6))
|
|
160
|
+
let opened = false
|
|
161
|
+
let degraded = false
|
|
162
|
+
if (entry.state === 'HALF_OPEN' || entry.consecutiveFailures >= failureThreshold) {
|
|
163
|
+
entry.state = 'OPEN'
|
|
164
|
+
entry.openedAt = Date.now()
|
|
165
|
+
entry.tripCount += 1
|
|
166
|
+
// 📖 Escalating backoff: each successive trip multiplies the cooldown,
|
|
167
|
+
// capped so a flapping model can never disappear for hours.
|
|
168
|
+
const escalation = Math.min(entry.tripCount - 1, MAX_ESCALATION_STEPS)
|
|
169
|
+
entry.cooldownMs = Math.min(
|
|
170
|
+
maxCooldownMs,
|
|
171
|
+
Math.max(initialCooldownMs, initialCooldownMs * Math.pow(backoffMultiplier, escalation)),
|
|
172
|
+
)
|
|
173
|
+
opened = true
|
|
174
|
+
} else if (entry.consecutiveFailures >= degradedThreshold) {
|
|
175
|
+
if (entry.state === 'CLOSED') degraded = true
|
|
176
|
+
entry.state = 'DEGRADED'
|
|
177
|
+
}
|
|
178
|
+
if (entry.state !== before || opened || degraded) this.dirty = true
|
|
179
|
+
this.scheduleFlush()
|
|
180
|
+
return { state: entry.state, opened, degraded }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 📖 Full reset after a genuine success (routed traffic or a passing probe).
|
|
185
|
+
*/
|
|
186
|
+
markSuccess(key, initialCooldownMs = 30_000) {
|
|
187
|
+
const entry = this.breakers.get(key)
|
|
188
|
+
if (!entry) return
|
|
189
|
+
const changed = entry.state !== 'CLOSED' || entry.consecutiveFailures > 0 || entry.authError
|
|
190
|
+
entry.state = 'CLOSED'
|
|
191
|
+
entry.consecutiveFailures = 0
|
|
192
|
+
entry.cooldownMs = initialCooldownMs
|
|
193
|
+
entry.openedAt = null
|
|
194
|
+
entry.lastError = null
|
|
195
|
+
entry.authError = false
|
|
196
|
+
entry.updatedAt = Date.now()
|
|
197
|
+
// 📖 tripCount deliberately survives success: it is the escalating-backoff
|
|
198
|
+
// memory. It decays only via the TTL prune.
|
|
199
|
+
if (changed) {
|
|
200
|
+
this.dirty = true
|
|
201
|
+
this.scheduleFlush()
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
setFlag(key, flag, value) {
|
|
206
|
+
const entry = this.breakers.get(key)
|
|
207
|
+
if (!entry) return
|
|
208
|
+
entry[flag] = value
|
|
209
|
+
entry.updatedAt = Date.now()
|
|
210
|
+
this.dirty = true
|
|
211
|
+
this.scheduleFlush()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
get(key) {
|
|
215
|
+
return this.breakers.get(key) || null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
delete(key) {
|
|
219
|
+
if (this.breakers.delete(key)) {
|
|
220
|
+
this.dirty = true
|
|
221
|
+
this.scheduleFlush()
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 📖 Plain-object projection used by /stats, dashboards and persistence.
|
|
227
|
+
* `authError`/`stale`/`unsupported` style derived labels are applied by the
|
|
228
|
+
* caller; this returns the raw breaker fields.
|
|
229
|
+
*/
|
|
230
|
+
snapshot() {
|
|
231
|
+
const out = {}
|
|
232
|
+
for (const [key, entry] of this.breakers.entries()) {
|
|
233
|
+
out[key] = {
|
|
234
|
+
state: entry.state,
|
|
235
|
+
consecutiveFailures: entry.consecutiveFailures,
|
|
236
|
+
tripCount: entry.tripCount,
|
|
237
|
+
cooldownMs: entry.cooldownMs,
|
|
238
|
+
openedAt: entry.openedAt,
|
|
239
|
+
lastError: entry.lastError,
|
|
240
|
+
authError: entry.authError,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return out
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
scheduleFlush() {
|
|
247
|
+
if (this.flushTimer) return
|
|
248
|
+
this.flushTimer = setTimeout(() => {
|
|
249
|
+
this.flushTimer = null
|
|
250
|
+
this.flush()
|
|
251
|
+
}, FLUSH_DEBOUNCE_MS)
|
|
252
|
+
if (typeof this.flushTimer.unref === 'function') this.flushTimer.unref()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
flush() {
|
|
256
|
+
if (!this.dirty) return
|
|
257
|
+
try {
|
|
258
|
+
const payload = { version: STATE_VERSION, saved_at: new Date().toISOString(), breakers: this.snapshot() }
|
|
259
|
+
atomicWriteJson(this.path, payload, 0o600)
|
|
260
|
+
this.dirty = false
|
|
261
|
+
} catch (error) {
|
|
262
|
+
this.logger?.warn?.('Breaker state write failed', { error: error?.message })
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file constants.js
|
|
3
|
+
* @description Shared constants + helpers for Router v2 (ports, state files, model pinning).
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 Router v2 runs BETA alongside v1: own default port range (19380-19389
|
|
7
|
+
* production, 29380-29389 dev), own PID/port/log/state files with a `-v2`
|
|
8
|
+
* suffix, so both daemons can run on the same machine without clashing.
|
|
9
|
+
* Both daemons share the same `~/.free-coding-models.json` config (sets,
|
|
10
|
+
* keys, favorites); v2 never writes router sets (v1 owns config healing
|
|
11
|
+
* while v2 is in beta), it only reads + reloads.
|
|
12
|
+
*
|
|
13
|
+
* 📖 Pinned-model syntax: `model: "fcm:@provider/modelId"` routes to that
|
|
14
|
+
* exact model with failover disabled. It is what "test via router" uses to
|
|
15
|
+
* exercise ONE model through the FULL routing chain (normalization, pre
|
|
16
|
+
* prompt, response gate) instead of around it.
|
|
17
|
+
*
|
|
18
|
+
* @functions
|
|
19
|
+
* → getRouterV2PortRange() - Effective port range (dev vs production)
|
|
20
|
+
* → getRouterV2PidPath/PortPath/LogPath/StateDir() - Runtime file paths
|
|
21
|
+
* → parseFcmModel(model) - Resolve `fcm` | `fcm:<set>` | `fcm:@provider/model`
|
|
22
|
+
*
|
|
23
|
+
* @exports ROUTER_V2_DEFAULT_PORT, ROUTER_V2_MAX_PORT, ROUTER_V2_DEFAULT_PORT_DEV
|
|
24
|
+
* @exports ROUTER_V2_MAX_PORT_DEV, getRouterV2PortRange, getRouterV2PidPath
|
|
25
|
+
* @exports getRouterV2PortPath, getRouterV2LogPath, getRouterV2StateDir
|
|
26
|
+
* @exports parseFcmModel, FCM_V2_LOCAL_API_KEY
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { homedir } from 'node:os'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
|
|
32
|
+
export const ROUTER_V2_DEFAULT_PORT = 19380
|
|
33
|
+
export const ROUTER_V2_MAX_PORT = 19389
|
|
34
|
+
export const ROUTER_V2_DEFAULT_PORT_DEV = 29380
|
|
35
|
+
export const ROUTER_V2_MAX_PORT_DEV = 29389
|
|
36
|
+
|
|
37
|
+
// 📖 Placeholder API key coding tools send when the router needs no real key.
|
|
38
|
+
// Kept identical to v1 so switching a tool from v1 to v2 is a base-URL edit.
|
|
39
|
+
export const FCM_V2_LOCAL_API_KEY = 'fcm-local'
|
|
40
|
+
|
|
41
|
+
function isDev() {
|
|
42
|
+
return typeof process.env.FCM_DEV !== 'undefined' ? !!process.env.FCM_DEV : false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getRouterV2PortRange() {
|
|
46
|
+
return isDev()
|
|
47
|
+
? { defaultPort: ROUTER_V2_DEFAULT_PORT_DEV, maxPort: ROUTER_V2_MAX_PORT_DEV }
|
|
48
|
+
: { defaultPort: ROUTER_V2_DEFAULT_PORT, maxPort: ROUTER_V2_MAX_PORT }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getRouterV2PidPath() {
|
|
52
|
+
return join(homedir(), `.free-coding-models-daemon-v2${isDev() ? '-dev' : ''}.pid`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getRouterV2PortPath() {
|
|
56
|
+
return join(homedir(), `.free-coding-models-daemon-v2${isDev() ? '-dev' : ''}.port`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getRouterV2LogPath() {
|
|
60
|
+
return join(homedir(), `.free-coding-models-daemon-v2${isDev() ? '-dev' : ''}.log`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 📖 Directory holding v2 runtime state (breaker store, request history,
|
|
65
|
+
* token counters). Kept next to the classic config file so backups and
|
|
66
|
+
* `--fix-permissions` cover the whole family.
|
|
67
|
+
*/
|
|
68
|
+
export function getRouterV2StateDir() {
|
|
69
|
+
return homedir()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getRouterV2BreakersPath() {
|
|
73
|
+
return join(getRouterV2StateDir(), '.free-coding-models-router-v2-breakers.json')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getRouterV2HistoryPath() {
|
|
77
|
+
return join(getRouterV2StateDir(), '.free-coding-models-router-v2-history.json')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getRouterV2TokensPath() {
|
|
81
|
+
return join(getRouterV2StateDir(), `.free-coding-models-tokens-v2${isDev() ? '-dev' : ''}.json`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 📖 Resolve the `model` field of an incoming request.
|
|
86
|
+
* @param {string} model - raw model string from the client
|
|
87
|
+
* @returns {{ kind: 'default', set: null, pinned: null }
|
|
88
|
+
* | { kind: 'set', set: string, pinned: null }
|
|
89
|
+
* | { kind: 'pinned', set: null, pinned: { provider: string, model: string } }
|
|
90
|
+
* | { kind: 'unknown' }}
|
|
91
|
+
*/
|
|
92
|
+
export function parseFcmModel(model) {
|
|
93
|
+
if (typeof model !== 'string' || !model.trim()) return { kind: 'unknown', set: null, pinned: null }
|
|
94
|
+
const value = model.trim()
|
|
95
|
+
if (value === 'fcm' || value === 'fcm:default') return { kind: 'default', set: null, pinned: null }
|
|
96
|
+
if (value.startsWith('fcm:@')) {
|
|
97
|
+
const rest = value.slice(5)
|
|
98
|
+
const slashIdx = rest.indexOf('/')
|
|
99
|
+
if (slashIdx <= 0 || slashIdx === rest.length - 1) return { kind: 'unknown', set: null, pinned: null }
|
|
100
|
+
return { kind: 'pinned', set: null, pinned: { provider: rest.slice(0, slashIdx), model: rest.slice(slashIdx + 1) } }
|
|
101
|
+
}
|
|
102
|
+
if (value.startsWith('fcm:')) {
|
|
103
|
+
const setName = value.slice(4).trim()
|
|
104
|
+
if (!setName) return { kind: 'default', set: null, pinned: null }
|
|
105
|
+
return { kind: 'set', set: setName, pinned: null }
|
|
106
|
+
}
|
|
107
|
+
return { kind: 'unknown', set: null, pinned: null }
|
|
108
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file decision-trace.js
|
|
3
|
+
* @description Per-request routing decision trace for Router v2.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 v1's request log had a single boolean `failover` tag: users could see
|
|
7
|
+
* THAT something switched, never WHY, and never which candidates were
|
|
8
|
+
* skipped (circuit open? quota pause? dead key?). v2 records an ordered
|
|
9
|
+
* trace for every request:
|
|
10
|
+
* - `skipped`: candidates excluded before any attempt, with the reason
|
|
11
|
+
* - `attempts`: what was actually dispatched, with status/latency/error
|
|
12
|
+
* - `served`: the model that produced the final answer
|
|
13
|
+
* The trace is attached to the response via short `x-fcm-v2-*` headers so
|
|
14
|
+
* agents can see routing decisions without opening a dashboard, and it is
|
|
15
|
+
* persisted to the request history for the TUI + web views.
|
|
16
|
+
*
|
|
17
|
+
* 📖 Privacy contract: traces carry routing metadata only (model keys,
|
|
18
|
+
* statuses, timings, error KIND strings). No prompts, no response bodies,
|
|
19
|
+
* no API keys, no upstream URLs.
|
|
20
|
+
*
|
|
21
|
+
* @functions
|
|
22
|
+
* → createDecisionTrace(input) - New trace object for a request
|
|
23
|
+
* → traceSkip(trace, model, reason) - Record a pre-dispatch skip
|
|
24
|
+
* → traceAttempt(trace, model, result) - Record a dispatch attempt
|
|
25
|
+
* → finishTrace(trace, result) - Close the trace with the final outcome
|
|
26
|
+
* → decisionHeaderValue(trace) - Compact single-line header value
|
|
27
|
+
* → traceSummary(trace) - Human-facing summary for logs and dashboards
|
|
28
|
+
*
|
|
29
|
+
* @exports createDecisionTrace, traceSkip, traceAttempt, finishTrace
|
|
30
|
+
* @exports decisionHeaderValue, traceSummary
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { randomUUID } from 'node:crypto'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 📖 Create the trace object for one routed request.
|
|
37
|
+
* @param {{ requestId?: string, set?: string, protocol?: 'openai'|'anthropic',
|
|
38
|
+
* modelRequested?: string, pinnedModel?: string|null }} input
|
|
39
|
+
*/
|
|
40
|
+
export function createDecisionTrace({ requestId = null, set = null, protocol = 'openai', modelRequested = null, pinnedModel = null } = {}) {
|
|
41
|
+
return {
|
|
42
|
+
request_id: requestId || `req-${randomUUID()}`,
|
|
43
|
+
at: new Date().toISOString(),
|
|
44
|
+
set,
|
|
45
|
+
protocol,
|
|
46
|
+
model_requested: modelRequested,
|
|
47
|
+
pinned_model: pinnedModel,
|
|
48
|
+
skipped: [],
|
|
49
|
+
attempts: [],
|
|
50
|
+
served_model: null,
|
|
51
|
+
last_resort_used: false,
|
|
52
|
+
outcome: null,
|
|
53
|
+
wall_ms: null,
|
|
54
|
+
tokens: 0,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 📖 Record that a candidate was excluded BEFORE any dispatch, with the
|
|
60
|
+
* machine-readable reason (`circuit_open`, `quota_paused`, `auth_error`,
|
|
61
|
+
* `stale`, `missing_key`, `provider_blocked`, ...).
|
|
62
|
+
*/
|
|
63
|
+
export function traceSkip(trace, model, reason) {
|
|
64
|
+
if (!trace) return
|
|
65
|
+
trace.skipped.push({ model, reason, at: new Date().toISOString() })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 📖 Record a dispatch attempt. `result` mirrors the proxy functions' return
|
|
70
|
+
* shape: `{ status, latencyMs, error }` where error is a failure KIND string.
|
|
71
|
+
*/
|
|
72
|
+
export function traceAttempt(trace, model, { status = null, latencyMs = null, error = null } = {}) {
|
|
73
|
+
if (!trace) return
|
|
74
|
+
trace.attempts.push({
|
|
75
|
+
model,
|
|
76
|
+
status,
|
|
77
|
+
latency_ms: latencyMs,
|
|
78
|
+
error: error || null,
|
|
79
|
+
at: new Date().toISOString(),
|
|
80
|
+
})
|
|
81
|
+
trace.total_attempts = trace.attempts.length
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 📖 Close the trace. `outcome` is one of: 'served' | 'all_failed' |
|
|
86
|
+
* 'client_aborted' | 'rejected' | 'overloaded'.
|
|
87
|
+
*/
|
|
88
|
+
export function finishTrace(trace, { outcome, servedModel = null, wallMs = null, lastResort = false, tokens = 0 } = {}) {
|
|
89
|
+
if (!trace) return null
|
|
90
|
+
trace.outcome = outcome
|
|
91
|
+
trace.served_model = servedModel
|
|
92
|
+
trace.wall_ms = wallMs
|
|
93
|
+
trace.last_resort_used = lastResort === true
|
|
94
|
+
trace.tokens = tokens
|
|
95
|
+
return trace
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 📖 Compact single-line value for the `x-fcm-v2-decision` response header.
|
|
100
|
+
* Format: `servedModel!outcome|attempt1:status->attempt2:status|skips=N`.
|
|
101
|
+
* Header values must be ASCII one-liners, so model keys are already safe
|
|
102
|
+
* (provider/model ids) and everything else is bounded and truncated.
|
|
103
|
+
*
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
export function decisionHeaderValue(trace) {
|
|
107
|
+
if (!trace) return 'unknown'
|
|
108
|
+
const attempts = (trace.attempts || [])
|
|
109
|
+
.map((a) => `${a.model}:${a.status ?? a.error ?? 'ERR'}`)
|
|
110
|
+
.join('->')
|
|
111
|
+
.slice(0, 300)
|
|
112
|
+
const parts = [
|
|
113
|
+
`${trace.served_model || 'none'}!${trace.outcome || 'pending'}`,
|
|
114
|
+
attempts || 'no-attempts',
|
|
115
|
+
`skips=${(trace.skipped || []).length}`,
|
|
116
|
+
]
|
|
117
|
+
return parts.join('|').slice(0, 480)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 📖 Human-facing one-liner for logs, TUI overlays and history tables.
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function traceSummary(trace) {
|
|
125
|
+
if (!trace) return ''
|
|
126
|
+
const chain = (trace.attempts || [])
|
|
127
|
+
.map((a) => `${a.model}${a.error ? `(${a.error})` : a.status ? `(${a.status})` : ''}`)
|
|
128
|
+
.join(' -> ')
|
|
129
|
+
const skips = (trace.skipped || []).length
|
|
130
|
+
const base = chain || 'no candidate dispatched'
|
|
131
|
+
const suffix = skips > 0 ? ` [${skips} skipped]` : ''
|
|
132
|
+
const lastResort = trace.last_resort_used ? ' [last-resort]' : ''
|
|
133
|
+
return `${base}${suffix}${lastResort}`
|
|
134
|
+
}
|