free-coding-models 0.5.88 → 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.
@@ -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
+ }