flexinference 1.5.0 → 1.5.1
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/CHANGELOG.md +10 -0
- package/dist/index.js +57 -12
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.1
|
|
4
|
+
|
|
5
|
+
Small robustness fixes for the transport, no API changes. A non-streaming request that hits
|
|
6
|
+
its total `timeout` now throws a clear timeout error instead of a bare abort that looked like
|
|
7
|
+
a caller cancellation. A streamed response flushes its decoder and delivers the final frame
|
|
8
|
+
even when the stream ends without a trailing blank line, so the last event is never dropped,
|
|
9
|
+
and the streaming buffer is capped so a stream that never sends a separator cannot grow
|
|
10
|
+
without bound. The `start_within` type now matches the runtime rule, so a value the client
|
|
11
|
+
would reject at run time no longer type-checks.
|
|
12
|
+
|
|
3
13
|
## 1.5.0
|
|
4
14
|
|
|
5
15
|
Reworks request timeouts so a healthy stream is never cut off yet a hung request never hangs
|
package/dist/index.js
CHANGED
|
@@ -132,11 +132,18 @@ const FLEX_FIRST_BYTE_MARGIN_MS = 30_000; // headroom over the flex deadline for
|
|
|
132
132
|
const DEFAULT_IDLE_MS = 60_000; // max silence between streamed chunks before the stream is treated as hung
|
|
133
133
|
const DEFAULT_TOTAL_MS = 600_000; // non-streaming wall-clock budget (streaming has none)
|
|
134
134
|
const ERROR_BODY_READ_MS = 30_000; // bound the read of a non-2xx error body
|
|
135
|
+
// Cap the streaming buffer above the largest legitimate single frame (a terminal
|
|
136
|
+
// response object is well under this). A stream that never sends a blank-line separator
|
|
137
|
+
// would otherwise grow the buffer without bound; error clearly instead. See T2.
|
|
138
|
+
const MAX_SSE_BUFFER_CHARS = 8 * 1024 * 1024;
|
|
135
139
|
const MIN_DEADLINE_SECONDS = 5;
|
|
136
140
|
const MAX_DEADLINE_SECONDS = 600;
|
|
137
141
|
const SECONDS_PER_HOUR = 3600;
|
|
138
142
|
const SECONDS_PER_MINUTE = 60;
|
|
139
|
-
|
|
143
|
+
// One or two digits per field so the runtime accepts the same shapes the StartWithin
|
|
144
|
+
// template-literal type does (e.g. "0h-5m-0s"): `${number}` in the type imposes no digit
|
|
145
|
+
// count, so a two-digit-only regex would reject values that type-check. See T4.
|
|
146
|
+
const DURATION_RE = /^(\d{1,2})h-(\d{1,2})m-(\d{1,2})s$/;
|
|
140
147
|
const START_WITHIN_VALUES = '"default", "priority", "auto", or a duration "HHh-MMm-SSs"';
|
|
141
148
|
// Runtime guard so plain-JS callers (who get no compile-time check) still fail
|
|
142
149
|
// locally with a clear message instead of a provider round-trip. TypeScript callers
|
|
@@ -157,7 +164,7 @@ function assertStartWithin(body) {
|
|
|
157
164
|
const match = DURATION_RE.exec(value);
|
|
158
165
|
if (match === null) {
|
|
159
166
|
throw new Error(`FlexInference: \`start_within\` "${value}" is not ${START_WITHIN_VALUES}. ` +
|
|
160
|
-
`A duration is two digits each for hours, minutes, and seconds, for example "00h-05m-00s" for a 5-minute deadline.`);
|
|
167
|
+
`A duration is one or two digits each for hours, minutes, and seconds, for example "00h-05m-00s" for a 5-minute deadline.`);
|
|
161
168
|
}
|
|
162
169
|
const seconds = Number(match[1]) * SECONDS_PER_HOUR + Number(match[2]) * SECONDS_PER_MINUTE + Number(match[3]);
|
|
163
170
|
if (seconds < MIN_DEADLINE_SECONDS || seconds > MAX_DEADLINE_SECONDS) {
|
|
@@ -168,6 +175,7 @@ function assertStartWithin(body) {
|
|
|
168
175
|
// (turned into a clear message) versus the caller's own AbortSignal (propagated as-is).
|
|
169
176
|
const FIRST_BYTE_TIMEOUT = Symbol("first-byte-timeout");
|
|
170
177
|
const IDLE_TIMEOUT = Symbol("idle-timeout");
|
|
178
|
+
const TOTAL_TIMEOUT = Symbol("total-timeout");
|
|
171
179
|
/**
|
|
172
180
|
* Seconds for a flex-duration `start_within`, or null for a tier value ("default" etc.),
|
|
173
181
|
* a missing field, or a malformed one. Used to auto-size the first-byte wait: the router
|
|
@@ -189,12 +197,23 @@ function flexDeadlineSeconds(body) {
|
|
|
189
197
|
// and the abort listener. Non-streaming only: a stream is drained by streamSSE instead.
|
|
190
198
|
async function readJson(open) {
|
|
191
199
|
const { res, controller, cleanup, totalMs } = open;
|
|
200
|
+
// Abort with a dedicated reason so a fired total timeout can be told apart from the
|
|
201
|
+
// caller cancelling on purpose, and translated into a clear message (parity with the
|
|
202
|
+
// first-byte and idle timeouts, and with the Python SDK). See T1.
|
|
192
203
|
const totalTimer = setTimeout(() => {
|
|
193
|
-
controller.abort();
|
|
204
|
+
controller.abort(TOTAL_TIMEOUT);
|
|
194
205
|
}, totalMs);
|
|
195
206
|
try {
|
|
196
207
|
return (await res.json());
|
|
197
208
|
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
if (controller.signal.reason === TOTAL_TIMEOUT) {
|
|
211
|
+
throw new Error(`FlexInference: no full response from the router within ${String(totalMs)}ms ` +
|
|
212
|
+
"(total timeout). Raise `timeout` for very large non-streaming responses, or " +
|
|
213
|
+
"stream the request so a long generation is governed by the idle timeout instead.");
|
|
214
|
+
}
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
198
217
|
finally {
|
|
199
218
|
clearTimeout(totalTimer);
|
|
200
219
|
cleanup();
|
|
@@ -378,6 +397,19 @@ class Messages {
|
|
|
378
397
|
return readJson(open);
|
|
379
398
|
}
|
|
380
399
|
}
|
|
400
|
+
function parseSSEFrame(frame) {
|
|
401
|
+
const dataLines = [];
|
|
402
|
+
for (const line of frame.split("\n")) {
|
|
403
|
+
if (line.startsWith("data:"))
|
|
404
|
+
dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
405
|
+
}
|
|
406
|
+
if (dataLines.length === 0)
|
|
407
|
+
return null;
|
|
408
|
+
const data = dataLines.join("\n");
|
|
409
|
+
if (data === "[DONE]")
|
|
410
|
+
return { kind: "done" };
|
|
411
|
+
return { kind: "value", value: JSON.parse(data) };
|
|
412
|
+
}
|
|
381
413
|
async function* streamSSE(open) {
|
|
382
414
|
const { res, controller, idleMs, cleanup } = open;
|
|
383
415
|
if (!res.body) {
|
|
@@ -411,7 +443,18 @@ async function* streamSSE(open) {
|
|
|
411
443
|
}
|
|
412
444
|
const { done, value } = chunk;
|
|
413
445
|
if (done) {
|
|
446
|
+
// The stream ended. Flush any bytes the decoder held back for an incomplete
|
|
447
|
+
// multibyte sequence, then emit a final frame that arrived without its trailing
|
|
448
|
+
// blank line so the last piece of the answer is not dropped. See T3.
|
|
414
449
|
exhausted = true;
|
|
450
|
+
buffer += decoder.decode();
|
|
451
|
+
const frame = buffer;
|
|
452
|
+
buffer = "";
|
|
453
|
+
if (frame.length > 0) {
|
|
454
|
+
const parsed = parseSSEFrame(frame);
|
|
455
|
+
if (parsed !== null && parsed.kind === "value")
|
|
456
|
+
yield parsed.value;
|
|
457
|
+
}
|
|
415
458
|
break;
|
|
416
459
|
}
|
|
417
460
|
buffer += decoder.decode(value, { stream: true }).replace(/\r/g, "");
|
|
@@ -419,19 +462,21 @@ async function* streamSSE(open) {
|
|
|
419
462
|
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
420
463
|
const frame = buffer.slice(0, sep);
|
|
421
464
|
buffer = buffer.slice(sep + 2);
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
if (line.startsWith("data:"))
|
|
425
|
-
dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
426
|
-
}
|
|
427
|
-
if (dataLines.length === 0)
|
|
465
|
+
const parsed = parseSSEFrame(frame);
|
|
466
|
+
if (parsed === null)
|
|
428
467
|
continue;
|
|
429
|
-
|
|
430
|
-
if (data === "[DONE]") {
|
|
468
|
+
if (parsed.kind === "done") {
|
|
431
469
|
exhausted = true;
|
|
432
470
|
return;
|
|
433
471
|
}
|
|
434
|
-
yield
|
|
472
|
+
yield parsed.value;
|
|
473
|
+
}
|
|
474
|
+
// With every complete frame drained, whatever remains is a single unterminated
|
|
475
|
+
// frame. If it grows past the cap the stream is never sending a separator; error
|
|
476
|
+
// clearly instead of buffering without bound. See T2.
|
|
477
|
+
if (buffer.length > MAX_SSE_BUFFER_CHARS) {
|
|
478
|
+
throw new Error(`FlexInference: stream buffer exceeded ${String(MAX_SSE_BUFFER_CHARS)} characters ` +
|
|
479
|
+
"without a frame boundary; aborting to avoid unbounded memory.");
|
|
435
480
|
}
|
|
436
481
|
}
|
|
437
482
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexinference",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Official TypeScript SDK for FlexInference - a deadline-aware, OpenAI-compatible inference router.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Aditya Perswal <adityaperswal@gmail.com>",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"build": "tsc -p tsconfig.json",
|
|
35
35
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
36
36
|
"test:types": "tsc -p tsconfig.test.json --noEmit",
|
|
37
|
-
"test": "node test/smoke.mjs && npm run test:types",
|
|
37
|
+
"test": "node test/smoke.mjs && node test/fixes.test.mjs && npm run test:types",
|
|
38
38
|
"lint": "eslint .",
|
|
39
39
|
"lint:fix": "eslint . --fix",
|
|
40
40
|
"format": "prettier --write .",
|