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 +28 -0
- package/README.md +26 -12
- package/dist/index.d.ts +42 -16
- package/dist/index.js +224 -101
- package/package.json +2 -2
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
173
|
-
|
|
|
174
|
-
| `apiKey`
|
|
175
|
-
| `baseURL`
|
|
176
|
-
| `fetch`
|
|
177
|
-
| `timeout`
|
|
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:
|
|
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
|
-
*
|
|
90
|
-
*
|
|
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
|
|
116
|
+
interface OpenResult {
|
|
98
117
|
res: Response;
|
|
118
|
+
controller: AbortController;
|
|
99
119
|
cleanup: () => void;
|
|
120
|
+
idleMs: number;
|
|
121
|
+
totalMs: number;
|
|
100
122
|
}
|
|
101
|
-
type
|
|
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
|
|
132
|
+
private readonly firstByteMs;
|
|
133
|
+
private readonly idleMs;
|
|
134
|
+
private readonly totalMs;
|
|
111
135
|
constructor(options: ClientOptions);
|
|
112
|
-
private
|
|
136
|
+
private timeoutsFor;
|
|
137
|
+
private send;
|
|
138
|
+
private open;
|
|
113
139
|
}
|
|
114
140
|
declare class Responses {
|
|
115
|
-
private readonly
|
|
116
|
-
constructor(
|
|
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(
|
|
152
|
+
constructor(send: Send);
|
|
127
153
|
}
|
|
128
154
|
declare class ChatCompletions {
|
|
129
|
-
private readonly
|
|
130
|
-
constructor(
|
|
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
|
|
140
|
-
constructor(
|
|
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
|
|
150
|
-
constructor(
|
|
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
|
|
97
|
-
super(err?.message
|
|
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 =
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
169
|
-
this.responses = new Responses(
|
|
170
|
-
this.chat = new Chat(
|
|
171
|
-
this.interactions = new Interactions(
|
|
172
|
-
this.messages = new Messages(
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
215
|
-
throw new Error(`FlexInference:
|
|
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(
|
|
317
|
+
clearTimeout(firstByteTimer); // headers arrived
|
|
220
318
|
if (!res.ok) {
|
|
221
|
-
// Read the error body under
|
|
222
|
-
|
|
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 =
|
|
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
|
-
|
|
243
|
-
constructor(
|
|
244
|
-
this.
|
|
343
|
+
send;
|
|
344
|
+
constructor(send) {
|
|
345
|
+
this.send = send;
|
|
245
346
|
}
|
|
246
347
|
async create(body, options) {
|
|
247
348
|
assertStartWithin(body);
|
|
248
|
-
const
|
|
249
|
-
if (body.stream)
|
|
250
|
-
return streamSSE(
|
|
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(
|
|
263
|
-
this.completions = new ChatCompletions(
|
|
357
|
+
constructor(send) {
|
|
358
|
+
this.completions = new ChatCompletions(send);
|
|
264
359
|
}
|
|
265
360
|
}
|
|
266
361
|
class ChatCompletions {
|
|
267
|
-
|
|
268
|
-
constructor(
|
|
269
|
-
this.
|
|
362
|
+
send;
|
|
363
|
+
constructor(send) {
|
|
364
|
+
this.send = send;
|
|
270
365
|
}
|
|
271
366
|
async create(body, options) {
|
|
272
367
|
assertStartWithin(body);
|
|
273
|
-
const
|
|
274
|
-
if (body.stream)
|
|
275
|
-
return streamSSE(
|
|
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
|
-
|
|
287
|
-
constructor(
|
|
288
|
-
this.
|
|
375
|
+
send;
|
|
376
|
+
constructor(send) {
|
|
377
|
+
this.send = send;
|
|
289
378
|
}
|
|
290
379
|
async create(body, options) {
|
|
291
380
|
assertStartWithin(body);
|
|
292
|
-
const
|
|
293
|
-
if (body.stream)
|
|
294
|
-
return streamSSE(
|
|
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
|
-
|
|
306
|
-
constructor(
|
|
307
|
-
this.
|
|
388
|
+
send;
|
|
389
|
+
constructor(send) {
|
|
390
|
+
this.send = send;
|
|
308
391
|
}
|
|
309
392
|
async create(body, options) {
|
|
310
393
|
assertStartWithin(body);
|
|
311
|
-
const
|
|
312
|
-
if (body.stream)
|
|
313
|
-
return streamSSE(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
345
|
-
|
|
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
|
-
|
|
352
|
-
if (data === "[DONE]") {
|
|
468
|
+
if (parsed.kind === "done") {
|
|
353
469
|
exhausted = true;
|
|
354
470
|
return;
|
|
355
471
|
}
|
|
356
|
-
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.");
|
|
357
480
|
}
|
|
358
481
|
}
|
|
359
482
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexinference",
|
|
3
|
-
"version": "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 .",
|