flexinference 1.4.1 → 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,33 @@
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
+
13
+ ## 1.5.0
14
+
15
+ Reworks request timeouts so a healthy stream is never cut off yet a hung request never hangs
16
+ forever. Streaming requests now have **no total cap** -- they run as long as tokens keep
17
+ arriving -- and are bounded instead by two new options: `firstByteTimeout` (how long to wait
18
+ for the first response, default `60000` ms) and `idleTimeout` (max silence between chunks
19
+ before the stream is treated as hung, default `60000` ms). Non-streaming requests keep the
20
+ total `timeout` (default `600000` ms). The first-byte wait is **auto-raised for a flex
21
+ `start_within`**, fixing flex requests whose deadline exceeded the old hardcoded 10s connect
22
+ cap and aborted before the router replied. All three timeouts are configurable on the client
23
+ and per request. Behavior change, no API breaks: `timeout` no longer caps a stream, and the
24
+ old 10s first-response cap is now 60s (auto-sized for flex).
25
+
26
+ Also teaches `FlexInferenceError` to parse the router's per-surface error shapes: the router now
27
+ returns errors in the dialect of the endpoint you called (OpenAI on responses/chat, Anthropic on
28
+ `messages`, Google on `interactions`), and the error class reads all three (`message` always,
29
+ `type` from the OpenAI/Anthropic `type` or Gemini `status`).
30
+
3
31
  ## 1.4.1
4
32
 
5
33
  Rewrites the README in plain language from the copy audit. It explains what `start_within` does, how the cheaper flex tier runs first and falls back up to your standard tier when it cannot start in time, and how you only pay a share of what a flex request saves you. No code or API changes.
package/README.md CHANGED
@@ -102,19 +102,29 @@ console.log(messageText(res));
102
102
 
103
103
  ## Timeouts and cancellation
104
104
 
105
- Every client has a built-in timeout: **10 seconds to connect** and **600 seconds (10 min) total**, matching the router's budget, so a request can never hang forever. Override the total with the `timeout` option (raise it for very long streams):
105
+ Streaming and non-streaming requests are timed differently, so a long generation is never cut off yet a hung request never hangs forever:
106
+
107
+ - **Non-streaming** requests have a total wall-clock budget: `timeout`, default **600000 ms (10 min)**, from send through the full body.
108
+ - **Streaming** requests have **no total cap** (a healthy stream runs as long as tokens keep arriving). They are bounded by two clocks: `firstByteTimeout` (wait for the first response, default **60000 ms**) and `idleTimeout` (max silence between chunks before the stream is treated as hung, default **60000 ms**).
109
+
110
+ The first-byte wait is **auto-raised for a flex `start_within`** (the router withholds response headers until the flex race resolves), so a long deadline just works. Raise `firstByteTimeout` yourself only for very large non-flex contexts whose first token is slow.
106
111
 
107
112
  ```ts
108
- const client = new FlexInference({ apiKey: "flex_live_...", timeout: 120_000 });
113
+ const client = new FlexInference({
114
+ apiKey: "flex_live_...",
115
+ timeout: 120_000, // non-streaming total budget
116
+ firstByteTimeout: 90_000, // wait for the first response
117
+ idleTimeout: 30_000, // max silence between streamed chunks
118
+ });
109
119
  ```
110
120
 
111
- Every call also accepts an `AbortSignal` as a second argument, so you can cancel a request yourself. Cancelling a stream stops it mid-flight:
121
+ Any of these can be overridden per request, alongside an `AbortSignal` to cancel yourself. Cancelling a stream stops it mid-flight:
112
122
 
113
123
  ```ts
114
124
  const controller = new AbortController();
115
125
  const res = await client.responses.create(
116
126
  { model: "gpt-5.5", input: "Write a haiku about cheap GPUs.", start_within: "priority" },
117
- { signal: controller.signal },
127
+ { signal: controller.signal, idleTimeout: 15_000 },
118
128
  );
119
129
  // controller.abort() from elsewhere to cancel
120
130
  console.log(outputText(res));
@@ -122,7 +132,7 @@ console.log(outputText(res));
122
132
 
123
133
  ## Errors
124
134
 
125
- Non-2xx responses throw `FlexInferenceError`, carrying the OpenAI-shaped `status`, `type`, `code`, and `param`:
135
+ Non-2xx responses throw `FlexInferenceError`, carrying `status`, `type`, `code`, and `param`. The router shapes error bodies to match the endpoint you called (OpenAI on `responses`/`chat`, Anthropic on `messages`, Google on `interactions`) so the SDK you would use for that surface parses them; `FlexInferenceError` reads all three. `message` and `status` are always set; `code`, `param`, and `docUrl` are populated on the OpenAI surface.
126
136
 
127
137
  ```ts
128
138
  import { FlexInferenceError } from "flexinference";
@@ -138,7 +148,7 @@ try {
138
148
  }
139
149
  ```
140
150
 
141
- Every FlexInference error tells you four things. It says what went wrong, why it went wrong, how to fix it, and it shows an example of a request that works. The `message` reads like a note from a person, so an agent can act on it instead of guessing. Errors that come from the provider pass straight through with their status and body intact, so you always see the real cause. For instance, a duration on a `claude-*` model returns `400 flex_unsupported_for_anthropic` with a message that tells you to drop the duration or switch to `default`, `priority`, or `auto`.
151
+ Every FlexInference error tells you four things. It says what went wrong, why it went wrong, how to fix it, and it shows an example of a request that works. The `message` reads like a note from a person, so an agent can act on it instead of guessing. Provider errors are reshaped into the same surface envelope (and normalized into `FlexInferenceError`), so you get one consistent error type no matter which model ran. For instance, a duration on a `claude-*` model returns `400 flex_unsupported_for_anthropic` with a message that tells you to drop the duration or switch to `default`, `priority`, or `auto`.
142
152
 
143
153
  ## Billing / 402
144
154
 
@@ -169,12 +179,16 @@ Because `PaymentRequiredError extends FlexInferenceError`, existing
169
179
 
170
180
  ## Configuration
171
181
 
172
- | Option | Default | Description |
173
- | --------- | ---------------------------------- | ------------------------------------------------ |
174
- | `apiKey` | - | Your `flex_live_` key (required). |
175
- | `baseURL` | `https://api.flexinference.com/v1` | Override the router endpoint. |
176
- | `fetch` | global `fetch` | Provide a custom fetch implementation. |
177
- | `timeout` | `600000` | Total request budget in ms (connect capped 10s). |
182
+ | Option | Default | Description |
183
+ | ------------------ | ---------------------------------- | ------------------------------------------------------------------------------- |
184
+ | `apiKey` | - | Your `flex_live_` key (required). |
185
+ | `baseURL` | `https://api.flexinference.com/v1` | Override the router endpoint. |
186
+ | `fetch` | global `fetch` | Provide a custom fetch implementation. |
187
+ | `timeout` | `600000` | Non-streaming total budget in ms (streaming has no total). |
188
+ | `firstByteTimeout` | `60000` | Wait for the first response in ms; auto-raised for a flex `start_within`. |
189
+ | `idleTimeout` | `60000` | Max silence between streamed chunks in ms before the stream is treated as hung. |
190
+
191
+ All three timeouts can also be passed per request (with `signal`) in the second argument.
178
192
 
179
193
  ## License
180
194
 
package/dist/index.d.ts CHANGED
@@ -70,7 +70,7 @@ export declare class FlexInferenceError extends Error {
70
70
  readonly param: string | null | undefined;
71
71
  /** Deep link into the docs for this error's code, when the router supplied one. */
72
72
  readonly docUrl: string | null | undefined;
73
- constructor(status: number, body: Partial<FlexErrorBody> | undefined, fallback: string);
73
+ constructor(status: number, body: unknown, fallback: string);
74
74
  }
75
75
  /**
76
76
  * Thrown on HTTP 402: billing is past due, so billable flex is paused. Free routing
@@ -86,19 +86,41 @@ export interface ClientOptions {
86
86
  /** Override the global fetch (e.g. for tests or non-standard runtimes). */
87
87
  fetch?: typeof fetch;
88
88
  /**
89
- * Total request budget in ms (connect through the full response/stream). Defaults
90
- * to 600000 (10 min). Connect alone is capped at 10s. Raise it for very long streams.
89
+ * Non-streaming wall-clock budget in ms (from send through the full body). Defaults to
90
+ * 600000 (10 min). Streaming requests ignore this: they are governed by `firstByteTimeout`
91
+ * and `idleTimeout` and have no total cap, so a healthy long stream is never cut off.
91
92
  */
92
93
  timeout?: number;
94
+ /**
95
+ * How long to wait in ms for the first response (headers) before the router has sent
96
+ * anything. Defaults to 60000. For a flex `start_within` it is auto-raised to cover the
97
+ * deadline (the router withholds headers during the race), so you rarely set this; raise
98
+ * it for very large non-flex contexts whose first token is slow.
99
+ */
100
+ firstByteTimeout?: number;
101
+ /**
102
+ * Max silence in ms between streamed chunks before the stream is treated as hung and
103
+ * aborted. Defaults to 60000. Streaming requests only.
104
+ */
105
+ idleTimeout?: number;
93
106
  }
94
107
  export interface RequestOptions {
95
108
  signal?: AbortSignal;
109
+ /** Override the client `firstByteTimeout` for this request (ms). */
110
+ firstByteTimeout?: number;
111
+ /** Override the client `idleTimeout` for this streaming request (ms). */
112
+ idleTimeout?: number;
113
+ /** Override the client `timeout` (non-streaming total budget) for this request (ms). */
114
+ timeout?: number;
96
115
  }
97
- interface PostResult {
116
+ interface OpenResult {
98
117
  res: Response;
118
+ controller: AbortController;
99
119
  cleanup: () => void;
120
+ idleMs: number;
121
+ totalMs: number;
100
122
  }
101
- type Post = (path: string, body: unknown, signal: AbortSignal | undefined) => Promise<PostResult>;
123
+ type Send = (path: string, body: unknown, options: RequestOptions | undefined) => Promise<OpenResult>;
102
124
  export declare class FlexInference {
103
125
  readonly responses: Responses;
104
126
  readonly chat: Chat;
@@ -107,13 +129,17 @@ export declare class FlexInference {
107
129
  private readonly apiKey;
108
130
  private readonly baseURL;
109
131
  private readonly fetchImpl;
110
- private readonly timeoutMs;
132
+ private readonly firstByteMs;
133
+ private readonly idleMs;
134
+ private readonly totalMs;
111
135
  constructor(options: ClientOptions);
112
- private post;
136
+ private timeoutsFor;
137
+ private send;
138
+ private open;
113
139
  }
114
140
  declare class Responses {
115
- private readonly post;
116
- constructor(post: Post);
141
+ private readonly send;
142
+ constructor(send: Send);
117
143
  create(body: ResponseCreateParams & {
118
144
  stream?: false | null;
119
145
  }, options?: RequestOptions): Promise<ResponseObject>;
@@ -123,11 +149,11 @@ declare class Responses {
123
149
  }
124
150
  declare class Chat {
125
151
  readonly completions: ChatCompletions;
126
- constructor(post: Post);
152
+ constructor(send: Send);
127
153
  }
128
154
  declare class ChatCompletions {
129
- private readonly post;
130
- constructor(post: Post);
155
+ private readonly send;
156
+ constructor(send: Send);
131
157
  create(body: ChatCompletionCreateParams & {
132
158
  stream?: false | null;
133
159
  }, options?: RequestOptions): Promise<ChatCompletion>;
@@ -136,8 +162,8 @@ declare class ChatCompletions {
136
162
  }, options?: RequestOptions): Promise<AsyncIterable<ChatCompletionChunk>>;
137
163
  }
138
164
  declare class Interactions {
139
- private readonly post;
140
- constructor(post: Post);
165
+ private readonly send;
166
+ constructor(send: Send);
141
167
  create(body: InteractionCreateParams & {
142
168
  stream?: false | null;
143
169
  }, options?: RequestOptions): Promise<InteractionObject>;
@@ -146,8 +172,8 @@ declare class Interactions {
146
172
  }, options?: RequestOptions): Promise<AsyncIterable<InteractionStreamEvent>>;
147
173
  }
148
174
  declare class Messages {
149
- private readonly post;
150
- constructor(post: Post);
175
+ private readonly send;
176
+ constructor(send: Send);
151
177
  create(body: MessageCreateParams & {
152
178
  stream?: false | null;
153
179
  }, options?: RequestOptions): Promise<MessageObject>;
package/dist/index.js CHANGED
@@ -92,15 +92,26 @@ export class FlexInferenceError extends Error {
92
92
  param;
93
93
  /** Deep link into the docs for this error's code, when the router supplied one. */
94
94
  docUrl;
95
+ // The router shapes errors by the endpoint you called, so `body` may be the OpenAI
96
+ // envelope (responses/chat), the Anthropic one (messages: `{type:"error", error:{...}}`),
97
+ // or the Google one (interactions: `{error:{code,message,status}}`). All three nest the
98
+ // detail under `error`, so this parses whichever arrived. `message` is always populated;
99
+ // `type` reads OpenAI/Anthropic `error.type` or falls back to Gemini's `error.status`;
100
+ // `code`/`param`/`doc_url` are OpenAI-only and undefined on the other shapes.
95
101
  constructor(status, body, fallback) {
96
- const err = body?.error;
97
- super(err?.message ?? fallback);
102
+ const err = isRecord(body) && isRecord(body.error) ? body.error : undefined;
103
+ super(typeof err?.message === "string" ? err.message : fallback);
98
104
  this.name = "FlexInferenceError";
99
105
  this.status = status;
100
- this.type = err?.type;
101
- this.code = err?.code;
102
- this.param = err?.param;
103
- this.docUrl = err?.doc_url;
106
+ this.type =
107
+ typeof err?.type === "string"
108
+ ? err.type
109
+ : typeof err?.status === "string"
110
+ ? err.status
111
+ : undefined;
112
+ this.code = typeof err?.code === "string" ? err.code : undefined;
113
+ this.param = typeof err?.param === "string" ? err.param : undefined;
114
+ this.docUrl = typeof err?.doc_url === "string" ? err.doc_url : undefined;
104
115
  }
105
116
  }
106
117
  /**
@@ -111,13 +122,28 @@ export class FlexInferenceError extends Error {
111
122
  export class PaymentRequiredError extends FlexInferenceError {
112
123
  }
113
124
  const DEFAULT_BASE_URL = "https://api.flexinference.com/v1";
114
- const DEFAULT_TIMEOUT_MS = 600_000; // 10 min total budget, matching the router's generosity
115
- const CONNECT_TIMEOUT_MS = 10_000; // fail fast if the router is unreachable
125
+ // A streaming request is governed by two clocks and no total: how long we wait for the
126
+ // first response (headers), and the maximum silence between chunks once it is flowing. A
127
+ // healthy long generation is allowed to run as long as tokens keep arriving. A
128
+ // non-streaming request has no stream to idle-watch, so it keeps a total wall-clock budget
129
+ // instead.
130
+ const DEFAULT_FIRST_BYTE_MS = 60_000; // wait for the first response; auto-raised for a flex start_within
131
+ const FLEX_FIRST_BYTE_MARGIN_MS = 30_000; // headroom over the flex deadline for the fallback-to-standard path
132
+ const DEFAULT_IDLE_MS = 60_000; // max silence between streamed chunks before the stream is treated as hung
133
+ const DEFAULT_TOTAL_MS = 600_000; // non-streaming wall-clock budget (streaming has none)
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;
116
139
  const MIN_DEADLINE_SECONDS = 5;
117
140
  const MAX_DEADLINE_SECONDS = 600;
118
141
  const SECONDS_PER_HOUR = 3600;
119
142
  const SECONDS_PER_MINUTE = 60;
120
- 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$/;
121
147
  const START_WITHIN_VALUES = '"default", "priority", "auto", or a duration "HHh-MMm-SSs"';
122
148
  // Runtime guard so plain-JS callers (who get no compile-time check) still fail
123
149
  // locally with a clear message instead of a provider round-trip. TypeScript callers
@@ -138,13 +164,61 @@ function assertStartWithin(body) {
138
164
  const match = DURATION_RE.exec(value);
139
165
  if (match === null) {
140
166
  throw new Error(`FlexInference: \`start_within\` "${value}" is not ${START_WITHIN_VALUES}. ` +
141
- `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.`);
142
168
  }
143
169
  const seconds = Number(match[1]) * SECONDS_PER_HOUR + Number(match[2]) * SECONDS_PER_MINUTE + Number(match[3]);
144
170
  if (seconds < MIN_DEADLINE_SECONDS || seconds > MAX_DEADLINE_SECONDS) {
145
171
  throw new Error("FlexInference: `start_within` duration must be between 5s and 10m.");
146
172
  }
147
173
  }
174
+ // Distinct abort reasons so an aborted fetch/read can be told apart: a timeout we fired
175
+ // (turned into a clear message) versus the caller's own AbortSignal (propagated as-is).
176
+ const FIRST_BYTE_TIMEOUT = Symbol("first-byte-timeout");
177
+ const IDLE_TIMEOUT = Symbol("idle-timeout");
178
+ const TOTAL_TIMEOUT = Symbol("total-timeout");
179
+ /**
180
+ * Seconds for a flex-duration `start_within`, or null for a tier value ("default" etc.),
181
+ * a missing field, or a malformed one. Used to auto-size the first-byte wait: the router
182
+ * withholds response headers until the flex race commits or falls back, so the first-byte
183
+ * clock must cover the whole deadline plus the fallback path.
184
+ */
185
+ function flexDeadlineSeconds(body) {
186
+ const value = typeof body === "object" && body !== null
187
+ ? body["start_within"]
188
+ : undefined;
189
+ if (typeof value !== "string")
190
+ return null;
191
+ const match = DURATION_RE.exec(value);
192
+ if (match === null)
193
+ return null;
194
+ return (Number(match[1]) * SECONDS_PER_HOUR + Number(match[2]) * SECONDS_PER_MINUTE + Number(match[3]));
195
+ }
196
+ // Read a non-streamed JSON body under the total wall-clock budget, then release the timer
197
+ // and the abort listener. Non-streaming only: a stream is drained by streamSSE instead.
198
+ async function readJson(open) {
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.
203
+ const totalTimer = setTimeout(() => {
204
+ controller.abort(TOTAL_TIMEOUT);
205
+ }, totalMs);
206
+ try {
207
+ return (await res.json());
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
+ }
217
+ finally {
218
+ clearTimeout(totalTimer);
219
+ cleanup();
220
+ }
221
+ }
148
222
  export class FlexInference {
149
223
  responses;
150
224
  chat;
@@ -153,28 +227,54 @@ export class FlexInference {
153
227
  apiKey;
154
228
  baseURL;
155
229
  fetchImpl;
156
- timeoutMs;
230
+ firstByteMs;
231
+ idleMs;
232
+ totalMs;
157
233
  constructor(options) {
158
234
  if (!options.apiKey)
159
235
  throw new Error("FlexInference: `apiKey` is required.");
160
236
  this.apiKey = options.apiKey;
161
237
  this.baseURL = (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
162
- this.timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
238
+ this.firstByteMs = options.firstByteTimeout ?? DEFAULT_FIRST_BYTE_MS;
239
+ this.idleMs = options.idleTimeout ?? DEFAULT_IDLE_MS;
240
+ this.totalMs = options.timeout ?? DEFAULT_TOTAL_MS;
163
241
  const f = options.fetch ?? globalThis.fetch;
164
242
  if (typeof f !== "function") {
165
243
  throw new Error("FlexInference: no global fetch found; pass `fetch` in options.");
166
244
  }
167
245
  this.fetchImpl = f;
168
- const post = (path, body, signal) => this.post(path, body, signal);
169
- this.responses = new Responses(post);
170
- this.chat = new Chat(post);
171
- this.interactions = new Interactions(post);
172
- this.messages = new Messages(post);
246
+ const send = (path, body, options) => this.send(path, body, options);
247
+ this.responses = new Responses(send);
248
+ this.chat = new Chat(send);
249
+ this.interactions = new Interactions(send);
250
+ this.messages = new Messages(send);
251
+ }
252
+ // Resolve the three per-request clocks. A per-request override always wins. Otherwise the
253
+ // first-byte wait is auto-sized for a flex `start_within`: it can never be shorter than
254
+ // the deadline plus the fallback margin (headers are withheld for the whole race), but a
255
+ // larger client default still wins over a short deadline.
256
+ timeoutsFor(body, options) {
257
+ const deadline = flexDeadlineSeconds(body);
258
+ const autoFirstByte = deadline === null
259
+ ? this.firstByteMs
260
+ : Math.max(this.firstByteMs, deadline * 1000 + FLEX_FIRST_BYTE_MARGIN_MS);
261
+ return {
262
+ firstByteMs: options?.firstByteTimeout ?? autoFirstByte,
263
+ idleMs: options?.idleTimeout ?? this.idleMs,
264
+ totalMs: options?.timeout ?? this.totalMs,
265
+ };
266
+ }
267
+ async send(path, body, options) {
268
+ const { firstByteMs, idleMs, totalMs } = this.timeoutsFor(body, options);
269
+ const { res, controller, cleanup } = await this.open(path, body, options?.signal, firstByteMs);
270
+ return { res, controller, cleanup, idleMs, totalMs };
173
271
  }
174
- async post(path, body, signal) {
175
- // One AbortController fed by three sources: the caller's signal, a 10s connect
176
- // timer (cleared once headers arrive), and the total budget (cleared by the
177
- // consumer once the body/stream is done). No dangling timers after completion.
272
+ // Open the request and return once response headers arrive (or throw on an error status).
273
+ // One AbortController is fed by two sources here: the caller's signal, and a first-byte
274
+ // timer (cleared once headers arrive). The idle timer (streaming) or total timer
275
+ // (non-streaming) is armed by the caller against the returned controller. Distinct abort
276
+ // reasons let a fired timeout be told apart from a caller abort.
277
+ async open(path, body, signal, firstByteMs) {
178
278
  const controller = new AbortController();
179
279
  const onCallerAbort = () => {
180
280
  controller.abort(signal?.reason);
@@ -185,17 +285,12 @@ export class FlexInference {
185
285
  else
186
286
  signal.addEventListener("abort", onCallerAbort, { once: true });
187
287
  }
188
- const connectTimer = setTimeout(() => {
189
- controller.abort();
190
- }, CONNECT_TIMEOUT_MS);
191
- const totalTimer = setTimeout(() => {
192
- controller.abort();
193
- }, this.timeoutMs);
194
288
  const cleanup = () => {
195
- clearTimeout(connectTimer);
196
- clearTimeout(totalTimer);
197
289
  signal?.removeEventListener("abort", onCallerAbort);
198
290
  };
291
+ const firstByteTimer = setTimeout(() => {
292
+ controller.abort(FIRST_BYTE_TIMEOUT);
293
+ }, firstByteMs);
199
294
  let res;
200
295
  try {
201
296
  res = await this.fetchImpl(`${this.baseURL}${path}`, {
@@ -210,24 +305,30 @@ export class FlexInference {
210
305
  });
211
306
  }
212
307
  catch (err) {
308
+ clearTimeout(firstByteTimer);
213
309
  cleanup();
214
- if (controller.signal.aborted && signal?.aborted !== true) {
215
- throw new Error(`FlexInference: request to the router timed out (connect ${String(CONNECT_TIMEOUT_MS)}ms / total ${String(this.timeoutMs)}ms).`);
310
+ if (controller.signal.reason === FIRST_BYTE_TIMEOUT) {
311
+ throw new Error(`FlexInference: no response from the router within ${String(firstByteMs)}ms ` +
312
+ "(time to first byte). Raise `firstByteTimeout` for very large contexts or long " +
313
+ "`start_within` deadlines.");
216
314
  }
217
315
  throw err;
218
316
  }
219
- clearTimeout(connectTimer); // headers arrived; only the total budget governs the body now
317
+ clearTimeout(firstByteTimer); // headers arrived
220
318
  if (!res.ok) {
221
- // Read the error body under the same timeout, then clear the timers. Clearing
222
- // before the read would leave a stalled error body with no budget to abort it.
319
+ // Read the error body under a bounded budget, then release the abort listener.
320
+ const errTimer = setTimeout(() => {
321
+ controller.abort();
322
+ }, ERROR_BODY_READ_MS);
223
323
  let parsed;
224
324
  try {
225
- parsed = (await res.json());
325
+ parsed = await res.json();
226
326
  }
227
327
  catch {
228
328
  parsed = undefined;
229
329
  }
230
330
  finally {
331
+ clearTimeout(errTimer);
231
332
  cleanup();
232
333
  }
233
334
  if (res.status === 402) {
@@ -235,92 +336,82 @@ export class FlexInference {
235
336
  }
236
337
  throw new FlexInferenceError(res.status, parsed, `HTTP ${String(res.status)} ${res.statusText}`);
237
338
  }
238
- return { res, cleanup };
339
+ return { res, controller, cleanup };
239
340
  }
240
341
  }
241
342
  class Responses {
242
- post;
243
- constructor(post) {
244
- this.post = post;
343
+ send;
344
+ constructor(send) {
345
+ this.send = send;
245
346
  }
246
347
  async create(body, options) {
247
348
  assertStartWithin(body);
248
- const { res, cleanup } = await this.post("/responses", body, options?.signal);
249
- if (body.stream) {
250
- return streamSSE(res, cleanup);
251
- }
252
- try {
253
- return (await res.json());
254
- }
255
- finally {
256
- cleanup();
257
- }
349
+ const open = await this.send("/responses", body, options);
350
+ if (body.stream)
351
+ return streamSSE(open);
352
+ return readJson(open);
258
353
  }
259
354
  }
260
355
  class Chat {
261
356
  completions;
262
- constructor(post) {
263
- this.completions = new ChatCompletions(post);
357
+ constructor(send) {
358
+ this.completions = new ChatCompletions(send);
264
359
  }
265
360
  }
266
361
  class ChatCompletions {
267
- post;
268
- constructor(post) {
269
- this.post = post;
362
+ send;
363
+ constructor(send) {
364
+ this.send = send;
270
365
  }
271
366
  async create(body, options) {
272
367
  assertStartWithin(body);
273
- const { res, cleanup } = await this.post("/chat/completions", body, options?.signal);
274
- if (body.stream) {
275
- return streamSSE(res, cleanup);
276
- }
277
- try {
278
- return (await res.json());
279
- }
280
- finally {
281
- cleanup();
282
- }
368
+ const open = await this.send("/chat/completions", body, options);
369
+ if (body.stream)
370
+ return streamSSE(open);
371
+ return readJson(open);
283
372
  }
284
373
  }
285
374
  class Interactions {
286
- post;
287
- constructor(post) {
288
- this.post = post;
375
+ send;
376
+ constructor(send) {
377
+ this.send = send;
289
378
  }
290
379
  async create(body, options) {
291
380
  assertStartWithin(body);
292
- const { res, cleanup } = await this.post("/interactions", body, options?.signal);
293
- if (body.stream) {
294
- return streamSSE(res, cleanup);
295
- }
296
- try {
297
- return (await res.json());
298
- }
299
- finally {
300
- cleanup();
301
- }
381
+ const open = await this.send("/interactions", body, options);
382
+ if (body.stream)
383
+ return streamSSE(open);
384
+ return readJson(open);
302
385
  }
303
386
  }
304
387
  class Messages {
305
- post;
306
- constructor(post) {
307
- this.post = post;
388
+ send;
389
+ constructor(send) {
390
+ this.send = send;
308
391
  }
309
392
  async create(body, options) {
310
393
  assertStartWithin(body);
311
- const { res, cleanup } = await this.post("/messages", body, options?.signal);
312
- if (body.stream) {
313
- return streamSSE(res, cleanup);
314
- }
315
- try {
316
- return (await res.json());
317
- }
318
- finally {
319
- cleanup();
320
- }
394
+ const open = await this.send("/messages", body, options);
395
+ if (body.stream)
396
+ return streamSSE(open);
397
+ return readJson(open);
398
+ }
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(/^ /, ""));
321
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) };
322
412
  }
323
- async function* streamSSE(res, cleanup) {
413
+ async function* streamSSE(open) {
414
+ const { res, controller, idleMs, cleanup } = open;
324
415
  if (!res.body) {
325
416
  cleanup();
326
417
  throw new Error("FlexInference: streaming response has no body.");
@@ -331,9 +422,39 @@ async function* streamSSE(res, cleanup) {
331
422
  let exhausted = false;
332
423
  try {
333
424
  for (;;) {
334
- const { done, value } = await reader.read();
425
+ // Idle timeout: reset on every chunk. Fires only when the stream goes silent for
426
+ // idleMs, aborting the underlying fetch so a hung upstream can't pin the stream. A
427
+ // healthy stream (chunks flowing) keeps resetting it, so a long generation is fine.
428
+ const idleTimer = setTimeout(() => {
429
+ controller.abort(IDLE_TIMEOUT);
430
+ }, idleMs);
431
+ let chunk;
432
+ try {
433
+ chunk = await reader.read();
434
+ }
435
+ catch (err) {
436
+ if (controller.signal.reason === IDLE_TIMEOUT) {
437
+ throw new Error(`FlexInference: stream stalled - no data for ${String(idleMs)}ms (idle timeout).`);
438
+ }
439
+ throw err;
440
+ }
441
+ finally {
442
+ clearTimeout(idleTimer);
443
+ }
444
+ const { done, value } = chunk;
335
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.
336
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
+ }
337
458
  break;
338
459
  }
339
460
  buffer += decoder.decode(value, { stream: true }).replace(/\r/g, "");
@@ -341,19 +462,21 @@ async function* streamSSE(res, cleanup) {
341
462
  while ((sep = buffer.indexOf("\n\n")) !== -1) {
342
463
  const frame = buffer.slice(0, sep);
343
464
  buffer = buffer.slice(sep + 2);
344
- const dataLines = [];
345
- for (const line of frame.split("\n")) {
346
- if (line.startsWith("data:"))
347
- dataLines.push(line.slice(5).replace(/^ /, ""));
348
- }
349
- if (dataLines.length === 0)
465
+ const parsed = parseSSEFrame(frame);
466
+ if (parsed === null)
350
467
  continue;
351
- const data = dataLines.join("\n");
352
- if (data === "[DONE]") {
468
+ if (parsed.kind === "done") {
353
469
  exhausted = true;
354
470
  return;
355
471
  }
356
- 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.");
357
480
  }
358
481
  }
359
482
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexinference",
3
- "version": "1.4.1",
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 .",