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.
- package/README.md +6 -0
- package/bin/free-coding-models.js +33 -2
- package/changelog/v0.5.89.md +20 -0
- package/package.json +2 -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/utils.js +12 -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-CAzFIt8P.css +0 -1
- package/web/dist/assets/index-DFg1h0Nd.js +0 -44
|
@@ -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
|
+
}
|