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.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * @file failure-classifier.js
3
+ * @description Typed failure classification for the Router v2 failover engine.
4
+ *
5
+ * @details
6
+ * 📖 Router v1 treated almost every non-2xx the same way: increment
7
+ * `consecutiveFailures` and fail over. That punishes healthy models when the
8
+ * CLIENT payload is the problem (400/413/422 would burn 3 or 4 healthy
9
+ * models toward circuit-open for nothing), and it merges fundamentally
10
+ * different situations (dead key vs quota pause vs busy model) into one
11
+ * counter.
12
+ *
13
+ * 📖 v2 classifies every failure into a typed kind, and each kind carries an
14
+ * explicit policy: does the attempt fail over to the next candidate, does it
15
+ * damage the model's health (circuit breaker), does it block the whole
16
+ * provider (dead key), or does it pause the model for a quota window
17
+ * (Retry-After aware). The classifier is pure: it never mutates runtime
18
+ * state, so it is trivially unit-testable.
19
+ *
20
+ * @functions
21
+ * → classifyStatus(status) - Map an HTTP status code to a failure kind
22
+ * → classifyFailure(input) - Full verdict for a failure (kind + policy flags)
23
+ * → clientStatusForKind(kind) - Status code to send the client when every
24
+ * model in the set failed with that kind
25
+ *
26
+ * @exports FAILURE_KINDS, classifyStatus, classifyFailure, clientStatusForKind
27
+ */
28
+
29
+ /**
30
+ * 📖 Every failure kind the v2 router can produce. Kept as a plain frozen
31
+ * object so tests and dashboards can enumerate them without relying on
32
+ * `Object.keys` order of a TS enum-like structure.
33
+ */
34
+ export const FAILURE_KINDS = Object.freeze({
35
+ AUTH: 'auth_error',
36
+ RATE_LIMIT: 'rate_limit',
37
+ QUOTA: 'quota_exhausted',
38
+ TIMEOUT: 'timeout',
39
+ NETWORK: 'network_error',
40
+ SERVER: 'provider_server_error',
41
+ OVERLOADED: 'model_overloaded',
42
+ INVALID_REQUEST: 'invalid_request',
43
+ INVALID_JSON: 'invalid_json',
44
+ EMPTY_CHOICES: 'empty_choices',
45
+ EMPTY_CONTENT: 'empty_content',
46
+ ERROR_PAYLOAD: 'error_payload',
47
+ HTML: 'html_maintenance',
48
+ EMPTY_STREAM: 'empty_stream',
49
+ STREAM_STALL: 'stream_stall',
50
+ PROVIDER_URL: 'provider_url_unresolvable',
51
+ })
52
+
53
+ // 📖 HTTP status → kind. 529 is the non-standard "Overloaded" status used by
54
+ // several inference providers: it is model-scoped (that specific model is
55
+ // busy), not a provider-wide outage.
56
+ const STATUS_KIND_MAP = new Map([
57
+ [401, FAILURE_KINDS.AUTH],
58
+ [403, FAILURE_KINDS.AUTH],
59
+ [408, FAILURE_KINDS.TIMEOUT],
60
+ [429, FAILURE_KINDS.RATE_LIMIT],
61
+ [500, FAILURE_KINDS.SERVER],
62
+ [502, FAILURE_KINDS.SERVER],
63
+ [503, FAILURE_KINDS.SERVER],
64
+ [504, FAILURE_KINDS.SERVER],
65
+ [529, FAILURE_KINDS.OVERLOADED],
66
+ ])
67
+
68
+ /**
69
+ * 📖 Map an HTTP status code to a coarse failure kind.
70
+ * @param {number} status
71
+ * @returns {string} one of FAILURE_KINDS values
72
+ */
73
+ export function classifyStatus(status) {
74
+ const kind = STATUS_KIND_MAP.get(status)
75
+ if (kind) return kind
76
+ if (status >= 500) return FAILURE_KINDS.SERVER
77
+ if (status >= 400) return FAILURE_KINDS.INVALID_REQUEST
78
+ return FAILURE_KINDS.NETWORK
79
+ }
80
+
81
+ /**
82
+ * 📖 Build the full policy verdict for a failure.
83
+ *
84
+ * @param {object} input
85
+ * @param {string|null} [input.kind] - explicit kind (content-level failures);
86
+ * when omitted the kind is derived from `status`.
87
+ * @param {number|null} [input.status] - upstream HTTP status
88
+ * @param {number|null} [input.retryAfterMs] - parsed Retry-After from upstream
89
+ * @returns {{
90
+ * kind: string,
91
+ * blame: 'provider'|'model'|'client',
92
+ * failover: boolean,
93
+ * healthDamage: boolean,
94
+ * blockProvider: boolean,
95
+ * quotaPauseMs: number|null,
96
+ * clientStatus: number,
97
+ * }}
98
+ */
99
+ export function classifyFailure({ kind = null, status = null, retryAfterMs = null } = {}) {
100
+ const resolvedKind = kind || (status != null ? classifyStatus(status) : FAILURE_KINDS.NETWORK)
101
+
102
+ switch (resolvedKind) {
103
+ case FAILURE_KINDS.AUTH:
104
+ // 📖 Dead or unauthorized key: fail over AND block the rest of this
105
+ // provider for the request, but do NOT spin the circuit breaker: the
106
+ // model itself is fine, the credential is not. markAuthError handles it.
107
+ return {
108
+ kind: resolvedKind,
109
+ blame: 'provider',
110
+ failover: true,
111
+ healthDamage: false,
112
+ blockProvider: true,
113
+ quotaPauseMs: null,
114
+ clientStatus: 401,
115
+ }
116
+ case FAILURE_KINDS.RATE_LIMIT:
117
+ case FAILURE_KINDS.QUOTA: {
118
+ // 📖 Rate limited: fail over and pause THIS model for the Retry-After
119
+ // window (capped) so it stops eating traffic it cannot serve. Health
120
+ // damage stays on: repeated 429s on every attempt legitimately mean
121
+ // the model is not usable right now.
122
+ const pauseMs = retryAfterMs != null ? Math.min(Math.max(0, retryAfterMs), 15 * 60 * 1000) : null
123
+ return {
124
+ kind: resolvedKind,
125
+ blame: 'model',
126
+ failover: true,
127
+ healthDamage: true,
128
+ blockProvider: false,
129
+ quotaPauseMs: pauseMs,
130
+ clientStatus: 429,
131
+ }
132
+ }
133
+ case FAILURE_KINDS.INVALID_REQUEST:
134
+ // 📖 Blame attribution fix: a 400/404/413/422 usually means the CLIENT
135
+ // payload does not fit this model (unsupported tools, too-large body).
136
+ // v2 still fails over (another model may accept the format) but never
137
+ // counts it toward the circuit breaker, so one oversized payload can no
138
+ // longer open circuits on three healthy models.
139
+ return {
140
+ kind: resolvedKind,
141
+ blame: 'client',
142
+ failover: true,
143
+ healthDamage: false,
144
+ blockProvider: false,
145
+ quotaPauseMs: null,
146
+ clientStatus: status || 400,
147
+ }
148
+ case FAILURE_KINDS.OVERLOADED:
149
+ // 📖 529 "Overloaded" is scoped to the model, never the provider.
150
+ return {
151
+ kind: resolvedKind,
152
+ blame: 'model',
153
+ failover: true,
154
+ healthDamage: true,
155
+ blockProvider: false,
156
+ quotaPauseMs: null,
157
+ clientStatus: 529,
158
+ }
159
+ case FAILURE_KINDS.TIMEOUT:
160
+ case FAILURE_KINDS.STREAM_STALL:
161
+ return {
162
+ kind: resolvedKind,
163
+ blame: 'provider',
164
+ failover: true,
165
+ healthDamage: true,
166
+ blockProvider: false,
167
+ quotaPauseMs: null,
168
+ clientStatus: 504,
169
+ }
170
+ case FAILURE_KINDS.NETWORK:
171
+ case FAILURE_KINDS.SERVER:
172
+ case FAILURE_KINDS.PROVIDER_URL:
173
+ return {
174
+ kind: resolvedKind,
175
+ blame: 'provider',
176
+ failover: true,
177
+ healthDamage: true,
178
+ blockProvider: false,
179
+ quotaPauseMs: null,
180
+ clientStatus: 502,
181
+ }
182
+ case FAILURE_KINDS.HTML:
183
+ return {
184
+ kind: resolvedKind,
185
+ blame: 'provider',
186
+ failover: true,
187
+ healthDamage: true,
188
+ blockProvider: false,
189
+ quotaPauseMs: null,
190
+ clientStatus: 503,
191
+ }
192
+ case FAILURE_KINDS.INVALID_JSON:
193
+ case FAILURE_KINDS.EMPTY_CHOICES:
194
+ case FAILURE_KINDS.EMPTY_CONTENT:
195
+ case FAILURE_KINDS.ERROR_PAYLOAD:
196
+ case FAILURE_KINDS.EMPTY_STREAM:
197
+ // 📖 The notorious "HTTP 200 but garbage" family: the provider answered
198
+ // with a usable transport envelope but no usable answer. These MUST fail
199
+ // over and MUST count as real failures, otherwise the router "succeeds"
200
+ // with empty output (v1's biggest blind spot).
201
+ return {
202
+ kind: resolvedKind,
203
+ blame: 'provider',
204
+ failover: true,
205
+ healthDamage: true,
206
+ blockProvider: false,
207
+ quotaPauseMs: null,
208
+ clientStatus: 502,
209
+ }
210
+ default:
211
+ return {
212
+ kind: resolvedKind,
213
+ blame: 'provider',
214
+ failover: true,
215
+ healthDamage: true,
216
+ blockProvider: false,
217
+ quotaPauseMs: null,
218
+ clientStatus: 502,
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * 📖 Status code to send the client when every candidate failed with the same
225
+ * kind. Used to refine the generic 503 "all models failed" response.
226
+ * @param {string} kind
227
+ * @returns {number}
228
+ */
229
+ export function clientStatusForKind(kind) {
230
+ return classifyFailure({ kind }).clientStatus
231
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @file request-history.js
3
+ * @description Persisted request history for Router v2.
4
+ *
5
+ * @details
6
+ * 📖 v1 kept the last 20 request log entries in memory only: restart the
7
+ * daemon and the evidence was gone, which made "the router feels flaky"
8
+ * impossible to debug after the fact. v2 persists every routed request
9
+ * (bounded ring, atomic writes, debounced flush) with its full decision
10
+ * trace so the TUI overlay and the web page can show a durable fallback
11
+ * chain per request.
12
+ *
13
+ * 📖 Storage shape: a single JSON file `{ version, entries: [...] }` written
14
+ * with `atomicWriteJson`. Entries are capped (oldest dropped) and each one
15
+ * is routing metadata only: model keys, statuses, timings, error kinds.
16
+ * No prompts, no completions, no credentials.
17
+ *
18
+ * @functions
19
+ * → new RequestHistory({ path, logger, maxEntries }) - Load persisted history
20
+ * → history.append(entry) - Add one request record (debounced flush)
21
+ * → history.recent(limit) - Newest-first slice
22
+ * → history.stats() - Aggregate counters over the retained window
23
+ * → history.flush({ force }) / history.clear()
24
+ *
25
+ * @exports RequestHistory
26
+ */
27
+
28
+ import { existsSync, readFileSync } from 'node:fs'
29
+ import { atomicWriteJson, safeJsonParse } from '../shared-helpers.js'
30
+
31
+ const STATE_VERSION = 1
32
+ const FLUSH_DEBOUNCE_MS = 2000
33
+ export const DEFAULT_MAX_ENTRIES = 500
34
+
35
+ export class RequestHistory {
36
+ constructor({ path, logger, maxEntries = DEFAULT_MAX_ENTRIES } = {}) {
37
+ this.path = path
38
+ this.logger = logger
39
+ this.maxEntries = Math.max(10, maxEntries)
40
+ this.entries = []
41
+ this.dirty = false
42
+ this.flushTimer = null
43
+ this.load()
44
+ }
45
+
46
+ load() {
47
+ try {
48
+ if (!existsSync(this.path)) return
49
+ const parsed = safeJsonParse(readFileSync(this.path, 'utf8'), null)
50
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) return
51
+ this.entries = parsed.entries
52
+ .filter((entry) => entry && typeof entry === 'object' && typeof entry.request_id === 'string')
53
+ .slice(-this.maxEntries)
54
+ } catch (error) {
55
+ this.logger?.warn?.('Request history load failed; starting fresh', { error: error?.message })
56
+ this.entries = []
57
+ }
58
+ }
59
+
60
+ /**
61
+ * 📖 Append one completed request record. Expected shape (all routing
62
+ * metadata, produced by the routeRequest finally-block):
63
+ * `{ request_id, at, set, protocol, model_requested, served_model, outcome,
64
+ * attempts: [...], skipped: [...], wall_ms, tokens, stream,
65
+ * last_resort_used }`
66
+ */
67
+ append(entry) {
68
+ if (!entry || typeof entry !== 'object') return
69
+ this.entries.push(entry)
70
+ while (this.entries.length > this.maxEntries) this.entries.shift()
71
+ this.dirty = true
72
+ this.scheduleFlush()
73
+ }
74
+
75
+ /** 📖 Newest-first slice for API responses and dashboards. */
76
+ recent(limit = 50) {
77
+ const n = Math.max(1, Math.min(limit, this.maxEntries))
78
+ return this.entries.slice(-n).reverse()
79
+ }
80
+
81
+ /** 📖 Aggregate counters over the retained window (newest `window` entries). */
82
+ stats({ window = this.maxEntries } = {}) {
83
+ const slice = this.entries.slice(-Math.max(1, window))
84
+ const total = slice.length
85
+ let served = 0
86
+ let failovers = 0
87
+ let lastResort = 0
88
+ let attemptsSum = 0
89
+ let wallSum = 0
90
+ let wallCount = 0
91
+ for (const entry of slice) {
92
+ if (entry.outcome === 'served') served += 1
93
+ if ((entry.attempts?.length || 0) > 1) failovers += 1
94
+ if (entry.last_resort_used === true) lastResort += 1
95
+ attemptsSum += entry.attempts?.length || 0
96
+ if (Number.isFinite(entry.wall_ms) && entry.wall_ms > 0) {
97
+ wallSum += entry.wall_ms
98
+ wallCount += 1
99
+ }
100
+ }
101
+ return {
102
+ retained: total,
103
+ served,
104
+ all_failed: total - served,
105
+ failover_rate: total > 0 ? Math.round((failovers / total) * 100) / 100 : 0,
106
+ success_rate: total > 0 ? Math.round((served / total) * 100) / 100 : null,
107
+ last_resort_used: lastResort,
108
+ avg_attempts: total > 0 ? Math.round((attemptsSum / total) * 100) / 100 : 0,
109
+ avg_wall_ms: wallCount > 0 ? Math.round(wallSum / wallCount) : null,
110
+ }
111
+ }
112
+
113
+ scheduleFlush() {
114
+ if (this.flushTimer) return
115
+ this.flushTimer = setTimeout(() => {
116
+ this.flushTimer = null
117
+ this.flush()
118
+ }, FLUSH_DEBOUNCE_MS)
119
+ if (typeof this.flushTimer.unref === 'function') this.flushTimer.unref()
120
+ }
121
+
122
+ flush() {
123
+ if (!this.dirty) return
124
+ try {
125
+ atomicWriteJson(this.path, { version: STATE_VERSION, entries: this.entries }, 0o600)
126
+ this.dirty = false
127
+ } catch (error) {
128
+ this.logger?.warn?.('Request history write failed', { error: error?.message })
129
+ }
130
+ }
131
+
132
+ clear() {
133
+ this.entries = []
134
+ this.dirty = true
135
+ this.flush()
136
+ }
137
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * @file response-gate.js
3
+ * @description Content-level response validation for Router v2.
4
+ *
5
+ * @details
6
+ * 📖 The single biggest v1 blind spot: free providers sometimes answer
7
+ * HTTP 200 with an empty `choices` array, an embedded `error` object, or an
8
+ * SSE stream that closes without ever producing useful content. v1 counted
9
+ * all of those as SUCCESS, so the failover engine never fired and coding
10
+ * agents silently received nothing.
11
+ *
12
+ * 📖 The gate lives in two layers:
13
+ * - `validateChatCompletionPayload` checks a parsed non-streaming 200 body.
14
+ * - `createStreamReadinessTracker` inspects SSE chunks as they arrive and
15
+ * answers "has this stream produced anything useful yet?" so the daemon
16
+ * can hold the first chunks briefly, fail over on an error payload before
17
+ * anything reaches the client, and treat a content-less close as a real
18
+ * failure.
19
+ *
20
+ * 📖 "Useful content" means: a non-empty text delta, tool_calls, a legacy
21
+ * function_call, or reasoning tokens. A bare `{role: "assistant"}` first
22
+ * chunk is normal framing, not content. Reasoning-only output DOES count
23
+ * (some thinking models stream reasoning before text), so it is never
24
+ * treated as a failure.
25
+ *
26
+ * @functions
27
+ * → validateChatCompletionPayload(payload, opts) - Gate a parsed JSON body
28
+ * → createStreamReadinessTracker() - Incremental SSE usefulness tracker
29
+ * → estimateTokens(text) - Cheap completion-token estimate for streams
30
+ *
31
+ * @exports validateChatCompletionPayload, createStreamReadinessTracker, estimateTokens
32
+ */
33
+
34
+ // 📖 A single SSE event larger than this cannot be a useful first frame of a
35
+ // coding answer; treat it as unparsable garbage rather than buffering forever.
36
+ const MAX_HOLD_BUFFER_BYTES = 256 * 1024
37
+
38
+ /**
39
+ * 📖 Validate a parsed non-streaming chat-completion payload.
40
+ *
41
+ * @param {unknown} payload - the JSON.parse result of a 200 body
42
+ * @param {{ mode?: 'strict'|'basic' }} [opts]
43
+ * - strict (default): also requires the first choice to carry actual
44
+ * content (text, tool_calls, function_call or reasoning).
45
+ * - basic: only rejects structural garbage (no choices, embedded error).
46
+ * @returns {{ ok: boolean, reason: string|null, detail: string|null }}
47
+ */
48
+ export function validateChatCompletionPayload(payload, { mode = 'strict' } = {}) {
49
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
50
+ return { ok: false, reason: 'invalid_json', detail: 'payload is not an object' }
51
+ }
52
+ if (payload.error !== undefined && payload.error !== null) {
53
+ const err = payload.error
54
+ const detail = typeof err === 'object' && err !== null
55
+ ? String(err.code || err.type || err.message || 'upstream error object').slice(0, 200)
56
+ : String(err).slice(0, 200)
57
+ return { ok: false, reason: 'error_payload', detail }
58
+ }
59
+ if (!Array.isArray(payload.choices) || payload.choices.length === 0) {
60
+ return { ok: false, reason: 'empty_choices', detail: 'choices missing or empty' }
61
+ }
62
+ if (mode === 'basic') return { ok: true, reason: null, detail: null }
63
+
64
+ const choice = payload.choices[0]
65
+ if (!choice || typeof choice !== 'object') {
66
+ return { ok: false, reason: 'empty_content', detail: 'first choice is not an object' }
67
+ }
68
+ const msg = choice.message
69
+ const content = typeof msg?.content === 'string' ? msg.content.trim() : ''
70
+ const hasToolCalls = Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0
71
+ const hasFunctionCall = msg?.function_call != null
72
+ const reasoning = typeof msg?.reasoning_content === 'string' ? msg.reasoning_content.trim() : ''
73
+ const reasoningAlt = typeof msg?.reasoning === 'string' ? msg.reasoning.trim() : ''
74
+ if (!content && !hasToolCalls && !hasFunctionCall && !reasoning && !reasoningAlt) {
75
+ return { ok: false, reason: 'empty_content', detail: 'no text, tool_calls or reasoning in first choice' }
76
+ }
77
+ return { ok: true, reason: null, detail: null }
78
+ }
79
+
80
+ /**
81
+ * 📖 Incremental tracker for SSE streams. Feed every decoded text chunk into
82
+ * `observe()`; the tracker extracts `data:` JSON payloads and reports:
83
+ * - useful: seen real content (text delta, tool_calls, reasoning, ...)
84
+ * - errorPayload: an SSE `data:` frame carried an `error` object
85
+ * The daemon combines those signals with its hold-buffer policy to decide
86
+ * between flush-to-client, fail-over-before-first-byte, and "content-less
87
+ * stream = failure".
88
+ *
89
+ * @returns {{
90
+ * observe(text: string): void,
91
+ * get useful(): boolean,
92
+ * get errorPayload(): boolean,
93
+ * get bytesSeen(): number,
94
+ * get unparsableFrames(): number,
95
+ * describe(): string,
96
+ * }}
97
+ */
98
+ export function createStreamReadinessTracker() {
99
+ let useful = false
100
+ let errorPayload = false
101
+ let bytesSeen = 0
102
+ let unparsableFrames = 0
103
+ let lineRemainder = ''
104
+
105
+ const inspectDataPayload = (raw) => {
106
+ const trimmed = raw.trim()
107
+ if (!trimmed || trimmed === '[DONE]') return
108
+ let parsed
109
+ try {
110
+ parsed = JSON.parse(trimmed)
111
+ } catch {
112
+ unparsableFrames += 1
113
+ return
114
+ }
115
+ if (!parsed || typeof parsed !== 'object') return
116
+ if (parsed.error !== undefined && parsed.error !== null) {
117
+ errorPayload = true
118
+ return
119
+ }
120
+ const choices = parsed.choices
121
+ if (!Array.isArray(choices)) return
122
+ for (const choice of choices) {
123
+ const delta = choice?.delta ?? choice?.message
124
+ if (!delta || typeof delta !== 'object') continue
125
+ const content = typeof delta.content === 'string' ? delta.content : ''
126
+ if (content.length > 0) useful = true
127
+ if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) useful = true
128
+ if (delta.function_call != null) useful = true
129
+ const reasoning = typeof delta.reasoning_content === 'string' ? delta.reasoning_content : ''
130
+ if (reasoning.length > 0) useful = true
131
+ const reasoningAlt = typeof delta.reasoning === 'string' ? delta.reasoning : ''
132
+ if (reasoningAlt.length > 0) useful = true
133
+ }
134
+ }
135
+
136
+ return {
137
+ observe(text) {
138
+ if (typeof text !== 'string' || text.length === 0) return
139
+ bytesSeen += Buffer.byteLength(text)
140
+ const data = lineRemainder + text
141
+ const lines = data.split('\n')
142
+ // 📖 The last element is either an incomplete line (keep it for next
143
+ // chunk) or an empty trailing piece after a final newline.
144
+ lineRemainder = lines.pop() ?? ''
145
+ for (const line of lines) {
146
+ const trimmed = line.trim()
147
+ if (!trimmed || trimmed.startsWith(':')) continue
148
+ if (trimmed.startsWith('data:')) {
149
+ inspectDataPayload(trimmed.slice(5))
150
+ if (useful && errorPayload) return
151
+ }
152
+ }
153
+ },
154
+ get useful() { return useful },
155
+ get errorPayload() { return errorPayload },
156
+ get bytesSeen() { return bytesSeen },
157
+ get unparsableFrames() { return unparsableFrames },
158
+ get maxHoldBytes() { return MAX_HOLD_BUFFER_BYTES },
159
+ describe() {
160
+ return `useful=${useful} error=${errorPayload} bytes=${bytesSeen} unparsable=${unparsableFrames}`
161
+ },
162
+ }
163
+ }
164
+
165
+ /**
166
+ * 📖 Cheap completion-token estimate for streamed responses where the
167
+ * upstream never sends a usage block. Roughly 4 characters per token is the
168
+ * usual rule of thumb for English/code; good enough for usage dashboards.
169
+ * @param {string} text
170
+ * @returns {number}
171
+ */
172
+ export function estimateTokens(text) {
173
+ if (typeof text !== 'string' || text.length === 0) return 0
174
+ return Math.max(1, Math.ceil(text.length / 4))
175
+ }