prefix-safe-json 0.4.4 → 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.
- package/dist/providers/anthropic.d.ts +1 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +89 -8
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts +122 -0
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +442 -25
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai-compatible.d.ts +47 -1
- package/dist/providers/openai-compatible.d.ts.map +1 -1
- package/dist/providers/openai-compatible.js +381 -84
- package/dist/providers/openai-compatible.js.map +1 -1
- package/dist/providers/openai.d.ts +7 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +459 -67
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts.map +1 -1
- package/dist/providers/openrouter.js +14 -2
- package/dist/providers/openrouter.js.map +1 -1
- package/package.json +4 -1
package/dist/providers/openai.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
199
|
+
delta: chunk.delta,
|
|
88
200
|
});
|
|
89
|
-
this.accumulatedArguments.set(chunk.item_id, chunk.arguments);
|
|
90
201
|
}
|
|
91
|
-
|
|
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:
|
|
212
|
+
code: TOOL_ARGUMENTS_AFTER_END_DIAGNOSTIC_CODE,
|
|
98
213
|
severity: "error",
|
|
99
|
-
message:
|
|
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
|
-
//
|
|
181
|
-
|
|
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.
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
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: "
|
|
392
|
+
type: "provider_diagnostic",
|
|
196
393
|
sequence: ++this.sequence,
|
|
197
394
|
provider: this.provider,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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: "
|
|
406
|
+
type: "provider_diagnostic",
|
|
206
407
|
sequence: ++this.sequence,
|
|
207
408
|
provider: this.provider,
|
|
208
|
-
callRef: { sourceKey:
|
|
209
|
-
|
|
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
|
-
|
|
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: "
|
|
437
|
+
type: "provider_diagnostic",
|
|
215
438
|
sequence: ++this.sequence,
|
|
216
439
|
provider: this.provider,
|
|
217
|
-
callRef: { sourceKey
|
|
218
|
-
|
|
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
|
-
|
|
224
|
-
if (
|
|
225
|
-
|
|
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 (
|
|
228
|
-
|
|
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
|
|
231
|
-
reason =
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|