flexinference 1.4.1 → 1.5.0

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0
4
+
5
+ Reworks request timeouts so a healthy stream is never cut off yet a hung request never hangs
6
+ forever. Streaming requests now have **no total cap** -- they run as long as tokens keep
7
+ arriving -- and are bounded instead by two new options: `firstByteTimeout` (how long to wait
8
+ for the first response, default `60000` ms) and `idleTimeout` (max silence between chunks
9
+ before the stream is treated as hung, default `60000` ms). Non-streaming requests keep the
10
+ total `timeout` (default `600000` ms). The first-byte wait is **auto-raised for a flex
11
+ `start_within`**, fixing flex requests whose deadline exceeded the old hardcoded 10s connect
12
+ cap and aborted before the router replied. All three timeouts are configurable on the client
13
+ and per request. Behavior change, no API breaks: `timeout` no longer caps a stream, and the
14
+ old 10s first-response cap is now 60s (auto-sized for flex).
15
+
16
+ Also teaches `FlexInferenceError` to parse the router's per-surface error shapes: the router now
17
+ returns errors in the dialect of the endpoint you called (OpenAI on responses/chat, Anthropic on
18
+ `messages`, Google on `interactions`), and the error class reads all three (`message` always,
19
+ `type` from the OpenAI/Anthropic `type` or Gemini `status`).
20
+
3
21
  ## 1.4.1
4
22
 
5
23
  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,8 +122,16 @@ 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
116
135
  const MIN_DEADLINE_SECONDS = 5;
117
136
  const MAX_DEADLINE_SECONDS = 600;
118
137
  const SECONDS_PER_HOUR = 3600;
@@ -145,6 +164,42 @@ function assertStartWithin(body) {
145
164
  throw new Error("FlexInference: `start_within` duration must be between 5s and 10m.");
146
165
  }
147
166
  }
167
+ // Distinct abort reasons so an aborted fetch/read can be told apart: a timeout we fired
168
+ // (turned into a clear message) versus the caller's own AbortSignal (propagated as-is).
169
+ const FIRST_BYTE_TIMEOUT = Symbol("first-byte-timeout");
170
+ const IDLE_TIMEOUT = Symbol("idle-timeout");
171
+ /**
172
+ * Seconds for a flex-duration `start_within`, or null for a tier value ("default" etc.),
173
+ * a missing field, or a malformed one. Used to auto-size the first-byte wait: the router
174
+ * withholds response headers until the flex race commits or falls back, so the first-byte
175
+ * clock must cover the whole deadline plus the fallback path.
176
+ */
177
+ function flexDeadlineSeconds(body) {
178
+ const value = typeof body === "object" && body !== null
179
+ ? body["start_within"]
180
+ : undefined;
181
+ if (typeof value !== "string")
182
+ return null;
183
+ const match = DURATION_RE.exec(value);
184
+ if (match === null)
185
+ return null;
186
+ return (Number(match[1]) * SECONDS_PER_HOUR + Number(match[2]) * SECONDS_PER_MINUTE + Number(match[3]));
187
+ }
188
+ // Read a non-streamed JSON body under the total wall-clock budget, then release the timer
189
+ // and the abort listener. Non-streaming only: a stream is drained by streamSSE instead.
190
+ async function readJson(open) {
191
+ const { res, controller, cleanup, totalMs } = open;
192
+ const totalTimer = setTimeout(() => {
193
+ controller.abort();
194
+ }, totalMs);
195
+ try {
196
+ return (await res.json());
197
+ }
198
+ finally {
199
+ clearTimeout(totalTimer);
200
+ cleanup();
201
+ }
202
+ }
148
203
  export class FlexInference {
149
204
  responses;
150
205
  chat;
@@ -153,28 +208,54 @@ export class FlexInference {
153
208
  apiKey;
154
209
  baseURL;
155
210
  fetchImpl;
156
- timeoutMs;
211
+ firstByteMs;
212
+ idleMs;
213
+ totalMs;
157
214
  constructor(options) {
158
215
  if (!options.apiKey)
159
216
  throw new Error("FlexInference: `apiKey` is required.");
160
217
  this.apiKey = options.apiKey;
161
218
  this.baseURL = (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
162
- this.timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
219
+ this.firstByteMs = options.firstByteTimeout ?? DEFAULT_FIRST_BYTE_MS;
220
+ this.idleMs = options.idleTimeout ?? DEFAULT_IDLE_MS;
221
+ this.totalMs = options.timeout ?? DEFAULT_TOTAL_MS;
163
222
  const f = options.fetch ?? globalThis.fetch;
164
223
  if (typeof f !== "function") {
165
224
  throw new Error("FlexInference: no global fetch found; pass `fetch` in options.");
166
225
  }
167
226
  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);
227
+ const send = (path, body, options) => this.send(path, body, options);
228
+ this.responses = new Responses(send);
229
+ this.chat = new Chat(send);
230
+ this.interactions = new Interactions(send);
231
+ this.messages = new Messages(send);
232
+ }
233
+ // Resolve the three per-request clocks. A per-request override always wins. Otherwise the
234
+ // first-byte wait is auto-sized for a flex `start_within`: it can never be shorter than
235
+ // the deadline plus the fallback margin (headers are withheld for the whole race), but a
236
+ // larger client default still wins over a short deadline.
237
+ timeoutsFor(body, options) {
238
+ const deadline = flexDeadlineSeconds(body);
239
+ const autoFirstByte = deadline === null
240
+ ? this.firstByteMs
241
+ : Math.max(this.firstByteMs, deadline * 1000 + FLEX_FIRST_BYTE_MARGIN_MS);
242
+ return {
243
+ firstByteMs: options?.firstByteTimeout ?? autoFirstByte,
244
+ idleMs: options?.idleTimeout ?? this.idleMs,
245
+ totalMs: options?.timeout ?? this.totalMs,
246
+ };
247
+ }
248
+ async send(path, body, options) {
249
+ const { firstByteMs, idleMs, totalMs } = this.timeoutsFor(body, options);
250
+ const { res, controller, cleanup } = await this.open(path, body, options?.signal, firstByteMs);
251
+ return { res, controller, cleanup, idleMs, totalMs };
173
252
  }
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.
253
+ // Open the request and return once response headers arrive (or throw on an error status).
254
+ // One AbortController is fed by two sources here: the caller's signal, and a first-byte
255
+ // timer (cleared once headers arrive). The idle timer (streaming) or total timer
256
+ // (non-streaming) is armed by the caller against the returned controller. Distinct abort
257
+ // reasons let a fired timeout be told apart from a caller abort.
258
+ async open(path, body, signal, firstByteMs) {
178
259
  const controller = new AbortController();
179
260
  const onCallerAbort = () => {
180
261
  controller.abort(signal?.reason);
@@ -185,17 +266,12 @@ export class FlexInference {
185
266
  else
186
267
  signal.addEventListener("abort", onCallerAbort, { once: true });
187
268
  }
188
- const connectTimer = setTimeout(() => {
189
- controller.abort();
190
- }, CONNECT_TIMEOUT_MS);
191
- const totalTimer = setTimeout(() => {
192
- controller.abort();
193
- }, this.timeoutMs);
194
269
  const cleanup = () => {
195
- clearTimeout(connectTimer);
196
- clearTimeout(totalTimer);
197
270
  signal?.removeEventListener("abort", onCallerAbort);
198
271
  };
272
+ const firstByteTimer = setTimeout(() => {
273
+ controller.abort(FIRST_BYTE_TIMEOUT);
274
+ }, firstByteMs);
199
275
  let res;
200
276
  try {
201
277
  res = await this.fetchImpl(`${this.baseURL}${path}`, {
@@ -210,24 +286,30 @@ export class FlexInference {
210
286
  });
211
287
  }
212
288
  catch (err) {
289
+ clearTimeout(firstByteTimer);
213
290
  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).`);
291
+ if (controller.signal.reason === FIRST_BYTE_TIMEOUT) {
292
+ throw new Error(`FlexInference: no response from the router within ${String(firstByteMs)}ms ` +
293
+ "(time to first byte). Raise `firstByteTimeout` for very large contexts or long " +
294
+ "`start_within` deadlines.");
216
295
  }
217
296
  throw err;
218
297
  }
219
- clearTimeout(connectTimer); // headers arrived; only the total budget governs the body now
298
+ clearTimeout(firstByteTimer); // headers arrived
220
299
  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.
300
+ // Read the error body under a bounded budget, then release the abort listener.
301
+ const errTimer = setTimeout(() => {
302
+ controller.abort();
303
+ }, ERROR_BODY_READ_MS);
223
304
  let parsed;
224
305
  try {
225
- parsed = (await res.json());
306
+ parsed = await res.json();
226
307
  }
227
308
  catch {
228
309
  parsed = undefined;
229
310
  }
230
311
  finally {
312
+ clearTimeout(errTimer);
231
313
  cleanup();
232
314
  }
233
315
  if (res.status === 402) {
@@ -235,92 +317,69 @@ export class FlexInference {
235
317
  }
236
318
  throw new FlexInferenceError(res.status, parsed, `HTTP ${String(res.status)} ${res.statusText}`);
237
319
  }
238
- return { res, cleanup };
320
+ return { res, controller, cleanup };
239
321
  }
240
322
  }
241
323
  class Responses {
242
- post;
243
- constructor(post) {
244
- this.post = post;
324
+ send;
325
+ constructor(send) {
326
+ this.send = send;
245
327
  }
246
328
  async create(body, options) {
247
329
  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
- }
330
+ const open = await this.send("/responses", body, options);
331
+ if (body.stream)
332
+ return streamSSE(open);
333
+ return readJson(open);
258
334
  }
259
335
  }
260
336
  class Chat {
261
337
  completions;
262
- constructor(post) {
263
- this.completions = new ChatCompletions(post);
338
+ constructor(send) {
339
+ this.completions = new ChatCompletions(send);
264
340
  }
265
341
  }
266
342
  class ChatCompletions {
267
- post;
268
- constructor(post) {
269
- this.post = post;
343
+ send;
344
+ constructor(send) {
345
+ this.send = send;
270
346
  }
271
347
  async create(body, options) {
272
348
  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
- }
349
+ const open = await this.send("/chat/completions", body, options);
350
+ if (body.stream)
351
+ return streamSSE(open);
352
+ return readJson(open);
283
353
  }
284
354
  }
285
355
  class Interactions {
286
- post;
287
- constructor(post) {
288
- this.post = post;
356
+ send;
357
+ constructor(send) {
358
+ this.send = send;
289
359
  }
290
360
  async create(body, options) {
291
361
  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
- }
362
+ const open = await this.send("/interactions", body, options);
363
+ if (body.stream)
364
+ return streamSSE(open);
365
+ return readJson(open);
302
366
  }
303
367
  }
304
368
  class Messages {
305
- post;
306
- constructor(post) {
307
- this.post = post;
369
+ send;
370
+ constructor(send) {
371
+ this.send = send;
308
372
  }
309
373
  async create(body, options) {
310
374
  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
- }
375
+ const open = await this.send("/messages", body, options);
376
+ if (body.stream)
377
+ return streamSSE(open);
378
+ return readJson(open);
321
379
  }
322
380
  }
323
- async function* streamSSE(res, cleanup) {
381
+ async function* streamSSE(open) {
382
+ const { res, controller, idleMs, cleanup } = open;
324
383
  if (!res.body) {
325
384
  cleanup();
326
385
  throw new Error("FlexInference: streaming response has no body.");
@@ -331,7 +390,26 @@ async function* streamSSE(res, cleanup) {
331
390
  let exhausted = false;
332
391
  try {
333
392
  for (;;) {
334
- const { done, value } = await reader.read();
393
+ // Idle timeout: reset on every chunk. Fires only when the stream goes silent for
394
+ // idleMs, aborting the underlying fetch so a hung upstream can't pin the stream. A
395
+ // healthy stream (chunks flowing) keeps resetting it, so a long generation is fine.
396
+ const idleTimer = setTimeout(() => {
397
+ controller.abort(IDLE_TIMEOUT);
398
+ }, idleMs);
399
+ let chunk;
400
+ try {
401
+ chunk = await reader.read();
402
+ }
403
+ catch (err) {
404
+ if (controller.signal.reason === IDLE_TIMEOUT) {
405
+ throw new Error(`FlexInference: stream stalled - no data for ${String(idleMs)}ms (idle timeout).`);
406
+ }
407
+ throw err;
408
+ }
409
+ finally {
410
+ clearTimeout(idleTimer);
411
+ }
412
+ const { done, value } = chunk;
335
413
  if (done) {
336
414
  exhausted = true;
337
415
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexinference",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
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>",