prefix-safe-json 0.4.3 → 0.4.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.
@@ -1,20 +1,122 @@
1
1
  import { OpenAICompatibleStreamAdapter } from "./openai-compatible.js";
2
- import { CONTENT_FILTERED_DIAGNOSTIC_CODE } from "../coordinator/diagnostic-codes.js";
2
+ import { CONTENT_FILTERED_DIAGNOSTIC_CODE, TOOL_ARGUMENTS_AFTER_END_DIAGNOSTIC_CODE, DUPLICATE_TOOL_END_DIAGNOSTIC_CODE, INVALID_CHOICE_INDEX_DIAGNOSTIC_CODE, DUPLICATE_CHOICE_INDEX_DIAGNOSTIC_CODE, } from "../coordinator/diagnostic-codes.js";
3
+ // Reason priority for aggregating legacy per-choice terminal reasons into
4
+ // the ONE stream-wide reason, at adapter.finish() time. Mirrors
5
+ // OpenAICompatibleStreamAdapter's own REASON_PRIORITY exactly (same
6
+ // values, same order, same "worst wins" semantics: provider_error
7
+ // strongest, complete weakest) - duplicated locally rather than
8
+ // cross-imported so this file's production scope stays self-contained
9
+ // (P4.3: src/providers/openai.ts only, openai-compatible.ts unmodified).
10
+ const LEGACY_REASON_PRIORITY = [
11
+ "provider_error",
12
+ "unknown",
13
+ "network_error",
14
+ "length",
15
+ "cancelled",
16
+ "complete",
17
+ ];
18
+ function mapLegacyFinishReason(providerReason) {
19
+ if (providerReason === "stop" || providerReason === "function_call" || providerReason === "tool_calls")
20
+ return "complete";
21
+ if (providerReason === "length")
22
+ return "length";
23
+ if (providerReason === "cancelled")
24
+ return "cancelled";
25
+ return "unknown";
26
+ }
3
27
  export class OpenAIStreamAdapter {
4
28
  provider = "openai";
5
29
  sequence = 0;
6
30
  // Delegating tool_calls format to compatible adapter
7
31
  compatibleAdapter = new OpenAICompatibleStreamAdapter();
8
- // For function_call (legacy)
32
+ // For the plural, new-style Chat Completions tool_calls format.
33
+ // Sticky stream-mode flag: once ANY chunk in this stream shows genuine
34
+ // plural tool_calls evidence (never inferred merely from finish_reason -
35
+ // see the "no prior plural tool call" regression), every later Chat
36
+ // Completions chunk continues through OpenAICompatibleStreamAdapter for
37
+ // the rest of the stream's lifetime, including a later chunk whose own
38
+ // delta carries no tool_calls at all - most commonly a separate
39
+ // empty-delta finish_reason terminal chunk (see
40
+ // test/integration/openai-official-sdk-lifecycle.test.ts for the exact
41
+ // shape openai@7.8.0's own SDK parser exposes for it). Before this flag
42
+ // existed, that terminal chunk failed the old per-chunk-only delegation
43
+ // check and fell through into the unrelated legacy function_call loop
44
+ // below, so the compatible adapter's own tracked call never received its
45
+ // tool_call_end (see test/providers/openai-tool-calls-terminal-routing.test.ts).
46
+ //
47
+ // Only ever set once this.hasLegacyFunctionCall is confirmed false for
48
+ // this stream - see push()'s own format-conflict check just above where
49
+ // this flag is read/written. A stream can only ever genuinely commit to
50
+ // ONE of the two Chat Completions tool-call shapes: conflicting evidence
51
+ // for the other format fails the whole stream closed instead of
52
+ // silently delegating to it (empirically confirmed necessary - a plural
53
+ // tool_calls chunk arriving after a legacy function_call had already
54
+ // started its own tracked call would otherwise delegate straight to
55
+ // OpenAICompatibleStreamAdapter, which has no knowledge of the outer
56
+ // adapter's legacy-tracked call and would happily track and correctly
57
+ // close the injected plural evidence as its own, fully independent,
58
+ // genuinely executable authority - see the "SYMMETRIC CASE" / "FORMAT-CONFLICT"
59
+ // regressions).
60
+ hasCompatibleToolCalls = false;
61
+ // For function_call (legacy). P4.3: choice-scoped identity and terminal
62
+ // ownership - the pre-existing design used ONE fixed, global sourceKey
63
+ // ("legacy-function-call") for every choice, so a genuine n>1 stream's
64
+ // independent per-choice function_call evidence could merge into one
65
+ // parser/identity (cross-choice argument/name injection - E-1), and a
66
+ // later, entirely separate choice's evidence could collide with and
67
+ // poison an earlier, already-legitimate choice's call (E-2). Ports the
68
+ // SAME choice-local architecture already proven for the plural path in
69
+ // OpenAICompatibleStreamAdapter (see that file's own class-level
70
+ // lifecycle-contract doc) onto this legacy singular wire shape, which
71
+ // has no array of tool_calls to key off - so per-choice state is
72
+ // tracked directly here instead of delegating.
73
+ //
74
+ // hasLegacyFunctionCall keeps its ORIGINAL meaning unchanged: "has ANY
75
+ // choice in this stream ever shown legacy function_call evidence" -
76
+ // still used by the mixed-format conflict check above and by finish()'s
77
+ // own branch-selection between this path and compatibleAdapter's.
9
78
  hasLegacyFunctionCall = false;
10
- legacySourceKey = "legacy-function-call";
79
+ // choiceIndex -> is THAT choice's own legacy call's argument stream
80
+ // currently open (started, not yet closed by ITS OWN finish_reason).
81
+ legacyChoiceOpen = new Map();
82
+ // choiceIndex -> that choice's own first-recorded terminal StreamEndReason.
83
+ // Never overwritten once set for a given choice - see Phase 10 handling
84
+ // below. Read by finish() to aggregate one stream-wide reason.
85
+ legacyChoiceTerminalReasons = new Map();
86
+ // The same choices' own RAW (pre-normalization) finish_reason strings,
87
+ // keyed identically - observability only, mirrored onto the aggregated
88
+ // provider_stream_end's own providerReason (see decide.ts's own doc
89
+ // comment: "never consulted by the decision logic itself").
90
+ legacyChoiceTerminalProviderReasons = new Map();
11
91
  // For Responses API
12
92
  accumulatedArguments = new Map();
13
93
  receivedDeltas = new Set();
94
+ // Item-local terminal state (P4.1 / F-2): every output-item id that has
95
+ // already received its own `response.output_item.done`. Uses the exact
96
+ // same identity as this adapter's own `output-item:{id}` sourceKey - no
97
+ // second identity system. `output_item.done` closes the argument stream
98
+ // choice-locally (tool_call_end), but that alone does not stop the
99
+ // coordinator from silently merging a LATER, real
100
+ // `response.function_call_arguments.delta`/`.done` for the SAME item_id
101
+ // into the same still-"collecting" call (handleCallEnd never changes
102
+ // call.status - see coordinator.ts). Recognized argument evidence for an
103
+ // item already in this set is therefore hardened below instead of merged.
104
+ doneOutputItemIds = new Set();
14
105
  finished = false;
15
106
  push(rawEvent) {
16
- if (this.finished)
17
- return [];
107
+ // No `finished` early return here: silently dropping every event after
108
+ // the first terminal meant a late argument delta, a conflicting or
109
+ // duplicate finish, or a provider error that arrived even one raw event
110
+ // late never reached the coordinator at all - not even as a diagnostic -
111
+ // so an already-decided call's authority could never be revoked by it.
112
+ // Every case below that has ITS OWN meaningful post-terminal handling
113
+ // (a genuine argument delta, a second output_item.added, a second
114
+ // stream-terminal) still fires exactly as if the stream were open; the
115
+ // coordinator's own `isFinished` gate (coordinator.ts's `push()`) is
116
+ // what turns each of those into a sticky, stream-wide
117
+ // AUTHORITY_PROTOCOL_VIOLATION_CODES diagnostic once it actually
118
+ // receives them. Mirrors the fix already applied to AiSdkStreamAdapter
119
+ // for the same invariant (GHSA-3xpw-9694-2xxp).
18
120
  const events = [];
19
121
  if (!rawEvent || typeof rawEvent !== "object") {
20
122
  events.push({
@@ -63,44 +165,84 @@ export class OpenAIStreamAdapter {
63
165
  });
64
166
  }
65
167
  else if (chunk.type === "response.function_call_arguments.delta" && chunk.item_id && chunk.delta) {
66
- this.receivedDeltas.add(chunk.item_id);
67
- const acc = this.accumulatedArguments.get(chunk.item_id) ?? "";
68
- this.accumulatedArguments.set(chunk.item_id, acc + chunk.delta);
69
- events.push({
70
- type: "tool_call_arguments_delta",
71
- sequence: ++this.sequence,
72
- provider: this.provider,
73
- callRef: { sourceKey: `output-item:${chunk.item_id}` },
74
- delta: chunk.delta,
75
- });
76
- }
77
- else if (chunk.type === "response.function_call_arguments.done" && chunk.item_id && chunk.arguments !== undefined) {
78
- const acc = this.accumulatedArguments.get(chunk.item_id) ?? "";
79
- const hasDeltas = this.receivedDeltas.has(chunk.item_id);
80
- if (!hasDeltas && chunk.arguments.length > 0) {
81
- // No deltas, just final arguments.
168
+ if (this.doneOutputItemIds.has(chunk.item_id)) {
169
+ // Item-local post-terminal evidence (P4.1 / F-2): this exact
170
+ // item already recorded its own output_item.done. Never merge
171
+ // further argument evidence as normal - that would silently
172
+ // mutate (and, if left structurally unclosed at done time,
173
+ // silently CLOSE) an already-ended item's value with no record
174
+ // of the anomaly. Same diagnostic code AiSdkStreamAdapter/
175
+ // OpenAICompatibleStreamAdapter already use for materially
176
+ // identical semantics ("tool argument evidence arrived after
177
+ // that call's end"), attributed to the item's own real,
178
+ // already-tool_call_start'd sourceKey so the coordinator
179
+ // resolves it to the actual call, not a phantom identity.
180
+ events.push({
181
+ type: "provider_diagnostic",
182
+ sequence: ++this.sequence,
183
+ provider: this.provider,
184
+ callRef: { sourceKey: `output-item:${chunk.item_id}` },
185
+ code: TOOL_ARGUMENTS_AFTER_END_DIAGNOSTIC_CODE,
186
+ severity: "error",
187
+ message: `Tool call argument evidence for item ${chunk.item_id} arrived after that item's own output_item.done`,
188
+ });
189
+ }
190
+ else {
191
+ this.receivedDeltas.add(chunk.item_id);
192
+ const acc = this.accumulatedArguments.get(chunk.item_id) ?? "";
193
+ this.accumulatedArguments.set(chunk.item_id, acc + chunk.delta);
82
194
  events.push({
83
195
  type: "tool_call_arguments_delta",
84
196
  sequence: ++this.sequence,
85
197
  provider: this.provider,
86
198
  callRef: { sourceKey: `output-item:${chunk.item_id}` },
87
- delta: chunk.arguments,
199
+ delta: chunk.delta,
88
200
  });
89
- this.accumulatedArguments.set(chunk.item_id, chunk.arguments);
90
201
  }
91
- else if (hasDeltas && chunk.arguments !== acc) {
202
+ }
203
+ else if (chunk.type === "response.function_call_arguments.done" && chunk.item_id && chunk.arguments !== undefined) {
204
+ if (this.doneOutputItemIds.has(chunk.item_id)) {
205
+ // Same item-local post-terminal hardening as the `.delta` branch
206
+ // above, for the `.done` (final-arguments) shape specifically.
92
207
  events.push({
93
208
  type: "provider_diagnostic",
94
209
  sequence: ++this.sequence,
95
210
  provider: this.provider,
96
211
  callRef: { sourceKey: `output-item:${chunk.item_id}` },
97
- code: "E_FINAL_ARGUMENTS_CONFLICT",
212
+ code: TOOL_ARGUMENTS_AFTER_END_DIAGNOSTIC_CODE,
98
213
  severity: "error",
99
- message: "Final arguments do not match accumulated deltas",
214
+ message: `Tool call argument evidence for item ${chunk.item_id} arrived after that item's own output_item.done`,
100
215
  });
101
216
  }
217
+ else {
218
+ const acc = this.accumulatedArguments.get(chunk.item_id) ?? "";
219
+ const hasDeltas = this.receivedDeltas.has(chunk.item_id);
220
+ if (!hasDeltas && chunk.arguments.length > 0) {
221
+ // No deltas, just final arguments.
222
+ events.push({
223
+ type: "tool_call_arguments_delta",
224
+ sequence: ++this.sequence,
225
+ provider: this.provider,
226
+ callRef: { sourceKey: `output-item:${chunk.item_id}` },
227
+ delta: chunk.arguments,
228
+ });
229
+ this.accumulatedArguments.set(chunk.item_id, chunk.arguments);
230
+ }
231
+ else if (hasDeltas && chunk.arguments !== acc) {
232
+ events.push({
233
+ type: "provider_diagnostic",
234
+ sequence: ++this.sequence,
235
+ provider: this.provider,
236
+ callRef: { sourceKey: `output-item:${chunk.item_id}` },
237
+ code: "E_FINAL_ARGUMENTS_CONFLICT",
238
+ severity: "error",
239
+ message: "Final arguments do not match accumulated deltas",
240
+ });
241
+ }
242
+ }
102
243
  }
103
244
  else if (chunk.type === "response.output_item.done" && chunk.item?.id) {
245
+ this.doneOutputItemIds.add(chunk.item.id);
104
246
  events.push({
105
247
  type: "tool_call_end",
106
248
  sequence: ++this.sequence,
@@ -177,67 +319,263 @@ export class OpenAIStreamAdapter {
177
319
  }
178
320
  return events;
179
321
  }
180
- // Check if it's the standard tool_calls format
181
- if (chunk.choices?.[0]?.delta?.tool_calls !== undefined) {
322
+ // This chunk's OWN evidence for each Chat Completions tool-call format,
323
+ // independent of any mode already committed to by an earlier chunk.
324
+ // Checked across every choice, not only choices[0]: a multi-choice
325
+ // (n>1) stream's tool call can legitimately live in a non-zero choice.
326
+ const hasPluralEvidence = Array.isArray(chunk.choices) && chunk.choices.some(choice => choice.delta?.tool_calls !== undefined);
327
+ const hasLegacyEvidence = Array.isArray(chunk.choices) && chunk.choices.some(choice => choice.delta?.function_call !== undefined);
328
+ // A stream can only ever genuinely be ONE Chat Completions tool-call
329
+ // format. Conflicting evidence - both shapes in the SAME chunk, or one
330
+ // format's evidence arriving after the OTHER was already committed to
331
+ // by an earlier chunk - must never silently pick a winner while
332
+ // letting the original, already-legitimate call keep its authority
333
+ // (that was the exact gap the earlier hasLegacyFunctionCall-only cross
334
+ // check left open - see the SYMMETRIC CASE / plural-then-singular
335
+ // regressions). It fails the WHOLE stream closed instead: same as
336
+ // every other meaningful evidence this method recognizes, there is no
337
+ // `if (this.finished) return` guard here, so this branch also runs -
338
+ // and correctly revokes an already-live, not-yet-consumed decision -
339
+ // when the conflicting evidence arrives AFTER a clean terminal already
340
+ // fired. That revocation is the coordinator's own isFinished protocol
341
+ // converting this second stream-end-shaped event into its sticky,
342
+ // stream-wide diagnostic (GHSA-3xpw-9694-2xxp) - this adapter does not
343
+ // track post-conflict state itself beyond the ordinary `finished` flag.
344
+ if ((hasPluralEvidence && hasLegacyEvidence) || (hasPluralEvidence && this.hasLegacyFunctionCall) || (hasLegacyEvidence && this.hasCompatibleToolCalls)) {
345
+ events.push({
346
+ type: "provider_stream_end",
347
+ sequence: ++this.sequence,
348
+ provider: this.provider,
349
+ reason: "provider_error",
350
+ providerReason: "mixed_tool_call_formats",
351
+ });
352
+ this.finished = true;
353
+ return events;
354
+ }
355
+ if (hasPluralEvidence) {
356
+ this.hasCompatibleToolCalls = true;
357
+ }
358
+ if (this.hasCompatibleToolCalls) {
182
359
  // Delegate to OpenAICompatibleStreamAdapter
183
360
  const compatibleEvents = this.compatibleAdapter.push(rawEvent);
184
361
  // Map provider name to "openai"
185
362
  return compatibleEvents.map(e => ({ ...e, provider: this.provider, sequence: ++this.sequence }));
186
363
  }
187
- // Handle legacy function_call format
364
+ // Handle legacy function_call format (P4.3: choice-scoped - see the
365
+ // field-level doc comments above for the full rationale).
188
366
  if (Array.isArray(chunk.choices)) {
367
+ // Duplicate-index detection within one chunk - mirrors
368
+ // OpenAICompatibleStreamAdapter's own choiceCounts pre-pass exactly,
369
+ // reused because the underlying choices[]/index wire structure is
370
+ // identical between plural and legacy singular tool-call shapes.
371
+ const choiceCounts = new Map();
189
372
  for (const choice of chunk.choices) {
190
- if (choice.delta?.function_call) {
191
- const fc = choice.delta.function_call;
192
- if (!this.hasLegacyFunctionCall) {
193
- this.hasLegacyFunctionCall = true;
373
+ if (Number.isInteger(choice.index) && choice.index >= 0) {
374
+ const choiceIndex = choice.index;
375
+ choiceCounts.set(choiceIndex, (choiceCounts.get(choiceIndex) ?? 0) + 1);
376
+ }
377
+ }
378
+ for (const choice of chunk.choices) {
379
+ // Choice-index validity (Phase 5): a legacy function_call-bearing
380
+ // choice with no trustworthy explicit index must never be
381
+ // silently assumed to be choice 0 - global, unattributable, fails
382
+ // the whole stream closed rather than guessing (same fail-closed
383
+ // posture INVALID_CHOICE_INDEX_DIAGNOSTIC_CODE already has for
384
+ // the plural path - reused rather than inventing a new code,
385
+ // since the identity ambiguity it represents is identical here).
386
+ // A finish_reason-only choice (no function_call evidence at all)
387
+ // with an invalid index carries no legacy identity to protect and
388
+ // is simply skipped, not diagnosed - out of this fix's scope.
389
+ if (!Number.isInteger(choice.index) || choice.index < 0) {
390
+ if (choice.delta?.function_call) {
194
391
  events.push({
195
- type: "tool_call_start",
392
+ type: "provider_diagnostic",
196
393
  sequence: ++this.sequence,
197
394
  provider: this.provider,
198
- callRef: { sourceKey: this.legacySourceKey },
199
- toolIndex: 0,
200
- name: fc.name,
395
+ code: INVALID_CHOICE_INDEX_DIAGNOSTIC_CODE,
396
+ severity: "error",
397
+ message: "legacy function_call choice.index is missing, non-integer, or negative; choice identity is ambiguous",
201
398
  });
202
399
  }
203
- else if (fc.name) {
400
+ continue;
401
+ }
402
+ const choiceIndex = choice.index;
403
+ if ((choiceCounts.get(choiceIndex) ?? 0) > 1) {
404
+ if (choice.delta?.function_call) {
204
405
  events.push({
205
- type: "tool_call_name_delta",
406
+ type: "provider_diagnostic",
206
407
  sequence: ++this.sequence,
207
408
  provider: this.provider,
208
- callRef: { sourceKey: this.legacySourceKey },
209
- delta: fc.name,
409
+ callRef: { sourceKey: `legacy-choice:${choiceIndex}` },
410
+ code: DUPLICATE_CHOICE_INDEX_DIAGNOSTIC_CODE,
411
+ severity: "error",
412
+ message: `choice.index ${choiceIndex} is duplicated in one provider event`,
210
413
  });
211
414
  }
212
- if (fc.arguments) {
415
+ continue;
416
+ }
417
+ const sourceKey = `legacy-choice:${choiceIndex}`;
418
+ if (choice.delta?.function_call) {
419
+ const fc = choice.delta.function_call;
420
+ const isKnownChoice = this.legacyChoiceOpen.has(choiceIndex);
421
+ // Choice-local post-terminal evidence: this exact choice's
422
+ // legacy call was already closed (its own finish_reason, or a
423
+ // direct adapter.finish() force-close). Guards BOTH the
424
+ // call-start path below AND the name/arguments-delta path -
425
+ // `isKnownChoice` alone (the original, insufficient check) only
426
+ // ever distinguished "genuinely new call" from "continuing
427
+ // call," never "continuing an OPEN call" from "evidence for an
428
+ // already-CLOSED one," which would otherwise let a late
429
+ // argument/name delta silently mutate (and, if left
430
+ // structurally unclosed at close time, silently CLOSE) an
431
+ // already-ended choice's value with no record of the anomaly -
432
+ // the same class of gap already hardened for OpenAI Responses
433
+ // (P4.1) and Anthropic (P4.2). Same diagnostic code those two
434
+ // use for materially identical semantics.
435
+ if (isKnownChoice && this.legacyChoiceOpen.get(choiceIndex) === false) {
213
436
  events.push({
214
- type: "tool_call_arguments_delta",
437
+ type: "provider_diagnostic",
215
438
  sequence: ++this.sequence,
216
439
  provider: this.provider,
217
- callRef: { sourceKey: this.legacySourceKey },
218
- delta: fc.arguments,
440
+ callRef: { sourceKey },
441
+ code: TOOL_ARGUMENTS_AFTER_END_DIAGNOSTIC_CODE,
442
+ severity: "error",
443
+ message: `Tool call evidence for choice ${choiceIndex} arrived after that choice's own finish_reason`,
219
444
  });
220
445
  }
446
+ else {
447
+ if (!isKnownChoice) {
448
+ this.hasLegacyFunctionCall = true;
449
+ this.legacyChoiceOpen.set(choiceIndex, true);
450
+ events.push({
451
+ type: "tool_call_start",
452
+ sequence: ++this.sequence,
453
+ provider: this.provider,
454
+ callRef: { sourceKey },
455
+ toolIndex: choiceIndex,
456
+ name: fc.name,
457
+ });
458
+ }
459
+ else if (fc.name) {
460
+ events.push({
461
+ type: "tool_call_name_delta",
462
+ sequence: ++this.sequence,
463
+ provider: this.provider,
464
+ callRef: { sourceKey },
465
+ delta: fc.name,
466
+ });
467
+ }
468
+ if (fc.arguments) {
469
+ events.push({
470
+ type: "tool_call_arguments_delta",
471
+ sequence: ++this.sequence,
472
+ provider: this.provider,
473
+ callRef: { sourceKey },
474
+ delta: fc.arguments,
475
+ });
476
+ }
477
+ }
221
478
  }
222
479
  if (choice.finish_reason != null) {
223
- let reason = "unknown";
224
- if (choice.finish_reason === "stop" || choice.finish_reason === "function_call" || choice.finish_reason === "tool_calls") {
225
- reason = "complete";
480
+ const alreadyTerminal = this.legacyChoiceTerminalReasons.has(choiceIndex);
481
+ if (alreadyTerminal) {
482
+ // Duplicate/conflicting terminal for the SAME choice (Phase
483
+ // 10): never silently overwrite the first-recorded reason.
484
+ // Exactly DUPLICATE_TOOL_END_DIAGNOSTIC_CODE's own documented
485
+ // meaning ("more than one end part for the same call") - this
486
+ // choice's own finish_reason IS what closes its legacy call,
487
+ // so a second one is genuinely a repeated end signal for that
488
+ // same call. Attributed to this choice's own real sourceKey
489
+ // only: exact attribution is possible here, so per this
490
+ // fix's own design this stays choice-local and does not
491
+ // poison unrelated sibling choices - distinct from the
492
+ // separate, already-approved stream-wide design used for the
493
+ // analogous plural-path anomaly in openai-compatible.ts.
494
+ events.push({
495
+ type: "provider_diagnostic",
496
+ sequence: ++this.sequence,
497
+ provider: this.provider,
498
+ callRef: { sourceKey },
499
+ code: DUPLICATE_TOOL_END_DIAGNOSTIC_CODE,
500
+ severity: "error",
501
+ message: `choice ${choiceIndex} received a second finish_reason ("${choice.finish_reason}") after already terminating`,
502
+ });
226
503
  }
227
- else if (choice.finish_reason === "length") {
228
- reason = "length";
504
+ else if (this.finished) {
505
+ // Post-blocker-fix: this choice was already closed by a PRIOR
506
+ // adapter.finish() call - force-closed via that method's own
507
+ // loop, which never touches legacyChoiceTerminalReasons (only
508
+ // legacyChoiceOpen - see finish()) - so `alreadyTerminal` above
509
+ // is false even though the adapter has already globally
510
+ // terminated. The ORIGINAL bug: silently recording this
511
+ // reason and returning nothing meant this late, recognized
512
+ // terminal evidence never reached the coordinator at all -
513
+ // not even as a diagnostic - so an already-computed, still-
514
+ // UNCONSUMED execute decision could never be revoked by it.
515
+ // Exactly the class of gap this file's own push() doc comment
516
+ // already promises never to reintroduce ("No `finished` early
517
+ // return here: silently dropping every event after the first
518
+ // terminal meant ... an already-decided call's authority
519
+ // could never be revoked by it").
520
+ //
521
+ // Reuses DUPLICATE_TOOL_END_DIAGNOSTIC_CODE rather than
522
+ // inventing a new mechanism: this choice's call already has
523
+ // an end (finish()'s own synthetic close), so a further,
524
+ // later terminal for the SAME choice is genuinely a second
525
+ // end signal for that same call - semantically identical to
526
+ // the in-stream duplicate-finish_reason case above. No new
527
+ // security mechanism is implemented here: coordinator.ts's
528
+ // own push() unconditionally converts ANY event arriving
529
+ // after its OWN isFinished flag into a sticky, stream-wide
530
+ // AUTHORITY_PROTOCOL_VIOLATION_CODES diagnostic
531
+ // (EVENT_AFTER_STREAM_END_DIAGNOSTIC_CODE, or
532
+ // TERMINAL_REASON_CONFLICT_DIAGNOSTIC_CODE for a genuinely
533
+ // conflicting provider_stream_end) regardless of this event's
534
+ // own code - this only needs to stop being silence. Never
535
+ // reopens/modifies legacyChoiceOpen/legacyChoiceTerminalReasons
536
+ // (that state is frozen the moment finish() ran) and never
537
+ // emits a second normal tool_call_end, which would look like
538
+ // ordinary in-stream evidence rather than a flagged anomaly.
539
+ //
540
+ // Only emitted when this exact choice had prior tracked
541
+ // activity (legacyChoiceOpen.has) - a finish_reason for a
542
+ // choice index that was never referenced at all carries no
543
+ // existing authority to protect, and is left exactly as
544
+ // before (silently ignored, matching the invalid-index
545
+ // choice's own "no identity to protect" design elsewhere in
546
+ // this file).
547
+ if (this.legacyChoiceOpen.has(choiceIndex)) {
548
+ events.push({
549
+ type: "provider_diagnostic",
550
+ sequence: ++this.sequence,
551
+ provider: this.provider,
552
+ callRef: { sourceKey },
553
+ code: DUPLICATE_TOOL_END_DIAGNOSTIC_CODE,
554
+ severity: "error",
555
+ message: `choice ${choiceIndex} received a finish_reason ("${choice.finish_reason}") after the adapter had already globally terminated via finish()`,
556
+ });
557
+ }
229
558
  }
230
- else if (choice.finish_reason === "cancelled") {
231
- reason = "cancelled";
559
+ else {
560
+ const reason = mapLegacyFinishReason(choice.finish_reason);
561
+ this.legacyChoiceTerminalReasons.set(choiceIndex, reason);
562
+ this.legacyChoiceTerminalProviderReasons.set(choiceIndex, choice.finish_reason);
563
+ if (this.legacyChoiceOpen.get(choiceIndex)) {
564
+ events.push({
565
+ type: "tool_call_end",
566
+ sequence: ++this.sequence,
567
+ provider: this.provider,
568
+ callRef: { sourceKey },
569
+ reason,
570
+ providerReason: choice.finish_reason,
571
+ });
572
+ this.legacyChoiceOpen.set(choiceIndex, false);
573
+ }
574
+ // No provider_stream_end here, and `this.finished` stays
575
+ // false: this choice finishing is not, by itself, proof the
576
+ // whole provider stream (which may still have other choices
577
+ // actively generating) has ended. See finish().
232
578
  }
233
- events.push({
234
- type: "provider_stream_end",
235
- sequence: ++this.sequence,
236
- provider: this.provider,
237
- reason,
238
- providerReason: choice.finish_reason,
239
- });
240
- this.finished = true;
241
579
  }
242
580
  }
243
581
  }
@@ -251,13 +589,67 @@ export class OpenAIStreamAdapter {
251
589
  if (compatibleEvents.length > 0 && !this.hasLegacyFunctionCall) {
252
590
  return compatibleEvents.map(e => ({ ...e, provider: this.provider, sequence: ++this.sequence }));
253
591
  }
254
- return [{
255
- type: "provider_stream_end",
256
- sequence: ++this.sequence,
257
- provider: this.provider,
258
- reason: meta?.reason ?? "unknown",
259
- providerReason: meta?.providerReason,
260
- }];
592
+ const events = [];
593
+ // Close any legacy choice whose call never got a chance to report its
594
+ // own finish_reason at all before the caller's raw iterator ended.
595
+ // Marks each one closed (mirrors OpenAICompatibleStreamAdapter's own
596
+ // `openSet.clear()` in its analogous finish() loop) - without this, a
597
+ // stray later push() for the SAME choice (still reachable: there is
598
+ // deliberately no top-of-push() `finished` guard, see GHSA-3xpw-9694-
599
+ // 2xxp) would find `legacyChoiceOpen` still true and emit a SECOND,
600
+ // spurious tool_call_end for a call this adapter already closed here.
601
+ for (const [choiceIndex, open] of this.legacyChoiceOpen.entries()) {
602
+ if (open) {
603
+ events.push({
604
+ type: "tool_call_end",
605
+ sequence: ++this.sequence,
606
+ provider: this.provider,
607
+ callRef: { sourceKey: `legacy-choice:${choiceIndex}` },
608
+ reason: meta?.reason ?? "unknown",
609
+ providerReason: meta?.providerReason,
610
+ });
611
+ this.legacyChoiceOpen.set(choiceIndex, false);
612
+ }
613
+ }
614
+ const { reason, providerReason } = this.aggregateLegacyTermination(meta?.reason, meta?.providerReason);
615
+ events.push({
616
+ type: "provider_stream_end",
617
+ sequence: ++this.sequence,
618
+ provider: this.provider,
619
+ reason,
620
+ providerReason,
621
+ });
622
+ return events;
623
+ }
624
+ // One stream-wide StreamEndReason from every legacy choice-local reason
625
+ // recorded so far, plus any caller-supplied override - "complete" only
626
+ // if every single one of them was "complete". Mirrors
627
+ // OpenAICompatibleStreamAdapter's own aggregateTermination() exactly
628
+ // (see LEGACY_REASON_PRIORITY above for why it is duplicated locally
629
+ // rather than cross-imported).
630
+ aggregateLegacyTermination(callerReason, callerProviderReason) {
631
+ let worst;
632
+ let worstRank = Infinity;
633
+ let worstProviderReason;
634
+ for (const [choiceIndex, candidate] of this.legacyChoiceTerminalReasons.entries()) {
635
+ const rank = LEGACY_REASON_PRIORITY.indexOf(candidate);
636
+ if (rank !== -1 && rank < worstRank) {
637
+ worst = candidate;
638
+ worstRank = rank;
639
+ worstProviderReason = this.legacyChoiceTerminalProviderReasons.get(choiceIndex);
640
+ }
641
+ }
642
+ if (callerReason !== undefined) {
643
+ const rank = LEGACY_REASON_PRIORITY.indexOf(callerReason);
644
+ if (rank !== -1 && rank < worstRank) {
645
+ worst = callerReason;
646
+ worstRank = rank;
647
+ worstProviderReason = callerProviderReason;
648
+ }
649
+ }
650
+ if (worst === undefined)
651
+ return { reason: "unknown", providerReason: callerProviderReason };
652
+ return { reason: worst, providerReason: worstProviderReason };
261
653
  }
262
654
  }
263
655
  //# sourceMappingURL=openai.js.map