flexinference 1.5.1 → 1.5.2
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 +7 -7
- package/README.md +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.js +104 -49
- package/dist/types.d.ts +2 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,8 +7,8 @@ its total `timeout` now throws a clear timeout error instead of a bare abort tha
|
|
|
7
7
|
a caller cancellation. A streamed response flushes its decoder and delivers the final frame
|
|
8
8
|
even when the stream ends without a trailing blank line, so the last event is never dropped,
|
|
9
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
|
|
11
|
-
|
|
10
|
+
without bound. The `start_within` type now requires the runtime's two-digit duration
|
|
11
|
+
shape, so values like `0h-5m-0s` no longer type-check.
|
|
12
12
|
|
|
13
13
|
## 1.5.0
|
|
14
14
|
|
|
@@ -82,11 +82,11 @@ required and aligns its values with OpenAI's `service_tier` vocabulary.
|
|
|
82
82
|
|
|
83
83
|
### Breaking changes
|
|
84
84
|
|
|
85
|
-
- **`start_within` is now required on every request.** It is typed as
|
|
86
|
-
`
|
|
87
|
-
|
|
88
|
-
A duration that fits the shape but is malformed or out of range is caught at **runtime**;
|
|
89
|
-
plain-JS callers get every check at **runtime** (a thrown `Error` before any network call).
|
|
85
|
+
- **`start_within` is now required on every request.** It is typed as `"default"`,
|
|
86
|
+
`"priority"`, `"auto"`, or a duration shaped like `"00h-05m-00s"`, so omitting it
|
|
87
|
+
(or passing a value outside those shapes) is a **compile-time** error.
|
|
88
|
+
A duration that fits the shape but is malformed or out of range is caught at **runtime**;
|
|
89
|
+
plain-JS callers get every check at **runtime** (a thrown `Error` before any network call).
|
|
90
90
|
- **Accepted values changed.** `start_within` now takes `"default"`, `"priority"`,
|
|
91
91
|
`"auto"`, or a duration `"HHh-MMm-SSs"` (5s-10m). The old `"standard"` value is
|
|
92
92
|
removed; use `"default"` (it maps to the same OpenAI service tier).
|
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ console.log(messageText(res));
|
|
|
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
106
|
|
|
107
|
-
- **Non-streaming** requests have a
|
|
107
|
+
- **Non-streaming** requests have a body-read budget after response headers arrive: `timeout`, default **600000 ms (10 min)**. The worst case is roughly `firstByteTimeout + timeout`.
|
|
108
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
109
|
|
|
110
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.
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,12 @@ type Schemas = components["schemas"];
|
|
|
5
5
|
* those OpenAI service tiers and proxy any model; a duration "HHh-MMm-SSs" (5s-10m)
|
|
6
6
|
* runs the flex race on a flex-capable model. An arbitrary string needs `as StartWithin`.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
|
|
9
|
+
type Second00To59 = "00" | "01" | "02" | "03" | "04" | "05" | "06" | "07" | "08" | "09" | `1${Digit}` | `2${Digit}` | `3${Digit}` | `4${Digit}` | `5${Digit}`;
|
|
10
|
+
type Second05To59 = Exclude<Second00To59, "00" | "01" | "02" | "03" | "04">;
|
|
11
|
+
type Minute01To09 = `0${Exclude<Digit, "0">}`;
|
|
12
|
+
type Duration = `00h-00m-${Second05To59}s` | `00h-${Minute01To09}m-${Second00To59}s` | "00h-10m-00s";
|
|
13
|
+
export type StartWithin = "default" | "priority" | "auto" | Duration;
|
|
9
14
|
/** Make `start_within` a required, typed field on a generated request body. */
|
|
10
15
|
type WithStartWithin<T> = Omit<T, "start_within"> & {
|
|
11
16
|
start_within: StartWithin;
|
|
@@ -78,6 +83,7 @@ export declare class FlexInferenceError extends Error {
|
|
|
78
83
|
* FlexInferenceError)` handlers keep working; narrow to this to handle 402 specially.
|
|
79
84
|
*/
|
|
80
85
|
export declare class PaymentRequiredError extends FlexInferenceError {
|
|
86
|
+
constructor(status: number, body: unknown, fallback: string);
|
|
81
87
|
}
|
|
82
88
|
export interface ClientOptions {
|
|
83
89
|
apiKey: string;
|
|
@@ -86,9 +92,10 @@ export interface ClientOptions {
|
|
|
86
92
|
/** Override the global fetch (e.g. for tests or non-standard runtimes). */
|
|
87
93
|
fetch?: typeof fetch;
|
|
88
94
|
/**
|
|
89
|
-
* Non-streaming
|
|
90
|
-
* 600000 (10 min).
|
|
91
|
-
*
|
|
95
|
+
* Non-streaming body-read budget in ms, after response headers arrive. Defaults to
|
|
96
|
+
* 600000 (10 min). The worst case is roughly `firstByteTimeout + timeout`.
|
|
97
|
+
* Streaming requests ignore this: they are governed by `firstByteTimeout` and
|
|
98
|
+
* `idleTimeout` and have no total cap, so a healthy long stream is never cut off.
|
|
92
99
|
*/
|
|
93
100
|
timeout?: number;
|
|
94
101
|
/**
|
package/dist/index.js
CHANGED
|
@@ -120,6 +120,10 @@ export class FlexInferenceError extends Error {
|
|
|
120
120
|
* FlexInferenceError)` handlers keep working; narrow to this to handle 402 specially.
|
|
121
121
|
*/
|
|
122
122
|
export class PaymentRequiredError extends FlexInferenceError {
|
|
123
|
+
constructor(status, body, fallback) {
|
|
124
|
+
super(status, body, fallback);
|
|
125
|
+
this.name = "PaymentRequiredError";
|
|
126
|
+
}
|
|
123
127
|
}
|
|
124
128
|
const DEFAULT_BASE_URL = "https://api.flexinference.com/v1";
|
|
125
129
|
// A streaming request is governed by two clocks and no total: how long we wait for the
|
|
@@ -134,43 +138,107 @@ const DEFAULT_TOTAL_MS = 600_000; // non-streaming wall-clock budget (streaming
|
|
|
134
138
|
const ERROR_BODY_READ_MS = 30_000; // bound the read of a non-2xx error body
|
|
135
139
|
// Cap the streaming buffer above the largest legitimate single frame (a terminal
|
|
136
140
|
// 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.
|
|
141
|
+
// would otherwise grow the buffer without bound; error clearly instead.
|
|
138
142
|
const MAX_SSE_BUFFER_CHARS = 8 * 1024 * 1024;
|
|
139
143
|
const MIN_DEADLINE_SECONDS = 5;
|
|
140
144
|
const MAX_DEADLINE_SECONDS = 600;
|
|
141
145
|
const SECONDS_PER_HOUR = 3600;
|
|
142
146
|
const SECONDS_PER_MINUTE = 60;
|
|
143
|
-
//
|
|
144
|
-
|
|
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$/;
|
|
147
|
+
// Two digits per field so the runtime matches the router, Python SDK, and StartWithin type.
|
|
148
|
+
const DURATION_RE = /^(\d{2})h-(\d{2})m-(\d{2})s$/;
|
|
147
149
|
const START_WITHIN_VALUES = '"default", "priority", "auto", or a duration "HHh-MMm-SSs"';
|
|
150
|
+
function startWithinValue(body) {
|
|
151
|
+
const value = typeof body === "object" && body !== null
|
|
152
|
+
? body["start_within"]
|
|
153
|
+
: undefined;
|
|
154
|
+
return value === undefined ? { kind: "missing" } : { kind: "present", value };
|
|
155
|
+
}
|
|
156
|
+
function parseStartWithin(value) {
|
|
157
|
+
if (typeof value !== "string")
|
|
158
|
+
return { kind: "invalid", reason: "type", value };
|
|
159
|
+
if (value === "default" || value === "priority" || value === "auto") {
|
|
160
|
+
return { kind: "tier", value };
|
|
161
|
+
}
|
|
162
|
+
const match = DURATION_RE.exec(value);
|
|
163
|
+
if (match === null)
|
|
164
|
+
return { kind: "invalid", reason: "malformed", value };
|
|
165
|
+
const seconds = Number(match[1]) * SECONDS_PER_HOUR +
|
|
166
|
+
Number(match[2]) * SECONDS_PER_MINUTE +
|
|
167
|
+
Number(match[3]);
|
|
168
|
+
return { kind: "duration", value, seconds };
|
|
169
|
+
}
|
|
148
170
|
// Runtime guard so plain-JS callers (who get no compile-time check) still fail
|
|
149
171
|
// locally with a clear message instead of a provider round-trip. TypeScript callers
|
|
150
172
|
// are already protected by the required StartWithin field above.
|
|
151
173
|
function assertStartWithin(body) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
: undefined;
|
|
155
|
-
if (value === undefined) {
|
|
174
|
+
const sw = startWithinValue(body);
|
|
175
|
+
if (sw.kind === "missing") {
|
|
156
176
|
throw new Error(`FlexInference: \`start_within\` is required on every request. Set it to ${START_WITHIN_VALUES}, ` +
|
|
157
177
|
`for example "start_within": "default" for standard-tier behavior or "00h-00m-30s" for a 30-second flex race.`);
|
|
158
178
|
}
|
|
159
|
-
|
|
179
|
+
const parsed = parseStartWithin(sw.value);
|
|
180
|
+
if (parsed.kind === "invalid" && parsed.reason === "type") {
|
|
160
181
|
throw new Error(`FlexInference: \`start_within\` must be a string (${START_WITHIN_VALUES}), for example "default".`);
|
|
161
182
|
}
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (match === null) {
|
|
166
|
-
throw new Error(`FlexInference: \`start_within\` "${value}" is not ${START_WITHIN_VALUES}. ` +
|
|
167
|
-
`A duration is one or two digits each for hours, minutes, and seconds, for example "00h-05m-00s" for a 5-minute deadline.`);
|
|
183
|
+
if (parsed.kind === "invalid") {
|
|
184
|
+
throw new Error(`FlexInference: \`start_within\` "${parsed.value}" is not ${START_WITHIN_VALUES}. ` +
|
|
185
|
+
`A duration is two digits each for hours, minutes, and seconds, for example "00h-05m-00s" for a 5-minute deadline.`);
|
|
168
186
|
}
|
|
169
|
-
|
|
170
|
-
|
|
187
|
+
if (parsed.kind === "duration" &&
|
|
188
|
+
(parsed.seconds < MIN_DEADLINE_SECONDS || parsed.seconds > MAX_DEADLINE_SECONDS)) {
|
|
171
189
|
throw new Error("FlexInference: `start_within` duration must be between 5s and 10m.");
|
|
172
190
|
}
|
|
173
191
|
}
|
|
192
|
+
// Per-endpoint required fields beyond the model/start_within envelope. Mirrors the Python
|
|
193
|
+
// _RequestSpec table in _validation.py: responses/interactions require `input` (presence only);
|
|
194
|
+
// chat requires `messages` (array); messages requires `messages` (array) + `max_tokens` (number).
|
|
195
|
+
const RESPONSES_REQUEST_SPEC = {
|
|
196
|
+
required: [{ name: "input", kind: "present" }],
|
|
197
|
+
};
|
|
198
|
+
const CHAT_REQUEST_SPEC = {
|
|
199
|
+
required: [{ name: "messages", kind: "array" }],
|
|
200
|
+
};
|
|
201
|
+
const INTERACTIONS_REQUEST_SPEC = {
|
|
202
|
+
required: [{ name: "input", kind: "present" }],
|
|
203
|
+
};
|
|
204
|
+
const MESSAGES_REQUEST_SPEC = {
|
|
205
|
+
required: [
|
|
206
|
+
{ name: "messages", kind: "array" },
|
|
207
|
+
{ name: "max_tokens", kind: "finiteNumber" },
|
|
208
|
+
],
|
|
209
|
+
};
|
|
210
|
+
// Read one field by reference off the raw body without copying it. `undefined` (or a non-object
|
|
211
|
+
// body) is treated as missing; `null` counts as present, matching Python's `name in body`
|
|
212
|
+
// presence check (a JSON `null` is a present key, and JSON serialization drops `undefined`).
|
|
213
|
+
function requestField(body, name) {
|
|
214
|
+
if (!isRecord(body))
|
|
215
|
+
return { kind: "missing" };
|
|
216
|
+
const value = body[name];
|
|
217
|
+
return value === undefined ? { kind: "missing" } : { kind: "present", value };
|
|
218
|
+
}
|
|
219
|
+
// Pre-flight guard: model, then start_within, then the endpoint's required fields in spec order.
|
|
220
|
+
// Pure - it only throws earlier for requests the router/Python already reject; it never mutates,
|
|
221
|
+
// clones, or reshapes the body, so unknown/extra fields pass straight through to `send`.
|
|
222
|
+
function validateRequest(spec, body) {
|
|
223
|
+
const model = requestField(body, "model");
|
|
224
|
+
if (model.kind === "missing" || typeof model.value !== "string" || model.value === "") {
|
|
225
|
+
throw new Error("FlexInference: `model` is required and must be a non-empty string.");
|
|
226
|
+
}
|
|
227
|
+
assertStartWithin(body);
|
|
228
|
+
for (const field of spec.required) {
|
|
229
|
+
const value = requestField(body, field.name);
|
|
230
|
+
if (value.kind === "missing") {
|
|
231
|
+
throw new Error(`FlexInference: \`${field.name}\` is required for this endpoint.`);
|
|
232
|
+
}
|
|
233
|
+
if (field.kind === "array" && !Array.isArray(value.value)) {
|
|
234
|
+
throw new Error(`FlexInference: \`${field.name}\` must be an array.`);
|
|
235
|
+
}
|
|
236
|
+
if (field.kind === "finiteNumber" &&
|
|
237
|
+
(typeof value.value !== "number" || !Number.isFinite(value.value))) {
|
|
238
|
+
throw new Error(`FlexInference: \`${field.name}\` must be a finite number.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
174
242
|
// Distinct abort reasons so an aborted fetch/read can be told apart: a timeout we fired
|
|
175
243
|
// (turned into a clear message) versus the caller's own AbortSignal (propagated as-is).
|
|
176
244
|
const FIRST_BYTE_TIMEOUT = Symbol("first-byte-timeout");
|
|
@@ -183,15 +251,11 @@ const TOTAL_TIMEOUT = Symbol("total-timeout");
|
|
|
183
251
|
* clock must cover the whole deadline plus the fallback path.
|
|
184
252
|
*/
|
|
185
253
|
function flexDeadlineSeconds(body) {
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
: undefined;
|
|
189
|
-
if (typeof value !== "string")
|
|
254
|
+
const sw = startWithinValue(body);
|
|
255
|
+
if (sw.kind === "missing")
|
|
190
256
|
return null;
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
return null;
|
|
194
|
-
return (Number(match[1]) * SECONDS_PER_HOUR + Number(match[2]) * SECONDS_PER_MINUTE + Number(match[3]));
|
|
257
|
+
const parsed = parseStartWithin(sw.value);
|
|
258
|
+
return parsed.kind === "duration" ? parsed.seconds : null;
|
|
195
259
|
}
|
|
196
260
|
// Read a non-streamed JSON body under the total wall-clock budget, then release the timer
|
|
197
261
|
// and the abort listener. Non-streaming only: a stream is drained by streamSSE instead.
|
|
@@ -199,7 +263,7 @@ async function readJson(open) {
|
|
|
199
263
|
const { res, controller, cleanup, totalMs } = open;
|
|
200
264
|
// Abort with a dedicated reason so a fired total timeout can be told apart from the
|
|
201
265
|
// caller cancelling on purpose, and translated into a clear message (parity with the
|
|
202
|
-
// first-byte and idle timeouts, and with the Python SDK).
|
|
266
|
+
// first-byte and idle timeouts, and with the Python SDK).
|
|
203
267
|
const totalTimer = setTimeout(() => {
|
|
204
268
|
controller.abort(TOTAL_TIMEOUT);
|
|
205
269
|
}, totalMs);
|
|
@@ -219,6 +283,13 @@ async function readJson(open) {
|
|
|
219
283
|
cleanup();
|
|
220
284
|
}
|
|
221
285
|
}
|
|
286
|
+
async function createEndpoint(send, path, spec, body, options) {
|
|
287
|
+
validateRequest(spec, body);
|
|
288
|
+
const open = await send(path, body, options);
|
|
289
|
+
if (body.stream)
|
|
290
|
+
return streamSSE(open);
|
|
291
|
+
return readJson(open);
|
|
292
|
+
}
|
|
222
293
|
export class FlexInference {
|
|
223
294
|
responses;
|
|
224
295
|
chat;
|
|
@@ -345,11 +416,7 @@ class Responses {
|
|
|
345
416
|
this.send = send;
|
|
346
417
|
}
|
|
347
418
|
async create(body, options) {
|
|
348
|
-
|
|
349
|
-
const open = await this.send("/responses", body, options);
|
|
350
|
-
if (body.stream)
|
|
351
|
-
return streamSSE(open);
|
|
352
|
-
return readJson(open);
|
|
419
|
+
return createEndpoint(this.send, "/responses", RESPONSES_REQUEST_SPEC, body, options);
|
|
353
420
|
}
|
|
354
421
|
}
|
|
355
422
|
class Chat {
|
|
@@ -364,11 +431,7 @@ class ChatCompletions {
|
|
|
364
431
|
this.send = send;
|
|
365
432
|
}
|
|
366
433
|
async create(body, options) {
|
|
367
|
-
|
|
368
|
-
const open = await this.send("/chat/completions", body, options);
|
|
369
|
-
if (body.stream)
|
|
370
|
-
return streamSSE(open);
|
|
371
|
-
return readJson(open);
|
|
434
|
+
return createEndpoint(this.send, "/chat/completions", CHAT_REQUEST_SPEC, body, options);
|
|
372
435
|
}
|
|
373
436
|
}
|
|
374
437
|
class Interactions {
|
|
@@ -377,11 +440,7 @@ class Interactions {
|
|
|
377
440
|
this.send = send;
|
|
378
441
|
}
|
|
379
442
|
async create(body, options) {
|
|
380
|
-
|
|
381
|
-
const open = await this.send("/interactions", body, options);
|
|
382
|
-
if (body.stream)
|
|
383
|
-
return streamSSE(open);
|
|
384
|
-
return readJson(open);
|
|
443
|
+
return createEndpoint(this.send, "/interactions", INTERACTIONS_REQUEST_SPEC, body, options);
|
|
385
444
|
}
|
|
386
445
|
}
|
|
387
446
|
class Messages {
|
|
@@ -390,11 +449,7 @@ class Messages {
|
|
|
390
449
|
this.send = send;
|
|
391
450
|
}
|
|
392
451
|
async create(body, options) {
|
|
393
|
-
|
|
394
|
-
const open = await this.send("/messages", body, options);
|
|
395
|
-
if (body.stream)
|
|
396
|
-
return streamSSE(open);
|
|
397
|
-
return readJson(open);
|
|
452
|
+
return createEndpoint(this.send, "/messages", MESSAGES_REQUEST_SPEC, body, options);
|
|
398
453
|
}
|
|
399
454
|
}
|
|
400
455
|
function parseSSEFrame(frame) {
|
|
@@ -445,7 +500,7 @@ async function* streamSSE(open) {
|
|
|
445
500
|
if (done) {
|
|
446
501
|
// The stream ended. Flush any bytes the decoder held back for an incomplete
|
|
447
502
|
// 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.
|
|
503
|
+
// blank line so the last piece of the answer is not dropped.
|
|
449
504
|
exhausted = true;
|
|
450
505
|
buffer += decoder.decode();
|
|
451
506
|
const frame = buffer;
|
|
@@ -473,7 +528,7 @@ async function* streamSSE(open) {
|
|
|
473
528
|
}
|
|
474
529
|
// With every complete frame drained, whatever remains is a single unterminated
|
|
475
530
|
// frame. If it grows past the cap the stream is never sending a separator; error
|
|
476
|
-
// clearly instead of buffering without bound.
|
|
531
|
+
// clearly instead of buffering without bound.
|
|
477
532
|
if (buffer.length > MAX_SSE_BUFFER_CHARS) {
|
|
478
533
|
throw new Error(`FlexInference: stream buffer exceeded ${String(MAX_SSE_BUFFER_CHARS)} characters ` +
|
|
479
534
|
"without a frame boundary; aborting to avoid unbounded memory.");
|
package/dist/types.d.ts
CHANGED
|
@@ -5398,6 +5398,8 @@ export interface components {
|
|
|
5398
5398
|
type: string;
|
|
5399
5399
|
code?: string | null;
|
|
5400
5400
|
param?: string | null;
|
|
5401
|
+
/** @description Additive deep link into the docs for this exact code. Present (a URL or null) on FlexInference-layer errors; absent on verbatim upstream errors. */
|
|
5402
|
+
doc_url?: string | null;
|
|
5401
5403
|
};
|
|
5402
5404
|
};
|
|
5403
5405
|
/** Interaction Text Part */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexinference",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
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 && node test/fixes.test.mjs && npm run test:types",
|
|
37
|
+
"test": "node test/smoke.mjs && node test/fixes.test.mjs && node test/parity.test.mjs && npm run test:types",
|
|
38
38
|
"lint": "eslint .",
|
|
39
39
|
"lint:fix": "eslint . --fix",
|
|
40
40
|
"format": "prettier --write .",
|