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 passthrough = new TransformStream({
357
- transform(chunk: any, controller: any) {
358
- controller.enqueue(chunk);
359
- scanner(decoder.decode(chunk, { stream: true }));
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(baseFetch, model.id, (expected, served) =>
393
- reportViolation(strictSota(), expected, served),
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.4",
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
+ }