flexinference 1.4.0 → 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 +22 -0
- package/README.md +41 -21
- package/dist/index.d.ts +42 -16
- package/dist/index.js +168 -90
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
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
|
+
|
|
21
|
+
## 1.4.1
|
|
22
|
+
|
|
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.
|
|
24
|
+
|
|
3
25
|
## 1.4.0
|
|
4
26
|
|
|
5
27
|
Surfaces the router's **`doc_url`** on `FlexInferenceError` as a new `docUrl` field: a deep
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# FlexInference (TypeScript)
|
|
2
2
|
|
|
3
|
-
The official TypeScript SDK for [FlexInference](https://flexinference.com)
|
|
3
|
+
The official TypeScript SDK for [FlexInference](https://flexinference.com), an inference router that works with OpenAI, Google Gemini, and Anthropic. Send the OpenAI requests you already send and bring your own provider key. Set one required field, `start_within`, to tell us how long you will wait, and we trade a little latency for a smaller bill. You can call it four ways. Use `responses`, `chat.completions`, `interactions` (the Gemini shape), or `messages` (the Anthropic shape). Any of them reaches any provider.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install flexinference
|
|
@@ -22,21 +22,23 @@ const res = await client.responses.create({
|
|
|
22
22
|
console.log(outputText(res));
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Responses come back as the **raw OpenAI JSON** (we never reshape the body), so there is no `output_text` field on the wire
|
|
25
|
+
Responses come back as the **raw OpenAI JSON** (we never reshape the body), so there is no `output_text` field on the wire. OpenAI's own SDKs compute that. `outputText(res)` pulls the assistant's text out of either a response or a chat completion for you.
|
|
26
26
|
|
|
27
|
-
`start_within` is
|
|
27
|
+
`start_within` is required on every request. It takes `"default"`, `"priority"`, `"auto"`, or a duration in the form `"HHh-MMm-SSs"` from 5 seconds to 10 minutes. A duration tells us how long you will wait for the request to start. We try the cheaper flex tier first and give it that long to begin. If flex cannot start in time, we move your request up to your standard tier so it still finishes. The words `"default"`, `"priority"`, and `"auto"` map to those OpenAI service tiers and proxy any model. Types catch most mistakes for you. A missing value, or a value that does not fit the allowed shapes, fails at compile time. A duration that fits the shape but is malformed or out of range fails at runtime, the same check plain-JavaScript callers get. See the [docs](https://flexinference.mintlify.app/deadline-routing) for the full rules.
|
|
28
|
+
|
|
29
|
+
This runs the opposite way from the usual fallback. Most setups start on the strong model and drop to a cheaper one when they need to. We start on the cheaper flex tier and escalate up to your standard model if flex cannot start in time, so a slow flex tier never costs you the answer.
|
|
28
30
|
|
|
29
31
|
## Providers (OpenAI, Gemini, and Anthropic)
|
|
30
32
|
|
|
31
|
-
FlexInference routes to **OpenAI**, **Google Gemini**, and **Anthropic**. Send the same OpenAI-shaped request and pass whichever model id you want
|
|
33
|
+
FlexInference routes to **OpenAI**, **Google Gemini**, and **Anthropic**. Send the same OpenAI-shaped request and pass whichever model id you want. Use `gpt-5.5`, `o4-mini`, `gemini-3.5-flash`, `claude-opus-4-8`, and so on. We translate Gemini and Anthropic to and from the OpenAI shape, so your code is identical for all three.
|
|
32
34
|
|
|
33
|
-
- **OpenAI:** `default` (standard tier), `priority`, `auto`, and
|
|
34
|
-
- **Gemini:** `default` maps to Gemini's
|
|
35
|
-
- **Anthropic (Claude):** proxy-only. `default`, `priority`, and `auto` work; there is **no flex
|
|
35
|
+
- **OpenAI:** `default` (the standard tier), `priority`, `auto`, and flex on flex-capable models. You reach flex by passing a duration.
|
|
36
|
+
- **Gemini:** `default` maps to Gemini's standard tier. You also get `priority` and flex on the Gemini flex models (`gemini-3.5-flash`, `gemini-3.1-flash-lite`, `gemini-3.1-pro-preview`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`). Gemini has no `auto` tier, so `start_within: "auto"` on a Gemini model returns `400`.
|
|
37
|
+
- **Anthropic (Claude):** proxy-only. `default`, `priority`, and `auto` work; there is **no flex tier**, so a duration `start_within` on a `claude-*` model returns `400 flex_unsupported_for_anthropic`. Anthropic requires a token cap, so set `max_output_tokens` (`max_completion_tokens` on Chat, `max_tokens` on Messages) or you get `400 missing_max_tokens`. You keep the unified API and tier control, and draw down your own Anthropic credits.
|
|
36
38
|
|
|
37
39
|
Add the provider key you'll use (OpenAI, Gemini, and/or Anthropic) in the [dashboard](https://www.flexinference.com/dashboard). Text, streaming, structured outputs, function calling, image input, and web search work across providers (send a Responses `web_search` tool; we map it to Gemini's `google_search`).
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
Do not send `service_tier`. The router sets the tier from `start_within`, and it rejects a caller-supplied `service_tier` with `400 service_tier_not_allowed`.
|
|
40
42
|
|
|
41
43
|
## Streaming
|
|
42
44
|
|
|
@@ -100,19 +102,29 @@ console.log(messageText(res));
|
|
|
100
102
|
|
|
101
103
|
## Timeouts and cancellation
|
|
102
104
|
|
|
103
|
-
|
|
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.
|
|
104
111
|
|
|
105
112
|
```ts
|
|
106
|
-
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
|
+
});
|
|
107
119
|
```
|
|
108
120
|
|
|
109
|
-
|
|
121
|
+
Any of these can be overridden per request, alongside an `AbortSignal` to cancel yourself. Cancelling a stream stops it mid-flight:
|
|
110
122
|
|
|
111
123
|
```ts
|
|
112
124
|
const controller = new AbortController();
|
|
113
125
|
const res = await client.responses.create(
|
|
114
126
|
{ model: "gpt-5.5", input: "Write a haiku about cheap GPUs.", start_within: "priority" },
|
|
115
|
-
{ signal: controller.signal },
|
|
127
|
+
{ signal: controller.signal, idleTimeout: 15_000 },
|
|
116
128
|
);
|
|
117
129
|
// controller.abort() from elsewhere to cancel
|
|
118
130
|
console.log(outputText(res));
|
|
@@ -120,7 +132,7 @@ console.log(outputText(res));
|
|
|
120
132
|
|
|
121
133
|
## Errors
|
|
122
134
|
|
|
123
|
-
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.
|
|
124
136
|
|
|
125
137
|
```ts
|
|
126
138
|
import { FlexInferenceError } from "flexinference";
|
|
@@ -136,10 +148,14 @@ try {
|
|
|
136
148
|
}
|
|
137
149
|
```
|
|
138
150
|
|
|
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`.
|
|
152
|
+
|
|
139
153
|
## Billing / 402
|
|
140
154
|
|
|
141
|
-
If your account's billing is past due, the router pauses
|
|
142
|
-
`402 Payment Required` on
|
|
155
|
+
If your account's billing is past due, the router pauses paid flex requests and returns
|
|
156
|
+
`402 Payment Required` on them. Free routing keeps working, so your standard-tier calls
|
|
157
|
+
still go through. Flex is the only part you ever pay for, and you only pay it when it
|
|
158
|
+
saves you money. The SDK throws a
|
|
143
159
|
typed `PaymentRequiredError` (a subclass of `FlexInferenceError`) for HTTP 402, so you
|
|
144
160
|
can catch it on its own and prompt the user to update payment while letting other errors
|
|
145
161
|
propagate:
|
|
@@ -163,12 +179,16 @@ Because `PaymentRequiredError extends FlexInferenceError`, existing
|
|
|
163
179
|
|
|
164
180
|
## Configuration
|
|
165
181
|
|
|
166
|
-
| Option
|
|
167
|
-
|
|
|
168
|
-
| `apiKey`
|
|
169
|
-
| `baseURL`
|
|
170
|
-
| `fetch`
|
|
171
|
-
| `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.
|
|
172
192
|
|
|
173
193
|
## License
|
|
174
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,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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
169
|
-
this.responses = new Responses(
|
|
170
|
-
this.chat = new Chat(
|
|
171
|
-
this.interactions = new Interactions(
|
|
172
|
-
this.messages = new Messages(
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
215
|
-
throw new Error(`FlexInference:
|
|
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(
|
|
298
|
+
clearTimeout(firstByteTimer); // headers arrived
|
|
220
299
|
if (!res.ok) {
|
|
221
|
-
// Read the error body under
|
|
222
|
-
|
|
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 =
|
|
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
|
-
|
|
243
|
-
constructor(
|
|
244
|
-
this.
|
|
324
|
+
send;
|
|
325
|
+
constructor(send) {
|
|
326
|
+
this.send = send;
|
|
245
327
|
}
|
|
246
328
|
async create(body, options) {
|
|
247
329
|
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
|
-
}
|
|
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(
|
|
263
|
-
this.completions = new ChatCompletions(
|
|
338
|
+
constructor(send) {
|
|
339
|
+
this.completions = new ChatCompletions(send);
|
|
264
340
|
}
|
|
265
341
|
}
|
|
266
342
|
class ChatCompletions {
|
|
267
|
-
|
|
268
|
-
constructor(
|
|
269
|
-
this.
|
|
343
|
+
send;
|
|
344
|
+
constructor(send) {
|
|
345
|
+
this.send = send;
|
|
270
346
|
}
|
|
271
347
|
async create(body, options) {
|
|
272
348
|
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
|
-
}
|
|
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
|
-
|
|
287
|
-
constructor(
|
|
288
|
-
this.
|
|
356
|
+
send;
|
|
357
|
+
constructor(send) {
|
|
358
|
+
this.send = send;
|
|
289
359
|
}
|
|
290
360
|
async create(body, options) {
|
|
291
361
|
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
|
-
}
|
|
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
|
-
|
|
306
|
-
constructor(
|
|
307
|
-
this.
|
|
369
|
+
send;
|
|
370
|
+
constructor(send) {
|
|
371
|
+
this.send = send;
|
|
308
372
|
}
|
|
309
373
|
async create(body, options) {
|
|
310
374
|
assertStartWithin(body);
|
|
311
|
-
const
|
|
312
|
-
if (body.stream)
|
|
313
|
-
return streamSSE(
|
|
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(
|
|
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
|
-
|
|
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