@rulvar/openai 1.26.0 → 1.28.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` /
@@ -635,10 +652,19 @@ function buildChatCompletionsParams(req, ids) {
635
652
  * Delta-patched chunk assembly for the degraded path; yields each
636
653
  * canonical event as its chunk is consumed (same live-streaming contract
637
654
  * as mapResponsesStream).
655
+ *
656
+ * The chat dialect has no explicit terminal frame at this layer: the
657
+ * only completion signal is a `finish_reason` on the last choice chunk.
658
+ * A stream that drains without one is a truncated wire read, so the
659
+ * mapper fails closed with one retryable transport error (after
660
+ * forwarding any usage the provider did report, which was still paid
661
+ * for) instead of synthesizing a `stop` finish, unless `options.signal`
662
+ * shows the caller requested the abort.
638
663
  */
639
- async function* mapChatCompletionsStream(stream, ids) {
664
+ async function* mapChatCompletionsStream(stream, ids, options) {
640
665
  const pendingCalls = /* @__PURE__ */ new Map();
641
666
  let finishReason;
667
+ let sawUsageChunk = false;
642
668
  let usage = {
643
669
  inputTokens: 0,
644
670
  outputTokens: 0,
@@ -683,6 +709,7 @@ async function* mapChatCompletionsStream(stream, ids) {
683
709
  if (typeof choice?.finish_reason === "string") finishReason = choice.finish_reason;
684
710
  const chunkUsage = chunk.usage;
685
711
  if (chunkUsage !== void 0 && chunkUsage !== null) {
712
+ sawUsageChunk = true;
686
713
  const promptDetails = chunkUsage.prompt_tokens_details;
687
714
  const promptTokens = typeof chunkUsage.prompt_tokens === "number" ? chunkUsage.prompt_tokens : 0;
688
715
  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 +721,23 @@ async function* mapChatCompletionsStream(stream, ids) {
694
721
  };
695
722
  }
696
723
  }
724
+ if (finishReason === void 0) {
725
+ if (options?.signal?.aborted === true) return;
726
+ if (sawUsageChunk) yield {
727
+ type: "usage",
728
+ usage
729
+ };
730
+ yield {
731
+ type: "error",
732
+ error: {
733
+ code: "agent",
734
+ message: "chat completions stream ended without a finish_reason; the read was truncated",
735
+ retryable: true,
736
+ data: { kind: "transport" }
737
+ }
738
+ };
739
+ return;
740
+ }
697
741
  for (const [, pending] of pendingCalls) {
698
742
  let args = {};
699
743
  try {
@@ -768,14 +812,17 @@ function openai(options = {}) {
768
812
  yield* mapResponsesStream(await client.responses.create({
769
813
  ...params,
770
814
  stream: true
771
- }, signal === void 0 ? void 0 : { signal }), ids, { effortDownmapped });
815
+ }, signal === void 0 ? void 0 : { signal }), ids, {
816
+ effortDownmapped,
817
+ ...signal === void 0 ? {} : { signal }
818
+ });
772
819
  } else {
773
820
  const params = buildChatCompletionsParams(req, ids);
774
821
  yield* mapChatCompletionsStream(await client.chat.completions.create({
775
822
  ...params,
776
823
  stream: true,
777
824
  stream_options: { include_usage: true }
778
- }, signal === void 0 ? void 0 : { signal }), ids);
825
+ }, signal === void 0 ? void 0 : { signal }), ids, signal === void 0 ? void 0 : { signal });
779
826
  }
780
827
  } catch (thrown) {
781
828
  if (signal?.aborted !== true) yield {
@@ -842,7 +889,7 @@ function openaiCompatible(cfg) {
842
889
  ...params,
843
890
  stream: true,
844
891
  stream_options: { include_usage: true }
845
- }, signal === void 0 ? void 0 : { signal }), ids);
892
+ }, signal === void 0 ? void 0 : { signal }), ids, signal === void 0 ? void 0 : { signal });
846
893
  } catch (thrown) {
847
894
  if (signal?.aborted !== true) yield {
848
895
  type: "error",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/openai",
3
- "version": "1.26.0",
3
+ "version": "1.28.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.26.0"
26
+ "@rulvar/core": "1.28.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.26.0"
32
+ "@rulvar/testing": "1.28.0"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",