@qoniai/qoni 0.4.1 → 0.4.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 CHANGED
@@ -6,6 +6,27 @@ This project follows semantic versioning. Before `1.0.0`, minor versions may
6
6
  include SDK API changes, while patch versions are reserved for backward
7
7
  compatible fixes.
8
8
 
9
+ ## 0.4.2 - 2026-09-09
10
+
11
+ ### Fixed
12
+
13
+ - Require authoritative run details when settling `wait()`: preserve detail
14
+ errors and reject unconfirmed terminal states instead of inventing success.
15
+ - Reuse terminal details read after a clean stream close and resume from every
16
+ wire event cursor, including frames filtered from semantic events.
17
+ - Propagate wait cancellation to detail and artifact-list GETs. Bound complete
18
+ JSON, NDJSON, binary, and HTTP error body reads by the request timeout, cancel
19
+ stalled readers, and discard late responses without starting replacement runs.
20
+ - Clean up wait timers and SSE abort listeners. Cancel idle SSE readers directly
21
+ and release their locks without waiting indefinitely for source cancellation.
22
+ - Retry HTTP 408 stream failures within the configured reconnect budget and stop
23
+ delivering buffered events after cancellation.
24
+
25
+ ### Added
26
+
27
+ - Optional abort signals on product run-detail and artifact-list wire reads;
28
+ existing calls without a signal remain compatible.
29
+
9
30
  ## Unreleased
10
31
 
11
32
  ### Breaking
package/README.md CHANGED
@@ -512,7 +512,7 @@ const qoni = new Qoni({
512
512
  | `secretKey` | `string` | Yes | Qoni secret key from Qoni Console. |
513
513
  | `host` | `string` | No | Optional Qoni Console/SDK gateway override for private or local deployments. |
514
514
  | `fetch` | `typeof fetch` | No | Custom transport implementation. |
515
- | `timeoutMs` | `number` | No | Per-request timeout (connect + response headers). Defaults to `30000`. |
515
+ | `timeoutMs` | `number` | No | Per-request timeout (connect + headers + body for one-shot requests; headers only for successful SSE connections). Defaults to `30000`. |
516
516
 
517
517
  > **`timeoutMs` does not bound a long-running run.** It applies to individual
518
518
  > one-shot HTTP requests (delegation, `status()`, a single POST). The live
@@ -550,6 +550,26 @@ await run.cancel(reason?) // idempotent
550
550
 
551
551
  `await run.wait(...)` settles to a `RunResult`:
552
552
 
553
+ The run-detail response must confirm a terminal state. Detail read errors propagate
554
+ unchanged; `wait()` never substitutes an SSE completion payload for the authoritative
555
+ result. A nonterminal or incomplete detail rejects with a retryable `upstream.failed`
556
+ error; use `status()` or re-attach to recover.
557
+
558
+ `timeoutMs` and `signal` stop waiting for detail/artifact reads and promises returned
559
+ by `onEvent`, `onWireEvent`, `onInteraction`, and `onScreenshot`. They cannot preempt
560
+ synchronous JavaScript or stop work already started inside a callback. `onReconnect`
561
+ is synchronous telemetry: its return value is not awaited and returned promise
562
+ rejections are not handled. Do not pass an async callback there; synchronous throws
563
+ propagate. Reconnect backoff remains abortable and subject to the wait deadline.
564
+
565
+ Detail/artifact-list GETs receive the wait signal: aborting or timing out a wait aborts
566
+ the request and cancels its body reader. The client's per-request timer also covers
567
+ complete JSON/NDJSON and binary body reads, including HTTP error bodies. A late response
568
+ from a custom fetch that ignores abort has its body canceled and cannot resume settlement
569
+ or start subsequent run requests. Custom transports must honor `RequestInit.signal` to
570
+ stop their own in-flight network work. Stopping a wait neither cancels the server-side
571
+ run nor creates a replacement task; use `cancel()` explicitly to stop the existing run.
572
+
553
573
  ```ts
554
574
  interface RunResult {
555
575
  runId: string;
package/README.zh-CN.md CHANGED
@@ -482,7 +482,7 @@ const qoni = new Qoni({
482
482
  | `secretKey` | `string` | 是 | Qoni Console 创建的 secret key。 |
483
483
  | `host` | `string` | 否 | 私有化或本地部署时可选的 Qoni Console/SDK 网关覆盖地址。 |
484
484
  | `fetch` | `typeof fetch` | 否 | 自定义传输实现。 |
485
- | `timeoutMs` | `number` | 否 | 单次请求超时(连接 + 响应头),默认 `30000`。 |
485
+ | `timeoutMs` | `number` | 否 | 单次请求超时(一次性请求覆盖连接、响应头及响应体;成功建立的 SSE 仅覆盖到响应头),默认 `30000`。 |
486
486
 
487
487
  > **`timeoutMs` 不会限制长任务。** 它只作用于单次一次性 HTTP 请求(委托、`status()`、单个 POST)。事件流(`events()` / `wait()`)**不受它约束**——流的响应头一到,这个单次计时器就被清掉了,所以一个跑一小时的 Deep Research 在默认 30s 客户端超时下也能正常流式。要限制等待时长用 `wait({ timeoutMs })`;不传则 `wait()` 一直等到终态。`wait()` 超时**不会停止 run**——它在服务端继续跑,可以再 `attach()` 回去。
488
488
 
@@ -512,6 +512,19 @@ await run.cancel(reason?) // 幂等
512
512
 
513
513
  `await run.wait(...)` 结算成一个 `RunResult`:
514
514
 
515
+ 必须由 run-detail 响应确认终态。详情读取错误原样抛出,`wait()` 不会用 SSE 完成事件替代权威结果。
516
+ 详情仍为非终态或缺少终态信息时,抛出可重试的 `upstream.failed`;可通过 `status()` 或重新 `attach()` 恢复。
517
+
518
+ `timeoutMs` 与 `signal` 会结束详情、交付物读取及 `onEvent`、`onWireEvent`、`onInteraction`、
519
+ `onScreenshot` 返回的 Promise 的等待,但不能抢占同步 JavaScript,也不能停止回调内部已启动的工作。
520
+ `onReconnect` 是同步遥测通知:返回值不被等待,返回的 Promise 的 rejection 也不被处理,因此不要传 async
521
+ 回调;同步抛错会向上传播。重连退避等待仍可被 signal 或 wait 超时中断。
522
+
523
+ 详情/交付物列表 GET 会接收 wait signal:wait 中止或超时时会中止请求、取消并释放响应体 reader。
524
+ 客户端单次请求计时器也覆盖完整 JSON/NDJSON、二进制响应体及 HTTP 错误体读取。即使自定义 fetch 忽略
525
+ abort,SDK 也会取消其迟到响应体,不恢复结算或启动后续 run 请求;自定义传输须遵守 `RequestInit.signal`
526
+ 才能停止其内部网络工作。停止 wait 既不会取消服务端 run,也不会创建替代任务;需要停止原 run 时须显式调用 `cancel()`。
527
+
515
528
  ```ts
516
529
  interface RunResult {
517
530
  runId: string;
package/dist/index.cjs CHANGED
@@ -816,11 +816,11 @@ var RunHandle = class {
816
816
  }
817
817
  /** Drive the run to a terminal state and return the settled result. */
818
818
  async wait(opts = {}) {
819
- const { signal, timedOut } = withTimeout(opts.signal, opts.timeoutMs);
819
+ const { signal, timedOut, dispose } = withTimeout(opts.signal, opts.timeoutMs);
820
820
  let lastEventId;
821
821
  let eventIdAtLastClose;
822
822
  let sawCleanClose = false;
823
- let terminalPayload;
823
+ let terminalDetail;
824
824
  let terminalDoneReason;
825
825
  let screenshotIndex = 0;
826
826
  try {
@@ -828,24 +828,28 @@ var RunHandle = class {
828
828
  for await (const event of this.events({
829
829
  signal,
830
830
  lastEventId,
831
- onWireEvent: opts.onWireEvent,
831
+ onWireEvent: (wire) => waitWithSignal(() => {
832
+ if (wire.id !== void 0) lastEventId = wire.id;
833
+ return opts.onWireEvent?.(wire);
834
+ }, signal),
832
835
  onReconnect: opts.onReconnect
833
836
  })) {
834
- if (event.raw.id) lastEventId = event.raw.id;
835
- await opts.onEvent?.(event);
837
+ await waitWithSignal(() => opts.onEvent?.(event), signal);
836
838
  if (event.type === "screenshot") {
837
- await opts.onScreenshot?.(event.image, screenshotIndex++);
839
+ await waitWithSignal(() => opts.onScreenshot?.(event.image, screenshotIndex++), signal);
838
840
  } else if (event.type === "interaction") {
839
- await opts.onInteraction?.(this.interactionHandle(event.data), event);
841
+ await waitWithSignal(() => opts.onInteraction?.(this.interactionHandle(event.data), event), signal);
840
842
  }
841
843
  if (event.isTerminal) {
842
- terminalPayload = isJsonObject(event.raw.data) ? event.raw.data : void 0;
843
844
  if (event.type === "done") terminalDoneReason = event.data.terminalReason || void 0;
844
845
  break settled;
845
846
  }
846
847
  }
847
- const detail2 = await this.ops.getDetail();
848
- if (isTerminalRunStatus(detail2.status)) break;
848
+ const detail2 = await waitWithSignal(() => this.ops.getDetail(signal), signal);
849
+ if (isTerminalRunStatus(normalizeRunStatus(this.id, detail2).status)) {
850
+ terminalDetail = detail2;
851
+ break;
852
+ }
849
853
  if (sawCleanClose && lastEventId === eventIdAtLastClose) {
850
854
  throw new QoniError(
851
855
  `run event stream ended before a terminal event while the run is still "${String(detail2.status)}" \u2014 re-attach or poll status()`,
@@ -855,6 +859,11 @@ var RunHandle = class {
855
859
  sawCleanClose = true;
856
860
  eventIdAtLastClose = lastEventId;
857
861
  }
862
+ const detail = terminalDetail ?? await waitWithSignal(() => this.ops.getDetail(signal), signal);
863
+ this.#adoptSessionRef(detail);
864
+ const result = settleRunResult(this.id, detail, [], terminalDoneReason);
865
+ result.artifacts = this.ops.listArtifacts ? await waitWithSignal(() => this.ops.listArtifacts(signal), signal) : [];
866
+ return result;
858
867
  } catch (err) {
859
868
  if (timedOut() && !opts.signal?.aborted) {
860
869
  throw new QoniTimeoutError(
@@ -863,17 +872,9 @@ var RunHandle = class {
863
872
  );
864
873
  }
865
874
  throw err;
875
+ } finally {
876
+ dispose();
866
877
  }
867
- let detail;
868
- try {
869
- detail = await this.ops.getDetail();
870
- } catch (err) {
871
- if (!terminalPayload) throw err;
872
- detail = terminalPayload;
873
- }
874
- this.#adoptSessionRef(detail);
875
- const artifacts = this.ops.listArtifacts ? await this.ops.listArtifacts() : [];
876
- return settleRunResult(this.id, detail, artifacts, terminalDoneReason);
877
878
  }
878
879
  /**
879
880
  * Wrap an {@link Interaction} (from `event.data` of an `interaction` event)
@@ -926,15 +927,12 @@ function normalizeRunStatus(id, detail) {
926
927
  function settleRunResult(runId, detail, artifacts, fallbackTerminalReason) {
927
928
  const terminalReason = (typeof detail.terminal_reason === "string" ? detail.terminal_reason : void 0) ?? fallbackTerminalReason;
928
929
  const isTaskSuccessful = typeof detail.is_task_successful === "boolean" ? detail.is_task_successful : void 0;
929
- let status;
930
- if (isTerminalRunStatus(detail.status)) {
931
- status = detail.status;
932
- } else if (terminalReason === "canceled") {
933
- status = "canceled";
934
- } else if (terminalReason === "failed" || terminalReason === "expired" || isTaskSuccessful === false) {
935
- status = "failed";
936
- } else {
937
- status = "succeeded";
930
+ const status = normalizeRunStatus(runId, detail).status;
931
+ if (!isTerminalRunStatus(status)) {
932
+ throw new QoniError(
933
+ "run detail did not confirm a terminal state \u2014 re-attach or poll status()",
934
+ { code: "upstream.failed", retryable: true, body: detail }
935
+ );
938
936
  }
939
937
  return {
940
938
  runId,
@@ -961,19 +959,40 @@ function isVideoFrame(wire) {
961
959
  return type === "browser_video_frame" || type === "run.browser_video_frame";
962
960
  }
963
961
  function withTimeout(signal, timeoutMs) {
964
- if (!timeoutMs) return { signal, timedOut: () => false };
962
+ if (!timeoutMs) return { signal, timedOut: () => false, dispose: () => {
963
+ } };
965
964
  const controller = new AbortController();
966
965
  let fired = false;
967
966
  const timer = setTimeout(() => {
968
967
  fired = true;
969
968
  controller.abort();
970
969
  }, timeoutMs);
971
- controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
970
+ const onAbort = () => controller.abort(signal?.reason);
971
+ const dispose = () => {
972
+ clearTimeout(timer);
973
+ signal?.removeEventListener("abort", onAbort);
974
+ };
975
+ controller.signal.addEventListener("abort", dispose, { once: true });
972
976
  if (signal) {
973
- if (signal.aborted) controller.abort();
974
- else signal.addEventListener("abort", () => controller.abort(), { once: true });
977
+ if (signal.aborted) onAbort();
978
+ else signal.addEventListener("abort", onAbort, { once: true });
975
979
  }
976
- return { signal: controller.signal, timedOut: () => fired };
980
+ return { signal: controller.signal, timedOut: () => fired, dispose };
981
+ }
982
+ function waitWithSignal(operation, signal) {
983
+ if (!signal) return Promise.resolve().then(operation);
984
+ return new Promise((resolve, reject) => {
985
+ const onAbort = () => reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
986
+ if (signal.aborted) {
987
+ onAbort();
988
+ return;
989
+ }
990
+ signal.addEventListener("abort", onAbort, { once: true });
991
+ Promise.resolve().then(() => {
992
+ signal.throwIfAborted();
993
+ return operation();
994
+ }).then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
995
+ });
977
996
  }
978
997
 
979
998
  // src/deep-research.ts
@@ -988,6 +1007,7 @@ function createDeepResearchNamespace(transport) {
988
1007
  `/deep_research/runs/${encodeURIComponent(input.runId)}`,
989
1008
  input.token,
990
1009
  {
1010
+ signal: input.signal,
991
1011
  requiredScopes: ["webagent.deep_research:read"]
992
1012
  }
993
1013
  ),
@@ -1033,6 +1053,7 @@ function createDeepResearchNamespace(transport) {
1033
1053
  `/deep_research/runs/${encodeURIComponent(input.runId)}/artifacts`,
1034
1054
  input.token,
1035
1055
  {
1056
+ signal: input.signal,
1036
1057
  requiredScopes: ["webagent.deep_research:read"]
1037
1058
  }
1038
1059
  ),
@@ -1047,7 +1068,7 @@ function createDeepResearchNamespace(transport) {
1047
1068
  };
1048
1069
  function buildOps(token, runId) {
1049
1070
  return {
1050
- getDetail: async () => asRecord((await api.get({ token, runId })).data),
1071
+ getDetail: async (signal) => asRecord((await api.get({ token, runId, signal })).data),
1051
1072
  streamEvents: (opts) => api.events({
1052
1073
  token,
1053
1074
  runId,
@@ -1067,8 +1088,8 @@ function createDeepResearchNamespace(transport) {
1067
1088
  );
1068
1089
  },
1069
1090
  cancel: async () => asRecord((await api.cancel({ token, runId })).data),
1070
- listArtifacts: async () => {
1071
- const res = await api.listArtifacts({ token, runId });
1091
+ listArtifacts: async (signal) => {
1092
+ const res = await api.listArtifacts({ token, runId, signal });
1072
1093
  const items = Array.isArray(res.data?.items) ? res.data.items : [];
1073
1094
  return items.filter(isJsonObject).map((item) => toArtifact(item, token, runId));
1074
1095
  }
@@ -1188,6 +1209,7 @@ function createDoAnythingNamespace(transport) {
1188
1209
  `/do_anything/runs/${encodeURIComponent(input.runId)}`,
1189
1210
  input.token,
1190
1211
  {
1212
+ signal: input.signal,
1191
1213
  requiredScopes: ["webagent.do_anything:read"]
1192
1214
  }
1193
1215
  ),
@@ -1245,7 +1267,7 @@ function createDoAnythingNamespace(transport) {
1245
1267
  };
1246
1268
  function buildOps(token, runId, capture) {
1247
1269
  return {
1248
- getDetail: async () => asRecord2((await api.getRun({ token, runId })).data),
1270
+ getDetail: async (signal) => asRecord2((await api.getRun({ token, runId, signal })).data),
1249
1271
  streamEvents: (opts) => api.events({
1250
1272
  token,
1251
1273
  runId,
@@ -1839,6 +1861,7 @@ function createWebSearchNamespace(transport) {
1839
1861
  `/web_search/runs/${encodeURIComponent(input.runId)}`,
1840
1862
  input.token,
1841
1863
  {
1864
+ signal: input.signal,
1842
1865
  requiredScopes: ["webagent.web_search:read"]
1843
1866
  }
1844
1867
  ),
@@ -1864,7 +1887,7 @@ function createWebSearchNamespace(transport) {
1864
1887
  };
1865
1888
  function buildOps(token, runId) {
1866
1889
  return {
1867
- getDetail: async () => asRecord4((await api.get({ token, runId })).data),
1890
+ getDetail: async (signal) => asRecord4((await api.get({ token, runId, signal })).data),
1868
1891
  streamEvents: (opts) => api.events({
1869
1892
  token,
1870
1893
  runId,
@@ -2137,7 +2160,9 @@ var Qoni = class {
2137
2160
  }
2138
2161
  }
2139
2162
  async webAgentJson(method, path, token, payload = {}) {
2163
+ payload.signal?.throwIfAborted();
2140
2164
  const runtimeToken = await this.productTokenFor("webagent", token, payload.requiredScopes);
2165
+ payload.signal?.throwIfAborted();
2141
2166
  const baseUrl = await this.baseUrlFor("webagent");
2142
2167
  return this.jsonRequest(
2143
2168
  baseUrl,
@@ -2148,9 +2173,11 @@ var Qoni = class {
2148
2173
  // Binary GET against the WebAgent API — artifact content downloads. JSON
2149
2174
  // error payloads still map to typed QoniErrors.
2150
2175
  async webAgentBytes(path, token, payload = {}) {
2176
+ payload.signal?.throwIfAborted();
2151
2177
  const runtimeToken = await this.productTokenFor("webagent", token, payload.requiredScopes);
2178
+ payload.signal?.throwIfAborted();
2152
2179
  const baseUrl = await this.baseUrlFor("webagent");
2153
- const response = await this.fetchWithTimeout(
2180
+ return this.fetchWithTimeout(
2154
2181
  urlWithBasePath(baseUrl, this.webAgentPath(runtimeToken, path), payload.query),
2155
2182
  {
2156
2183
  method: "GET",
@@ -2159,20 +2186,22 @@ var Qoni = class {
2159
2186
  authorization: `Bearer ${runtimeToken}`,
2160
2187
  ...payload.headers || {}
2161
2188
  }
2162
- }
2189
+ },
2190
+ async (response, signal) => {
2191
+ if (!response.ok) {
2192
+ throw errorFromPayload(
2193
+ response.status,
2194
+ await readJson(response, signal),
2195
+ `Qoni request failed with HTTP ${response.status}`
2196
+ );
2197
+ }
2198
+ return {
2199
+ bytes: await readBody(response, signal),
2200
+ mime: response.headers.get("content-type") || "application/octet-stream"
2201
+ };
2202
+ },
2203
+ payload.signal
2163
2204
  );
2164
- if (!response.ok) {
2165
- throw errorFromPayload(
2166
- response.status,
2167
- await readJson(response),
2168
- `Qoni request failed with HTTP ${response.status}`
2169
- );
2170
- }
2171
- const buffer = await response.arrayBuffer();
2172
- return {
2173
- bytes: new Uint8Array(buffer),
2174
- mime: response.headers.get("content-type") || "application/octet-stream"
2175
- };
2176
2205
  }
2177
2206
  async *webAgentSSE(path, token, payload = {}) {
2178
2207
  const runtimeToken = await this.productTokenFor("webagent", token, payload.requiredScopes);
@@ -2212,15 +2241,16 @@ var Qoni = class {
2212
2241
  });
2213
2242
  }
2214
2243
  async jsonRequest(baseUrl, pathname, options) {
2215
- const response = await this.fetchWithTimeout(
2244
+ const { response, payload } = await this.fetchWithTimeout(
2216
2245
  urlWithBasePath(baseUrl, pathname, options.query),
2217
2246
  {
2218
2247
  method: options.method,
2219
2248
  headers: requestHeaders(options),
2220
2249
  body: requestBody(options.body)
2221
- }
2250
+ },
2251
+ async (response2, signal) => ({ response: response2, payload: await readJson(response2, signal) }),
2252
+ options.signal
2222
2253
  );
2223
- const payload = await readJson(response);
2224
2254
  if (!response.ok) {
2225
2255
  throw errorFromPayload(
2226
2256
  response.status,
@@ -2270,45 +2300,55 @@ var Qoni = class {
2270
2300
  }
2271
2301
  }
2272
2302
  async *sseConnectOnce(baseUrl, pathname, options) {
2273
- const response = await this.fetchWithTimeout(
2274
- urlWithBasePath(baseUrl, pathname, options.query),
2275
- {
2276
- method: "GET",
2277
- headers: {
2278
- accept: "text/event-stream",
2279
- authorization: `Bearer ${options.token}`,
2280
- ...options.headers || {},
2281
- ...options.lastEventId ? { "last-event-id": options.lastEventId } : {}
2282
- }
2283
- },
2284
- options.signal,
2285
- { streamBody: true }
2286
- );
2287
- if (!response.ok) {
2288
- throw errorFromPayload(
2289
- response.status,
2290
- await readJson(response),
2291
- `Qoni stream failed with HTTP ${response.status}`
2292
- );
2293
- }
2294
- if (!response.body) {
2295
- throw errorFromPayload(502, void 0, "Qoni stream response has no body");
2296
- }
2297
- const reader = response.body.getReader();
2303
+ let cleanup;
2304
+ let reader;
2305
+ const onAbort = () => {
2306
+ void reader?.cancel(options.signal?.reason).catch(() => void 0);
2307
+ };
2298
2308
  const decoder = new TextDecoder();
2299
2309
  let buffer = "";
2300
2310
  try {
2311
+ const response = await this.fetchWithTimeout(
2312
+ urlWithBasePath(baseUrl, pathname, options.query),
2313
+ {
2314
+ method: "GET",
2315
+ headers: {
2316
+ accept: "text/event-stream",
2317
+ authorization: `Bearer ${options.token}`,
2318
+ ...options.headers || {},
2319
+ ...options.lastEventId ? { "last-event-id": options.lastEventId } : {}
2320
+ }
2321
+ },
2322
+ async (response2, signal) => {
2323
+ if (!response2.ok) {
2324
+ throw errorFromPayload(
2325
+ response2.status,
2326
+ await readJson(response2, signal),
2327
+ `Qoni stream failed with HTTP ${response2.status}`
2328
+ );
2329
+ }
2330
+ return response2;
2331
+ },
2332
+ options.signal,
2333
+ { streamCleanup: (dispose) => {
2334
+ cleanup = dispose;
2335
+ } }
2336
+ );
2337
+ if (!response.body) {
2338
+ throw errorFromPayload(502, void 0, "Qoni stream response has no body");
2339
+ }
2340
+ reader = response.body.getReader();
2341
+ options.signal?.addEventListener("abort", onAbort, { once: true });
2301
2342
  while (true) {
2302
- if (options.signal?.aborted) {
2303
- await reader.cancel().catch(() => void 0);
2304
- throw options.signal.reason || new DOMException("Aborted", "AbortError");
2305
- }
2343
+ options.signal?.throwIfAborted();
2306
2344
  const { done, value } = await reader.read();
2345
+ options.signal?.throwIfAborted();
2307
2346
  if (done) break;
2308
2347
  buffer += decoder.decode(value, { stream: true });
2309
2348
  const chunks = buffer.split(/\r?\n\r?\n/);
2310
2349
  buffer = chunks.pop() || "";
2311
2350
  for (const chunk of chunks) {
2351
+ options.signal?.throwIfAborted();
2312
2352
  const event = parseSSEEvent(chunk);
2313
2353
  if (event) yield event;
2314
2354
  }
@@ -2316,14 +2356,16 @@ var Qoni = class {
2316
2356
  const finalEvent = parseSSEEvent(buffer);
2317
2357
  if (finalEvent) yield finalEvent;
2318
2358
  } finally {
2319
- await reader.cancel().catch(() => void 0);
2359
+ cleanup?.();
2360
+ options.signal?.removeEventListener("abort", onAbort);
2361
+ void reader?.cancel().catch(() => void 0);
2320
2362
  try {
2321
- reader.releaseLock();
2363
+ reader?.releaseLock();
2322
2364
  } catch {
2323
2365
  }
2324
2366
  }
2325
2367
  }
2326
- async fetchWithTimeout(input, init, signal, opts) {
2368
+ async fetchWithTimeout(input, init, consume, signal, opts) {
2327
2369
  if (signal?.aborted) throw signal.reason || new DOMException("Aborted", "AbortError");
2328
2370
  const controller = new AbortController();
2329
2371
  const resolved = resolveLocalGenauthRequest(input);
@@ -2338,17 +2380,39 @@ var Qoni = class {
2338
2380
  didTimeout = true;
2339
2381
  controller.abort();
2340
2382
  }, this.timeoutMs);
2383
+ let response;
2341
2384
  try {
2342
- return await this.fetchImpl(resolved.url, { ...requestInit, signal: controller.signal });
2385
+ response = await new Promise((resolve, reject) => {
2386
+ const onAbort = () => reject(controller.signal.reason);
2387
+ controller.signal.addEventListener("abort", onAbort, { once: true });
2388
+ Promise.resolve().then(() => {
2389
+ controller.signal.throwIfAborted();
2390
+ return this.fetchImpl(resolved.url, { ...requestInit, signal: controller.signal });
2391
+ }).then((response2) => {
2392
+ if (controller.signal.aborted) {
2393
+ void response2.body?.cancel(controller.signal.reason).catch(() => void 0);
2394
+ reject(controller.signal.reason);
2395
+ } else {
2396
+ resolve(response2);
2397
+ }
2398
+ }, reject).finally(() => controller.signal.removeEventListener("abort", onAbort));
2399
+ });
2400
+ const result = await consume(response, controller.signal);
2401
+ controller.signal.throwIfAborted();
2402
+ return result;
2343
2403
  } catch (error) {
2344
- if (isAbortError(error) && didTimeout || isConnectTimeoutError(error)) {
2404
+ void response?.body?.cancel().catch(() => void 0);
2405
+ if (signal?.aborted) throw signal.reason;
2406
+ if (didTimeout || isConnectTimeoutError(error)) {
2345
2407
  throw timeoutError(error);
2346
2408
  }
2347
2409
  if (isFetchNetworkError(error)) throw networkError(error);
2348
2410
  throw error;
2349
2411
  } finally {
2350
2412
  clearTimeout(timeout);
2351
- if (!opts?.streamBody) signal?.removeEventListener("abort", abortFromSignal);
2413
+ const cleanup = () => signal?.removeEventListener("abort", abortFromSignal);
2414
+ if (opts?.streamCleanup) opts.streamCleanup(cleanup);
2415
+ else cleanup();
2352
2416
  }
2353
2417
  }
2354
2418
  async baseUrlFor(service) {
@@ -2404,7 +2468,7 @@ var Qoni = class {
2404
2468
  pathname,
2405
2469
  {}
2406
2470
  );
2407
- const response = await this.fetchWithTimeout(
2471
+ const { response, payload } = await this.fetchWithTimeout(
2408
2472
  urlWithBasePath(this.host, pathname),
2409
2473
  {
2410
2474
  method: "GET",
@@ -2412,9 +2476,9 @@ var Qoni = class {
2412
2476
  accept: "application/json",
2413
2477
  ...headers
2414
2478
  }
2415
- }
2479
+ },
2480
+ async (response2, signal) => ({ response: response2, payload: await readJson(response2, signal) })
2416
2481
  );
2417
- const payload = await readJson(response);
2418
2482
  if (!response.ok) {
2419
2483
  throw errorFromPayload(
2420
2484
  response.status,
@@ -2508,18 +2572,52 @@ function requestBody(body) {
2508
2572
  function isBodyInit(value) {
2509
2573
  return typeof value === "string" || value instanceof ArrayBuffer || value instanceof Blob || value instanceof FormData || value instanceof URLSearchParams;
2510
2574
  }
2511
- async function readJson(response) {
2575
+ async function readBody(response, signal) {
2576
+ if (!response.body) {
2577
+ signal.throwIfAborted();
2578
+ return new Uint8Array();
2579
+ }
2580
+ const reader = response.body.getReader();
2581
+ const onAbort = () => {
2582
+ void reader.cancel(signal.reason).catch(() => void 0);
2583
+ };
2584
+ signal.addEventListener("abort", onAbort, { once: true });
2585
+ try {
2586
+ const chunks = [];
2587
+ let size = 0;
2588
+ for (; ; ) {
2589
+ signal.throwIfAborted();
2590
+ const { done, value } = await reader.read();
2591
+ signal.throwIfAborted();
2592
+ if (done) break;
2593
+ chunks.push(value);
2594
+ size += value.byteLength;
2595
+ }
2596
+ const bytes = new Uint8Array(size);
2597
+ let offset = 0;
2598
+ for (const chunk of chunks) {
2599
+ bytes.set(chunk, offset);
2600
+ offset += chunk.byteLength;
2601
+ }
2602
+ return bytes;
2603
+ } finally {
2604
+ signal.removeEventListener("abort", onAbort);
2605
+ void reader.cancel().catch(() => void 0);
2606
+ reader.releaseLock();
2607
+ }
2608
+ }
2609
+ async function readJson(response, signal) {
2512
2610
  const contentType = response.headers.get("content-type")?.toLowerCase() || "";
2513
- if (contentType.includes("ndjson") || contentType.includes("jsonl")) {
2514
- const body = await response.text();
2515
- if (!body.trim()) return [];
2516
- try {
2611
+ const body = new TextDecoder().decode(await readBody(response, signal));
2612
+ try {
2613
+ if (contentType.includes("ndjson") || contentType.includes("jsonl")) {
2614
+ if (!body.trim()) return [];
2517
2615
  return body.split(/\r?\n/).filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
2518
- } catch {
2519
- return void 0;
2520
2616
  }
2617
+ return JSON.parse(body);
2618
+ } catch {
2619
+ return void 0;
2521
2620
  }
2522
- return response.json().catch(() => void 0);
2523
2621
  }
2524
2622
  function normalizeResponse(payload, _service) {
2525
2623
  if (isJsonObject(payload) && ("data" in payload || "meta" in payload)) {
@@ -2762,6 +2860,7 @@ function isRetryableStreamError(err) {
2762
2860
  if (err && typeof err === "object") {
2763
2861
  const e = err;
2764
2862
  if (e.name === "AbortError") return false;
2863
+ if (e.status === 408) return true;
2765
2864
  if (typeof e.retryable === "boolean") return e.retryable;
2766
2865
  if (typeof e.status === "number") {
2767
2866
  return e.status >= 500 || e.status === 408 || e.status === 429;
@@ -2868,9 +2967,6 @@ function normalizeDelegateAgentTokenResponse(response) {
2868
2967
  }
2869
2968
  };
2870
2969
  }
2871
- function isAbortError(error) {
2872
- return error instanceof DOMException && error.name === "AbortError";
2873
- }
2874
2970
  function isConnectTimeoutError(error) {
2875
2971
  const cause = errorCause(error);
2876
2972
  return errorCode(cause) === "UND_ERR_CONNECT_TIMEOUT" || errorName(cause) === "ConnectTimeoutError";