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 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
- const DURATION_RE = /^(\d{2})h-(\d{2})m-(\d{2})s$/;
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 dataLines = [];
423
- for (const line of frame.split("\n")) {
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
- const data = dataLines.join("\n");
430
- if (data === "[DONE]") {
468
+ if (parsed.kind === "done") {
431
469
  exhausted = true;
432
470
  return;
433
471
  }
434
- yield JSON.parse(data);
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.0",
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 .",