@rulvar/openai 1.27.0 → 1.29.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/dist/index.d.ts CHANGED
@@ -221,9 +221,17 @@ declare function normalizeOpenAiUsage(raw: Record<string, unknown> | undefined):
221
221
  * output array, never the output_text aggregate. Raw output items ride
222
222
  * finish.providerMetadata.openai.outputItems so the runtime can retain
223
223
  * reasoning items as provider-raw parts.
224
+ *
225
+ * A stream that drains without any response terminal event
226
+ * (`response.completed`, `response.incomplete`, `response.failed`, or
227
+ * `error`) is a truncated wire read: the mapper fails closed with one
228
+ * retryable transport error instead of ending silently, unless
229
+ * `options.signal` shows the caller requested the abort (the documented
230
+ * exception that ends a stream without a terminal event).
224
231
  */
225
232
  declare function mapResponsesStream(stream: AsyncIterable<ResponsesStreamEvent>, ids: OpenAiIdMap, options?: {
226
233
  effortDownmapped?: boolean;
234
+ signal?: AbortSignal;
227
235
  }): AsyncGenerator<ChatEvent, void>;
228
236
  /** Projects SDK/API errors into the retryable WireError vocabulary. */
229
237
  declare function openAiErrorToWire(error: unknown): WireError;
@@ -239,7 +247,17 @@ declare function buildChatCompletionsParams(req: ChatRequest, ids: OpenAiIdMap):
239
247
  * Delta-patched chunk assembly for the degraded path; yields each
240
248
  * canonical event as its chunk is consumed (same live-streaming contract
241
249
  * as mapResponsesStream).
250
+ *
251
+ * The chat dialect has no explicit terminal frame at this layer: the
252
+ * only completion signal is a `finish_reason` on the last choice chunk.
253
+ * A stream that drains without one is a truncated wire read, so the
254
+ * mapper fails closed with one retryable transport error (after
255
+ * forwarding any usage the provider did report, which was still paid
256
+ * for) instead of synthesizing a `stop` finish, unless `options.signal`
257
+ * shows the caller requested the abort.
242
258
  */
243
- declare function mapChatCompletionsStream(stream: AsyncIterable<Record<string, unknown>>, ids: OpenAiIdMap): AsyncGenerator<ChatEvent, void>;
259
+ declare function mapChatCompletionsStream(stream: AsyncIterable<Record<string, unknown>>, ids: OpenAiIdMap, options?: {
260
+ signal?: AbortSignal;
261
+ }): AsyncGenerator<ChatEvent, void>;
244
262
  //#endregion
245
263
  export { CONSERVATIVE_COMPATIBLE_CAPS, OPENAI_MODELS, OPENAI_PRICING, type OpenAiAdapterOptions, type OpenAiClientLike, type OpenAiCompatibleConfig, OpenAiIdMap, type OpenAiModelInfo, type OpenAiSdkOptions, type ResponsesStreamEvent, type V1190CacheAudit, auditV1190CacheJournal, buildChatCompletionsParams, buildResponsesParams, mapChatCompletionsStream, mapOpenAiEffort, mapResponsesStream, normalizeOpenAiUsage, openAiErrorToWire, openAiModelInfo, openai, openaiCompatible, undoV1190CacheDoubleCount };
package/dist/index.js CHANGED
@@ -370,6 +370,13 @@ function normalizeOpenAiUsage(raw) {
370
370
  * output array, never the output_text aggregate. Raw output items ride
371
371
  * finish.providerMetadata.openai.outputItems so the runtime can retain
372
372
  * reasoning items as provider-raw parts.
373
+ *
374
+ * A stream that drains without any response terminal event
375
+ * (`response.completed`, `response.incomplete`, `response.failed`, or
376
+ * `error`) is a truncated wire read: the mapper fails closed with one
377
+ * retryable transport error instead of ending silently, unless
378
+ * `options.signal` shows the caller requested the abort (the documented
379
+ * exception that ends a stream without a terminal event).
373
380
  */
374
381
  async function* mapResponsesStream(stream, ids, options) {
375
382
  const callIdByItemId = /* @__PURE__ */ new Map();
@@ -487,6 +494,16 @@ async function* mapResponsesStream(stream, ids, options) {
487
494
  return;
488
495
  default: break;
489
496
  }
497
+ if (options?.signal?.aborted === true) return;
498
+ yield {
499
+ type: "error",
500
+ error: {
501
+ code: "agent",
502
+ message: "Responses stream ended without a response terminal event (response.completed, response.incomplete, response.failed, or error); the read was truncated",
503
+ retryable: true,
504
+ data: { kind: "transport" }
505
+ }
506
+ };
490
507
  }
491
508
  /**
492
509
  * Classifies a terminal stream failure (`response.failed` /
@@ -518,6 +535,24 @@ function failedResponseError(code, message) {
518
535
  }
519
536
  };
520
537
  }
538
+ /**
539
+ * Largest delay a Node timer represents exactly; a bigger value would
540
+ * overflow into an almost immediate (storm inducing) retry.
541
+ */
542
+ const MAX_RETRY_AFTER_MS = 2147483647;
543
+ /**
544
+ * Parses a Retry-After header into milliseconds. Only the delta
545
+ * seconds form is honored: the HTTP date form and any other
546
+ * unparsable value return undefined so the engine falls back to its
547
+ * computed policy backoff instead of receiving NaN, and a huge but
548
+ * finite value is clamped to the Node timer maximum (v1.28.0 review
549
+ * P2).
550
+ */
551
+ function retryAfterMsFrom(header) {
552
+ const seconds = Number(header);
553
+ if (!Number.isFinite(seconds) || seconds < 0) return;
554
+ return Math.min(Math.round(seconds * 1e3), MAX_RETRY_AFTER_MS);
555
+ }
521
556
  /** Projects SDK/API errors into the retryable WireError vocabulary. */
522
557
  function openAiErrorToWire(error) {
523
558
  const record = error;
@@ -528,7 +563,7 @@ function openAiErrorToWire(error) {
528
563
  const headers = record.headers;
529
564
  if (headers !== void 0 && headers !== null) {
530
565
  const value = typeof headers.get === "function" ? headers.get("retry-after") ?? void 0 : headers["retry-after"];
531
- if (value !== void 0) retryAfterMs = Number(value) * 1e3;
566
+ if (value !== void 0) retryAfterMs = retryAfterMsFrom(value);
532
567
  }
533
568
  return {
534
569
  code: "agent",
@@ -635,10 +670,19 @@ function buildChatCompletionsParams(req, ids) {
635
670
  * Delta-patched chunk assembly for the degraded path; yields each
636
671
  * canonical event as its chunk is consumed (same live-streaming contract
637
672
  * as mapResponsesStream).
673
+ *
674
+ * The chat dialect has no explicit terminal frame at this layer: the
675
+ * only completion signal is a `finish_reason` on the last choice chunk.
676
+ * A stream that drains without one is a truncated wire read, so the
677
+ * mapper fails closed with one retryable transport error (after
678
+ * forwarding any usage the provider did report, which was still paid
679
+ * for) instead of synthesizing a `stop` finish, unless `options.signal`
680
+ * shows the caller requested the abort.
638
681
  */
639
- async function* mapChatCompletionsStream(stream, ids) {
682
+ async function* mapChatCompletionsStream(stream, ids, options) {
640
683
  const pendingCalls = /* @__PURE__ */ new Map();
641
684
  let finishReason;
685
+ let sawUsageChunk = false;
642
686
  let usage = {
643
687
  inputTokens: 0,
644
688
  outputTokens: 0,
@@ -683,6 +727,7 @@ async function* mapChatCompletionsStream(stream, ids) {
683
727
  if (typeof choice?.finish_reason === "string") finishReason = choice.finish_reason;
684
728
  const chunkUsage = chunk.usage;
685
729
  if (chunkUsage !== void 0 && chunkUsage !== null) {
730
+ sawUsageChunk = true;
686
731
  const promptDetails = chunkUsage.prompt_tokens_details;
687
732
  const promptTokens = typeof chunkUsage.prompt_tokens === "number" ? chunkUsage.prompt_tokens : 0;
688
733
  const clamped = clampCacheSubsets(promptTokens, typeof promptDetails?.cached_tokens === "number" ? promptDetails.cached_tokens : 0, typeof promptDetails?.cache_write_tokens === "number" ? promptDetails.cache_write_tokens : 0);
@@ -694,6 +739,23 @@ async function* mapChatCompletionsStream(stream, ids) {
694
739
  };
695
740
  }
696
741
  }
742
+ if (finishReason === void 0) {
743
+ if (options?.signal?.aborted === true) return;
744
+ if (sawUsageChunk) yield {
745
+ type: "usage",
746
+ usage
747
+ };
748
+ yield {
749
+ type: "error",
750
+ error: {
751
+ code: "agent",
752
+ message: "chat completions stream ended without a finish_reason; the read was truncated",
753
+ retryable: true,
754
+ data: { kind: "transport" }
755
+ }
756
+ };
757
+ return;
758
+ }
697
759
  for (const [, pending] of pendingCalls) {
698
760
  let args = {};
699
761
  try {
@@ -768,14 +830,17 @@ function openai(options = {}) {
768
830
  yield* mapResponsesStream(await client.responses.create({
769
831
  ...params,
770
832
  stream: true
771
- }, signal === void 0 ? void 0 : { signal }), ids, { effortDownmapped });
833
+ }, signal === void 0 ? void 0 : { signal }), ids, {
834
+ effortDownmapped,
835
+ ...signal === void 0 ? {} : { signal }
836
+ });
772
837
  } else {
773
838
  const params = buildChatCompletionsParams(req, ids);
774
839
  yield* mapChatCompletionsStream(await client.chat.completions.create({
775
840
  ...params,
776
841
  stream: true,
777
842
  stream_options: { include_usage: true }
778
- }, signal === void 0 ? void 0 : { signal }), ids);
843
+ }, signal === void 0 ? void 0 : { signal }), ids, signal === void 0 ? void 0 : { signal });
779
844
  }
780
845
  } catch (thrown) {
781
846
  if (signal?.aborted !== true) yield {
@@ -842,7 +907,7 @@ function openaiCompatible(cfg) {
842
907
  ...params,
843
908
  stream: true,
844
909
  stream_options: { include_usage: true }
845
- }, signal === void 0 ? void 0 : { signal }), ids);
910
+ }, signal === void 0 ? void 0 : { signal }), ids, signal === void 0 ? void 0 : { signal });
846
911
  } catch (thrown) {
847
912
  if (signal?.aborted !== true) yield {
848
913
  type: "error",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/openai",
3
- "version": "1.27.0",
3
+ "version": "1.29.0",
4
4
  "description": "Rulvar first-class provider adapter for the OpenAI Responses API, plus the openaiCompatible factory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -23,13 +23,13 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "openai": "^6.45.0",
26
- "@rulvar/core": "1.27.0"
26
+ "@rulvar/core": "1.29.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.20.0",
30
30
  "tsdown": "^0.22.3",
31
31
  "typescript": "~6.0.3",
32
- "@rulvar/testing": "1.27.0"
32
+ "@rulvar/testing": "1.29.0"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",