omnirush 0.8.4 → 0.8.5
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.
|
@@ -138,3 +138,132 @@ export function sanitizeGatewayErrorBody({
|
|
|
138
138
|
const message = friendlyGatewayMessage(status, ref, attempts, waitedMs);
|
|
139
139
|
return { bodyText: message, sanitized: true };
|
|
140
140
|
}
|
|
141
|
+
|
|
142
|
+
// --- stream stall watchdog -------------------------------------------------
|
|
143
|
+
//
|
|
144
|
+
// The agent core runs on Bun, and there its undici transport never
|
|
145
|
+
// settles a pending body read when the connection dies mid-stream: the
|
|
146
|
+
// HTTP idle timeout (or the gateway/edge dropping the socket) closes the
|
|
147
|
+
// socket, but `reader.read()` stays pending forever. The turn then sits
|
|
148
|
+
// on "Working…" until the user presses Esc — no error, no auto-retry
|
|
149
|
+
// (field report: session 01a0dd1a, a 13-minute hang after a tool call).
|
|
150
|
+
// On Node the same close surfaces as a `terminated` error that pi retries.
|
|
151
|
+
// The watchdog gives Bun that behavior: it re-exposes the body through a
|
|
152
|
+
// stream of our own and errors it when the upstream read has been
|
|
153
|
+
// pending for `idleMs`, so pi's retry takes over.
|
|
154
|
+
|
|
155
|
+
/** setTimeout's ceiling; pi passes it (2^31-1) for a disabled timeout. */
|
|
156
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
157
|
+
export const DEFAULT_STREAM_IDLE_MS = 300_000;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The idle limit for a model stream: OMNIRUSH_STREAM_IDLE_MS wins, then
|
|
161
|
+
* the request timeout pi hands the provider (its httpIdleTimeoutMs /
|
|
162
|
+
* retry.provider.timeoutMs), then 5 minutes. 0 (or a value at the timer
|
|
163
|
+
* ceiling, pi's "disabled") turns the watchdog off.
|
|
164
|
+
*/
|
|
165
|
+
export function streamIdleMs(envValue: string | undefined, requestTimeoutMs: unknown): number {
|
|
166
|
+
const pick = (value: unknown): number | undefined => {
|
|
167
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
168
|
+
const n = Number(value);
|
|
169
|
+
if (!Number.isFinite(n) || n < 0) return undefined;
|
|
170
|
+
return n >= MAX_TIMER_MS ? 0 : Math.floor(n);
|
|
171
|
+
};
|
|
172
|
+
return pick(envValue) ?? pick(requestTimeoutMs) ?? DEFAULT_STREAM_IDLE_MS;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The error a stalled stream fails with ("timed out" keeps it retryable for pi). */
|
|
176
|
+
export function streamStallError(idleMs: number): Error {
|
|
177
|
+
const seconds = Math.max(1, Math.round(idleMs / 1000));
|
|
178
|
+
const error = new Error(
|
|
179
|
+
`Omnirush model stream timed out: no data from the gateway for ${seconds}s — retrying`,
|
|
180
|
+
);
|
|
181
|
+
error.name = "OmnirushStreamStall";
|
|
182
|
+
return error;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Re-expose `source` as a new ReadableStream that errors with
|
|
187
|
+
* `streamStallError` when one upstream read stays pending for `idleMs`
|
|
188
|
+
* (0 = never), and with the abort reason when `signal` aborts. Every
|
|
189
|
+
* chunk is handed to `onChunk` before it is enqueued. The upstream
|
|
190
|
+
* reader is cancelled (best effort, never awaited — it may be the hung
|
|
191
|
+
* one) whenever the wrapper errors or is cancelled.
|
|
192
|
+
*/
|
|
193
|
+
export function withStallWatchdog(
|
|
194
|
+
source: ReadableStream<any>,
|
|
195
|
+
{
|
|
196
|
+
idleMs,
|
|
197
|
+
signal,
|
|
198
|
+
onChunk,
|
|
199
|
+
}: { idleMs: number; signal?: AbortSignal | null; onChunk?: (chunk: any) => void },
|
|
200
|
+
): ReadableStream<any> {
|
|
201
|
+
const reader = source.getReader();
|
|
202
|
+
let controller: ReadableStreamDefaultController<any>;
|
|
203
|
+
let finished = false;
|
|
204
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
205
|
+
|
|
206
|
+
const clearTimer = () => {
|
|
207
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
208
|
+
timer = undefined;
|
|
209
|
+
};
|
|
210
|
+
const finish = () => {
|
|
211
|
+
finished = true;
|
|
212
|
+
clearTimer();
|
|
213
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
214
|
+
};
|
|
215
|
+
const fail = (error: unknown) => {
|
|
216
|
+
if (finished) return;
|
|
217
|
+
finish();
|
|
218
|
+
try {
|
|
219
|
+
controller.error(error);
|
|
220
|
+
} catch {
|
|
221
|
+
/* already closed */
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
reader.cancel(error).catch(() => undefined);
|
|
225
|
+
} catch {
|
|
226
|
+
/* already released */
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
function onAbort() {
|
|
230
|
+
const reason = (signal as any)?.reason;
|
|
231
|
+
fail(reason ?? Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return new ReadableStream<any>({
|
|
235
|
+
start(c) {
|
|
236
|
+
controller = c;
|
|
237
|
+
if (signal?.aborted) onAbort();
|
|
238
|
+
else signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
239
|
+
},
|
|
240
|
+
async pull(c) {
|
|
241
|
+
if (finished) return;
|
|
242
|
+
if (idleMs > 0) timer = setTimeout(() => fail(streamStallError(idleMs)), Math.min(idleMs, MAX_TIMER_MS - 1));
|
|
243
|
+
let result: ReadableStreamReadResult<any>;
|
|
244
|
+
try {
|
|
245
|
+
result = await reader.read();
|
|
246
|
+
} catch (error) {
|
|
247
|
+
fail(error);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
clearTimer();
|
|
251
|
+
if (finished) return;
|
|
252
|
+
if (result.done) {
|
|
253
|
+
finish();
|
|
254
|
+
c.close();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
onChunk?.(result.value);
|
|
259
|
+
} catch {
|
|
260
|
+
/* observers never break the stream */
|
|
261
|
+
}
|
|
262
|
+
c.enqueue(result.value);
|
|
263
|
+
},
|
|
264
|
+
cancel(reason) {
|
|
265
|
+
finish();
|
|
266
|
+
return reader.cancel(reason).catch(() => undefined);
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
// - on 429 / 5xx / network failures, retries with exponential backoff
|
|
15
15
|
// + full jitter (shared retry layer, "attempt 2/5" progress on
|
|
16
16
|
// stderr) so users ride out backend deploys.
|
|
17
|
+
// - fails a model stream that stops delivering data (stall watchdog)
|
|
18
|
+
// with a retryable error, so a dropped connection never leaves the
|
|
19
|
+
// turn hanging on Bun (pi retries it like on Node).
|
|
17
20
|
// - SANITIZES every non-OK response body: raw upstream provider text
|
|
18
21
|
// ("help.openai.com… request ID…") never reaches the user — the
|
|
19
22
|
// agent layer gets an omnirush message with a local correlation id
|
|
@@ -34,6 +37,8 @@ import {
|
|
|
34
37
|
isSotaViolation,
|
|
35
38
|
sanitizeGatewayErrorBody,
|
|
36
39
|
servedModelFromSseData,
|
|
40
|
+
streamIdleMs,
|
|
41
|
+
withStallWatchdog,
|
|
37
42
|
} from "./sota-lib";
|
|
38
43
|
import {
|
|
39
44
|
formatUnavailableSummary,
|
|
@@ -209,12 +214,15 @@ async function readErrorBody(response: any): Promise<string> {
|
|
|
209
214
|
* Retries only cover the request up to (and including) the response
|
|
210
215
|
* headers — once an OK response body starts streaming it is forwarded
|
|
211
216
|
* untouched; a mid-stream failure surfaces as the provider's own error.
|
|
217
|
+
* A body that stops delivering data for `idleMs` fails with a retryable
|
|
218
|
+
* "timed out" error (stall watchdog, see sota-lib) instead of hanging.
|
|
212
219
|
* Client aborts (Esc) are never retried.
|
|
213
220
|
*/
|
|
214
221
|
function tapFetch(
|
|
215
222
|
baseFetch: any,
|
|
216
223
|
expectedModelId: string,
|
|
217
224
|
onViolation: (expected: string, served: string) => void,
|
|
225
|
+
idleMs: number,
|
|
218
226
|
): any {
|
|
219
227
|
return async (input: any, init: any) => {
|
|
220
228
|
const requestUrl = typeof input === "string" ? input : input?.url;
|
|
@@ -353,13 +361,11 @@ function tapFetch(
|
|
|
353
361
|
};
|
|
354
362
|
const scanner = createSseDataScanner(onData);
|
|
355
363
|
const decoder = new TextDecoder();
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
},
|
|
364
|
+
const body = withStallWatchdog(response.body, {
|
|
365
|
+
idleMs,
|
|
366
|
+
signal: init?.signal,
|
|
367
|
+
onChunk: (chunk: any) => scanner(decoder.decode(chunk, { stream: true })),
|
|
361
368
|
});
|
|
362
|
-
const body = response.body.pipeThrough(passthrough);
|
|
363
369
|
return new Response(body, {
|
|
364
370
|
status: response.status,
|
|
365
371
|
statusText: response.statusText,
|
|
@@ -389,8 +395,11 @@ export default function (pi: any) {
|
|
|
389
395
|
const baseFetch = options?.fetch ?? globalThis.fetch;
|
|
390
396
|
const tapped = {
|
|
391
397
|
...(options ?? {}),
|
|
392
|
-
fetch: tapFetch(
|
|
393
|
-
|
|
398
|
+
fetch: tapFetch(
|
|
399
|
+
baseFetch,
|
|
400
|
+
model.id,
|
|
401
|
+
(expected, served) => reportViolation(strictSota(), expected, served),
|
|
402
|
+
streamIdleMs(process.env.OMNIRUSH_STREAM_IDLE_MS, options?.timeoutMs),
|
|
394
403
|
),
|
|
395
404
|
};
|
|
396
405
|
return simple
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omnirush",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"description": "Omnirush \u2014 free daily tokens for the most powerful coding model on earth.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -41,4 +41,4 @@
|
|
|
41
41
|
"minimatch": "^10.2.6",
|
|
42
42
|
"zod": "^4.6.5"
|
|
43
43
|
}
|
|
44
|
-
}
|
|
44
|
+
}
|