@gajae-code/ai 0.13.3 → 0.14.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +45 -2
  2. package/dist/types/auth-broker/client.d.ts +9 -1
  3. package/dist/types/auth-broker/redact.d.ts +7 -0
  4. package/dist/types/auth-broker/remote-store.d.ts +50 -9
  5. package/dist/types/auth-broker/types.d.ts +14 -0
  6. package/dist/types/auth-broker/wire-schemas.d.ts +25 -0
  7. package/dist/types/auth-storage.d.ts +200 -6
  8. package/dist/types/core.d.ts +1 -0
  9. package/dist/types/model-cache.d.ts +4 -1
  10. package/dist/types/model-manager.d.ts +11 -0
  11. package/dist/types/provider-models/openai-compat.d.ts +5 -0
  12. package/dist/types/providers/anthropic.d.ts +31 -0
  13. package/dist/types/providers/cursor.d.ts +9 -1
  14. package/dist/types/providers/mock.d.ts +7 -1
  15. package/dist/types/providers/transform-messages.d.ts +18 -0
  16. package/dist/types/types.d.ts +28 -14
  17. package/dist/types/usage/grok-cli.d.ts +5 -0
  18. package/dist/types/usage.d.ts +6 -0
  19. package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
  20. package/dist/types/utils/event-stream.d.ts +4 -2
  21. package/dist/types/utils/fallback-transport.d.ts +10 -0
  22. package/dist/types/utils/http-inspector.d.ts +1 -0
  23. package/dist/types/utils/idle-iterator.d.ts +13 -1
  24. package/dist/types/utils/oauth/callback-server.d.ts +13 -0
  25. package/dist/types/utils/parse-bind.d.ts +8 -5
  26. package/dist/types/utils/tool-call-healing.d.ts +7 -0
  27. package/dist/types/utils/tool-choice-capability.d.ts +11 -0
  28. package/package.json +3 -2
  29. package/src/auth-broker/client.ts +30 -0
  30. package/src/auth-broker/redact.ts +15 -0
  31. package/src/auth-broker/refresher.ts +4 -2
  32. package/src/auth-broker/remote-store.ts +693 -70
  33. package/src/auth-broker/server.ts +57 -12
  34. package/src/auth-broker/types.ts +16 -0
  35. package/src/auth-broker/wire-schemas.ts +21 -0
  36. package/src/auth-gateway/server.ts +84 -19
  37. package/src/auth-storage.ts +985 -41
  38. package/src/core.ts +1 -0
  39. package/src/model-cache.ts +23 -4
  40. package/src/model-manager.ts +70 -11
  41. package/src/model-thinking.ts +21 -1
  42. package/src/models.json +1733 -392
  43. package/src/provider-models/descriptors.ts +5 -1
  44. package/src/provider-models/openai-compat.ts +52 -28
  45. package/src/providers/amazon-bedrock.ts +2 -1
  46. package/src/providers/anthropic.ts +824 -29
  47. package/src/providers/cursor.ts +83 -3
  48. package/src/providers/mock.ts +13 -3
  49. package/src/providers/ollama.ts +9 -2
  50. package/src/providers/openai-codex-responses.ts +16 -9
  51. package/src/providers/openai-completions.ts +5 -3
  52. package/src/providers/openai-responses-shared.ts +175 -21
  53. package/src/providers/register-builtins.ts +5 -2
  54. package/src/providers/transform-messages.ts +64 -1
  55. package/src/stream.ts +12 -2
  56. package/src/types.ts +28 -13
  57. package/src/usage/grok-cli.ts +86 -1
  58. package/src/usage.ts +7 -0
  59. package/src/utils/discovery/openai-compatible.ts +89 -4
  60. package/src/utils/event-stream.ts +11 -2
  61. package/src/utils/fallback-transport.ts +44 -2
  62. package/src/utils/http-inspector.ts +1 -0
  63. package/src/utils/idle-iterator.ts +29 -6
  64. package/src/utils/oauth/callback-server.ts +31 -1
  65. package/src/utils/parse-bind.ts +27 -0
  66. package/src/utils/tool-call-healing.ts +13 -2
  67. package/src/utils/tool-choice-capability.ts +386 -6
@@ -27,7 +27,7 @@ import type {
27
27
  } from "../types";
28
28
  import { normalizeSystemPrompts } from "../utils";
29
29
  import { AssistantMessageEventStream } from "../utils/event-stream";
30
- import { parseStreamingJson } from "../utils/json-parse";
30
+ import { findUnnecessaryUnicodeEscape, parseStreamingJson } from "../utils/json-parse";
31
31
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
32
32
  import { flattenToolRootCombinators, toolWireSchema } from "../utils/schema";
33
33
  import { CURSOR_COMPOSER_EDIT_DISCIPLINE_PROMPT, isComposerHarnessModel } from "./composer-discipline";
@@ -564,6 +564,9 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
564
564
  if (state.currentToolCall) {
565
565
  const idx = output.content.indexOf(state.currentToolCall);
566
566
  state.currentToolCall.arguments = parseStreamingJson(state.currentToolCall.partialJson);
567
+ if (findUnnecessaryUnicodeEscape(state.currentToolCall.partialJson ?? "")) {
568
+ state.currentToolCall.escapedNonAsciiArguments = true;
569
+ }
567
570
  delete (state.currentToolCall as any).partialJson;
568
571
  delete (state.currentToolCall as any).index;
569
572
  stream.push({
@@ -1913,7 +1916,75 @@ function cursorNativeToolName(kindKey: string): string {
1913
1916
  // do not otherwise handle (everything except mcpToolCall / updateTodosToolCall), so
1914
1917
  // without this they are silently dropped and never render. Build a generic toolCall
1915
1918
  // block from whichever *ToolCall field is set so the call (and its result) is shown.
1916
- function buildNativeToolCallBlock(
1919
+
1920
+ /** Hard node budget for one native-payload conversion; bounds hostile or cyclic graphs. */
1921
+ const CURSOR_JSON_SAFE_MAX_NODES = 10_000;
1922
+ const CURSOR_JSON_SAFE_MAX_DEPTH = 100;
1923
+
1924
+ /**
1925
+ * Total conversion of a Cursor protobuf payload into plain JSON-safe data.
1926
+ *
1927
+ * protobuf-es v2 messages are plain objects, but they carry `$typeName`
1928
+ * markers, `bigint` fields (e.g. `fileSize`, `durationMs`, `timestampMs`,
1929
+ * `fileOutputThresholdBytes`), and `Uint8Array` blobs. None of those may leak
1930
+ * into assistant message content: toolCall `arguments` are staged into managed
1931
+ * snapshots, persisted to the JSONL transcript, and replayed to providers —
1932
+ * all of which require `JSON.stringify`-safe values. Attaching the raw payload
1933
+ * is exactly the local-snapshot producer defect class behind issue #4578.
1934
+ *
1935
+ * Rules: `$typeName` is stripped, safe-range bigints become numbers (decimal
1936
+ * strings beyond `Number.MAX_SAFE_INTEGER`), byte arrays become base64
1937
+ * strings, dates become ISO strings, functions/symbols are dropped, cycles
1938
+ * and over-depth values collapse to null, and containers stop accepting
1939
+ * entries once the shared node budget is exhausted.
1940
+ */
1941
+ function cursorJsonSafeValue(value: unknown, path?: Set<object>, budget?: { remaining: number }, depth = 0): unknown {
1942
+ const seen = path ?? new Set<object>();
1943
+ const nodes = budget ?? { remaining: CURSOR_JSON_SAFE_MAX_NODES };
1944
+ if (nodes.remaining-- <= 0) return null;
1945
+ if (depth >= CURSOR_JSON_SAFE_MAX_DEPTH) return null;
1946
+ if (typeof value === "bigint") {
1947
+ return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER)
1948
+ ? Number(value)
1949
+ : value.toString();
1950
+ }
1951
+ if (typeof value === "function" || typeof value === "symbol" || value === undefined) return null;
1952
+ if (typeof value === "number" && !Number.isFinite(value)) return null;
1953
+ if (value === null || typeof value !== "object") return value;
1954
+ if (seen.has(value)) return null;
1955
+ if (value instanceof Uint8Array)
1956
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("base64");
1957
+ if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null;
1958
+ seen.add(value);
1959
+ try {
1960
+ if (Array.isArray(value)) {
1961
+ const array: unknown[] = [];
1962
+ for (const entry of value) {
1963
+ if (nodes.remaining <= 0) break;
1964
+ array.push(cursorJsonSafeValue(entry, seen, nodes, depth + 1));
1965
+ }
1966
+ return array;
1967
+ }
1968
+ const record: Record<string, unknown> = {};
1969
+ for (const [key, entry] of Object.entries(value)) {
1970
+ if (key === "$typeName") continue;
1971
+ if (nodes.remaining <= 0) break;
1972
+ record[key] = cursorJsonSafeValue(entry, seen, nodes, depth + 1);
1973
+ }
1974
+ return record;
1975
+ } catch {
1976
+ return null;
1977
+ } finally {
1978
+ seen.delete(value);
1979
+ }
1980
+ }
1981
+
1982
+ /** Exported for direct regression coverage of the JSON-safety boundary. */
1983
+ export function cursorJsonSafeValueForTest(value: unknown): unknown {
1984
+ return cursorJsonSafeValue(value);
1985
+ }
1986
+
1987
+ export function buildNativeToolCallBlock(
1917
1988
  toolCall: Record<string, unknown>,
1918
1989
  callId: string,
1919
1990
  index: number,
@@ -1922,11 +1993,20 @@ function buildNativeToolCallBlock(
1922
1993
  if (!/ToolCall$/.test(key) || !payload || typeof payload !== "object") continue;
1923
1994
  if (key === "mcpToolCall" || key === "updateTodosToolCall") continue;
1924
1995
  const args = (payload as { args?: unknown }).args;
1996
+ const hasObjectArgs = args !== null && typeof args === "object";
1997
+ const convertedArgs = hasObjectArgs ? cursorJsonSafeValue(args) : undefined;
1998
+ const safeArguments =
1999
+ convertedArgs !== undefined &&
2000
+ convertedArgs !== null &&
2001
+ typeof convertedArgs === "object" &&
2002
+ !Array.isArray(convertedArgs)
2003
+ ? (convertedArgs as Record<string, unknown>)
2004
+ : { raw: hasObjectArgs ? convertedArgs : cursorJsonSafeValue(payload) };
1925
2005
  return {
1926
2006
  type: "toolCall",
1927
2007
  id: callId,
1928
2008
  name: cursorNativeToolName(key),
1929
- arguments: args && typeof args === "object" ? (args as Record<string, unknown>) : { raw: payload },
2009
+ arguments: safeArguments,
1930
2010
  index,
1931
2011
  kind: "native",
1932
2012
  };
@@ -75,10 +75,12 @@ export type MockContent =
75
75
  arguments: Record<string, unknown> | string;
76
76
  /** Simulate a provider-flagged truncated call (cut off mid-arguments). */
77
77
  incompleteArguments?: boolean;
78
- /** Simulate a provider-flagged `\uXXXX`-escaped-arguments call. */
78
+ /** Typed reason matching `ToolCall.incompleteArgumentsReason`. Defaults to `"truncated"`. */
79
+ incompleteArgumentsReason?: "truncated" | "malformed" | "conflicting" | "ambiguous";
80
+ /** Simulate a provider-flagged `\uXXXX`-escaped non-ASCII argument payload. */
79
81
  escapedNonAsciiArguments?: boolean;
82
+ thoughtSignature?: string;
80
83
  };
81
-
82
84
  /** One scripted response. */
83
85
  export interface MockResponse {
84
86
  /** Content blocks to emit, in order. Strings become text blocks. */
@@ -89,6 +91,9 @@ export interface MockResponse {
89
91
  usage?: Partial<Omit<Usage, "cost">> & { cost?: Partial<Usage["cost"]> };
90
92
  /** Pre-set responseId. */
91
93
  responseId?: string;
94
+ /** Optional provider metadata copied onto the final assistant message. */
95
+ disabledFeatures?: string[];
96
+ providerPayload?: AssistantMessage["providerPayload"];
92
97
  /** Optional typed provider failure metadata for retry/fallback tests. */
93
98
  transportFailure?: AssistantMessage["transportFailure"];
94
99
  /** If set, the stream emits a terminal error event instead of completing. */
@@ -367,6 +372,8 @@ async function runMock(
367
372
  provider: model.provider,
368
373
  model: model.id,
369
374
  responseId: response.responseId,
375
+ disabledFeatures: response.disabledFeatures,
376
+ providerPayload: response.providerPayload,
370
377
  transportFailure: response.transportFailure,
371
378
  usage: emptyUsage(),
372
379
  stopReason: "stop",
@@ -424,8 +431,11 @@ function normalizeContent(input: MockContent, state: MockModel): TextContent | T
424
431
  id: input.id ?? generateToolCallId(state),
425
432
  name: input.name,
426
433
  arguments: typeof input.arguments === "string" ? input.arguments : { ...input.arguments },
427
- ...(input.incompleteArguments ? { incompleteArguments: true } : {}),
434
+ ...(input.incompleteArguments
435
+ ? { incompleteArguments: true, incompleteArgumentsReason: input.incompleteArgumentsReason ?? "truncated" }
436
+ : {}),
428
437
  ...(input.escapedNonAsciiArguments ? { escapedNonAsciiArguments: true } : {}),
438
+ ...(input.thoughtSignature ? { thoughtSignature: input.thoughtSignature } : {}),
429
439
  } as ToolCall;
430
440
  }
431
441
  return input;
@@ -18,7 +18,7 @@ import { normalizeSystemPrompts } from "../utils";
18
18
  import { AssistantMessageEventStream } from "../utils/event-stream";
19
19
  import { transportFailureFacts } from "../utils/fallback-transport";
20
20
  import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
21
- import { isCompleteJson, parseStreamingJson } from "../utils/json-parse";
21
+ import { findUnnecessaryUnicodeEscape, isCompleteJson, parseStreamingJson } from "../utils/json-parse";
22
22
  import { resolveRetryBudget } from "../utils/retry-budget";
23
23
  import { flattenToolRootCombinators, toolWireSchema } from "../utils/schema";
24
24
  import {
@@ -361,6 +361,9 @@ function endToolCallBlock(stream: AssistantMessageEventStream, output: Assistant
361
361
  if (toolCall.partialJson !== undefined) {
362
362
  if (toolCall.partialJson.trim()) {
363
363
  toolCall.arguments = parseStreamingJson<Record<string, unknown>>(toolCall.partialJson);
364
+ if (findUnnecessaryUnicodeEscape(toolCall.partialJson)) {
365
+ toolCall.escapedNonAsciiArguments = true;
366
+ }
364
367
  }
365
368
  delete toolCall.partialJson;
366
369
  }
@@ -550,6 +553,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
550
553
  name,
551
554
  arguments: parseStreamingJson<Record<string, unknown>>(partialJson),
552
555
  partialJson,
556
+ ...(findUnnecessaryUnicodeEscape(partialJson) ? { escapedNonAsciiArguments: true } : {}),
553
557
  };
554
558
  if (unverifiableArguments) unverifiableArgumentToolCallIds.add(toolCall.id);
555
559
  output.content.push(toolCall);
@@ -591,7 +595,10 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
591
595
  for (const block of output.content) {
592
596
  if (block.type !== "toolCall") continue;
593
597
  const partialJson = (block as InternalToolCallBlock).partialJson;
594
- if (partialJson !== undefined && !isCompleteJson(partialJson)) block.incompleteArguments = true;
598
+ if (partialJson !== undefined && !isCompleteJson(partialJson)) {
599
+ block.incompleteArguments = true;
600
+ block.incompleteArgumentsReason = "truncated";
601
+ }
595
602
  }
596
603
  }
597
604
  for (const index of activeToolIndices) {
@@ -57,7 +57,7 @@ import {
57
57
  getStreamFirstEventTimeoutMs,
58
58
  iterateWithIdleTimeout,
59
59
  } from "../utils/idle-iterator";
60
- import { parseStreamingJson } from "../utils/json-parse";
60
+ import { findUnnecessaryUnicodeEscape, parseStreamingJson } from "../utils/json-parse";
61
61
  import { resolveRetryBudget } from "../utils/retry-budget";
62
62
  import {
63
63
  adaptSchemaForStrict,
@@ -1276,6 +1276,7 @@ function handleToolCallArgumentsDone(
1276
1276
  if (typeof args === "string") {
1277
1277
  currentBlock.partialJson = args;
1278
1278
  currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
1279
+ if (findUnnecessaryUnicodeEscape(args)) currentBlock.escapedNonAsciiArguments = true;
1279
1280
  }
1280
1281
  }
1281
1282
 
@@ -1391,6 +1392,7 @@ function handleOutputItemDone(
1391
1392
  id,
1392
1393
  name: codexToolCanonicalName(item.name),
1393
1394
  arguments: parseStreamingJson(item.arguments || "{}"),
1395
+ ...(findUnnecessaryUnicodeEscape(item.arguments || "") ? { escapedNonAsciiArguments: true } : {}),
1394
1396
  };
1395
1397
  runtime.canSafelyReplayWebsocketOverSse = false;
1396
1398
  stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
@@ -1853,24 +1855,29 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
1853
1855
  context: Context,
1854
1856
  options?: OpenAICodexResponsesOptions,
1855
1857
  ): AssistantMessageEventStream => {
1856
- const stream = new AssistantMessageEventStream();
1858
+ const consumerAbortController = new AbortController();
1859
+ const stream = new AssistantMessageEventStream(() => consumerAbortController.abort());
1860
+ const signal = options?.signal
1861
+ ? AbortSignal.any([options.signal, consumerAbortController.signal])
1862
+ : consumerAbortController.signal;
1863
+ const streamOptions = { ...options, signal };
1857
1864
 
1858
1865
  (async () => {
1859
1866
  const startTime = Date.now();
1860
1867
  const output = createAssistantOutput(model);
1861
- const requestSetup = createRequestSetup(options);
1868
+ const requestSetup = createRequestSetup(streamOptions);
1862
1869
  let processingContext: CodexStreamProcessingContext | undefined;
1863
1870
 
1864
1871
  try {
1865
- const requestContext = await buildCodexRequestContext(model, context, options, output);
1872
+ const requestContext = await buildCodexRequestContext(model, context, streamOptions, output);
1866
1873
  let initialTransport: CodexInitialTransport;
1867
1874
  try {
1868
- initialTransport = await openInitialCodexEventStream(model, options, requestSetup, requestContext);
1875
+ initialTransport = await openInitialCodexEventStream(model, streamOptions, requestSetup, requestContext);
1869
1876
  } catch (error) {
1870
- if (options?.fallbackManaged) throw error;
1877
+ if (streamOptions.fallbackManaged) throw error;
1871
1878
  initialTransport = await retryCodexInitialTransportWithoutToolChoice(
1872
1879
  model,
1873
- options,
1880
+ streamOptions,
1874
1881
  requestSetup,
1875
1882
  requestContext,
1876
1883
  stream,
@@ -1889,7 +1896,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
1889
1896
  model,
1890
1897
  output,
1891
1898
  stream,
1892
- options,
1899
+ options: streamOptions,
1893
1900
  requestSetup,
1894
1901
  requestContext,
1895
1902
  startTime,
@@ -1907,7 +1914,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
1907
1914
  model,
1908
1915
  output,
1909
1916
  stream,
1910
- options,
1917
+ options: streamOptions,
1911
1918
  requestSetup,
1912
1919
  requestContext: {
1913
1920
  apiKey: "",
@@ -676,8 +676,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
676
676
  return;
677
677
  }
678
678
  block.arguments = parseStreamingJson(block.partialArgs);
679
- // Raw-wire check: after decode the escape is invisible.
680
- if (findUnnecessaryUnicodeEscape(block.partialArgs)) block.escapedNonAsciiArguments = true;
679
+ if (findUnnecessaryUnicodeEscape(block.partialArgs ?? "")) block.escapedNonAsciiArguments = true;
681
680
  delete (block as { partialArgs?: string }).partialArgs;
682
681
  stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial: output });
683
682
  };
@@ -806,7 +805,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
806
805
  partialArgs: call.arguments,
807
806
  };
808
807
  block.arguments = parseStreamingJson(call.arguments);
809
- if (findUnnecessaryUnicodeEscape(call.arguments)) block.escapedNonAsciiArguments = true;
808
+ // The healer already normalized `call.arguments`, decoding any escapes away,
809
+ // so the signal has to come from its pre-round-trip sample of the raw payload.
810
+ if (call.escapedNonAsciiArguments) block.escapedNonAsciiArguments = true;
810
811
  currentBlock = block;
811
812
  output.content.push(block);
812
813
  stream.push({ type: "toolcall_start", contentIndex: blockIndex(block), partial: output });
@@ -1023,6 +1024,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
1023
1024
  const partial = (currentBlock as { partialArgs?: string }).partialArgs;
1024
1025
  if (partial !== undefined && !isCompleteJson(partial)) {
1025
1026
  currentBlock.incompleteArguments = true;
1027
+ currentBlock.incompleteArgumentsReason = "truncated";
1026
1028
  }
1027
1029
  }
1028
1030
 
@@ -31,6 +31,7 @@ import {
31
31
  import { normalizeResponsesToolCallId, sanitizeJsonStrings } from "../utils";
32
32
  import type { AssistantMessageEventStream } from "../utils/event-stream";
33
33
  import { findUnnecessaryUnicodeEscape, isCompleteJson, parseStreamingJson } from "../utils/json-parse";
34
+ import { areJsonValuesEqual } from "../utils/schema";
34
35
  import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard";
35
36
 
36
37
  const OPENAI_RESPONSES_PROGRESS_EVENT_TYPES = new Set([
@@ -405,6 +406,27 @@ export async function processResponsesStream<TApi extends Api>(
405
406
  summaryBuffer: string;
406
407
  rawBuffer: string;
407
408
  summaryStarted: boolean;
409
+ /**
410
+ * Raw `arguments` carried by the item's `response.output_item.added` snapshot.
411
+ * Kept out of the streaming buffer (a relay may put a `{}` placeholder here)
412
+ * but retained as the lowest-precedence source for relays that supply the
413
+ * real payload only in that snapshot.
414
+ */
415
+ addedArguments: string;
416
+ /**
417
+ * Set when this entry's tool identity is ambiguous (a duplicate `call_id`,
418
+ * an `id`/`call_id` namespace collision, or any other shape where a delta
419
+ * cannot be unambiguously attributed). The entry is finalized as
420
+ * `incompleteArguments` so the agent loop rejects it instead of executing
421
+ * possibly-misattributed arguments.
422
+ */
423
+ ambiguousIdentity: boolean;
424
+ /**
425
+ * Whether this entry has already been finalized by a terminal
426
+ * `response.output_item.done`. A duplicate terminal event for the same item
427
+ * must not emit a second `toolcall_end`/`text_end`/`thinking_end`.
428
+ */
429
+ finalized: boolean;
408
430
  }
409
431
  // Per-item argument buffer keyed on stable item identity. Multiple tool-call
410
432
  // items can stream interleaved argument deltas in one response, so a single
@@ -412,6 +434,7 @@ export async function processResponsesStream<TApi extends Api>(
412
434
  const items = new Map<string, ItemEntry>();
413
435
  let lastKey: string | null = null;
414
436
  const idKey = (id: string) => `id:${id}`;
437
+ const callKey = (id: string) => `call:${id}`;
415
438
  const idxKey = (n: number) => `idx:${n}`;
416
439
  const hasIndex = (n: number | undefined): n is number => typeof n === "number" && Number.isFinite(n);
417
440
  const resolveEntry = (
@@ -428,7 +451,18 @@ export async function processResponsesStream<TApi extends Api>(
428
451
  ): ItemEntry | undefined => {
429
452
  if (itemId) {
430
453
  const byId = items.get(idKey(itemId));
454
+ const byCallId = items.get(callKey(itemId));
455
+ // Ambiguous identity: `item_id` matches one entry as its canonical id and
456
+ // a *different* entry as its `call_id` (an id/call_id namespace collision).
457
+ // Picking either silently mis-attributes the payload, so mark both
458
+ // ambiguous and drop the delta instead of resolving.
459
+ if (byId && byCallId && byId !== byCallId) {
460
+ byId.ambiguousIdentity = true;
461
+ byCallId.ambiguousIdentity = true;
462
+ return undefined;
463
+ }
431
464
  if (byId) return byId;
465
+ if (byCallId) return byCallId;
432
466
  }
433
467
  if (hasIndex(outputIndex)) {
434
468
  const byIdx = items.get(idxKey(outputIndex));
@@ -448,23 +482,62 @@ export async function processResponsesStream<TApi extends Api>(
448
482
  summaryBuffer: "",
449
483
  rawBuffer: "",
450
484
  summaryStarted: false,
485
+ addedArguments: item.type === "function_call" ? (item.arguments ?? "") : "",
486
+ ambiguousIdentity: false,
487
+ finalized: false,
451
488
  };
452
489
  // Primary key prefers the stable item id; if the wire omits it, fall back to
453
490
  // the positional index. A synthetic key keeps the entry addressable as lastKey
454
491
  // for continuation-style non-tool events even when neither is present.
455
492
  const key = item.id ? idKey(item.id) : hasIndex(outputIndex) ? idxKey(outputIndex) : `seq:${items.size}`;
456
493
  items.set(key, entry);
457
- if (item.id && hasIndex(outputIndex)) items.set(idxKey(outputIndex), entry);
494
+ // Index alias: only claim it when no other entry already holds it. Two items
495
+ // sharing one `output_index` (a relay defect) must not have the second steal
496
+ // the alias and drop the first's index-routed deltas; each stays addressable
497
+ // by its own stable id/call_id, and the index keeps resolving to the first
498
+ // occupant rather than silently reassigning.
499
+ if (hasIndex(outputIndex)) {
500
+ const idxK = idxKey(outputIndex);
501
+ if (!items.has(idxK)) items.set(idxK, entry);
502
+ }
503
+ if ((item.type === "function_call" || item.type === "custom_tool_call") && item.call_id) {
504
+ const callK = callKey(item.call_id);
505
+ const existing = items.get(callK);
506
+ // Duplicate `call_id` in one response: two distinct items claim the same
507
+ // alias. Fail closed for both — neither's arguments can be trusted to
508
+ // belong to the right call once their deltas and terminals are aliased.
509
+ if (existing && existing !== entry) {
510
+ existing.ambiguousIdentity = true;
511
+ entry.ambiguousIdentity = true;
512
+ } else if (!existing) {
513
+ items.set(callK, entry);
514
+ }
515
+ }
516
+ // Detect an id/call_id collision at registration too: a new item whose id
517
+ // equals another item's call_id (or vice versa) makes id-based resolution
518
+ // ambiguous for any delta keyed on that shared string.
519
+ if (item.id) {
520
+ const callAliasOfOther = items.get(callKey(item.id));
521
+ if (callAliasOfOther && callAliasOfOther !== entry) {
522
+ callAliasOfOther.ambiguousIdentity = true;
523
+ entry.ambiguousIdentity = true;
524
+ }
525
+ }
458
526
  lastKey = key;
459
527
  return entry;
460
528
  };
461
- const dropEntry = (itemId: string | undefined, outputIndex: number | undefined): void => {
462
- const key = itemId ? idKey(itemId) : hasIndex(outputIndex) ? idxKey(outputIndex) : null;
463
- if (key) {
529
+ const dropEntry = (itemId: string | undefined, outputIndex: number | undefined, callId?: string): void => {
530
+ const entry =
531
+ (itemId ? (items.get(idKey(itemId)) ?? items.get(callKey(itemId))) : undefined) ??
532
+ (callId ? items.get(callKey(callId)) : undefined) ??
533
+ (hasIndex(outputIndex) ? items.get(idxKey(outputIndex)) : undefined);
534
+ if (!entry) return;
535
+ entry.finalized = true;
536
+ for (const [key, candidate] of items) {
537
+ if (candidate !== entry) continue;
464
538
  items.delete(key);
465
539
  if (lastKey === key) lastKey = null;
466
540
  }
467
- if (itemId && hasIndex(outputIndex)) items.delete(idxKey(outputIndex));
468
541
  };
469
542
  let sawFirstToken = false;
470
543
 
@@ -492,7 +565,7 @@ export async function processResponsesStream<TApi extends Api>(
492
565
  id: encodeResponsesToolCallId(item.call_id, item.id),
493
566
  name: item.name,
494
567
  arguments: {},
495
- partialJson: item.arguments || "",
568
+ partialJson: "",
496
569
  };
497
570
  const entry = registerEntry(item, block, outputIndex);
498
571
  stream.push({ type: "toolcall_start", contentIndex: entry.blockContentIndex, partial: output });
@@ -649,7 +722,21 @@ export async function processResponsesStream<TApi extends Api>(
649
722
  } else if (event.type === "response.output_item.done") {
650
723
  const item = structuredCloneJSON(event.item);
651
724
  options?.onOutputItemDone?.(item);
652
- const entry = resolveEntry(item.id, event.output_index, "never");
725
+ // A tool item may be registered under its call id alone (relays that omit
726
+ // item ids in `added`) and then introduce an item id in the terminal event,
727
+ // so both identities are tried before the positional fallback.
728
+ const isToolItem = item.type === "function_call" || item.type === "custom_tool_call";
729
+ const entry =
730
+ resolveEntry(item.id, event.output_index, "never") ??
731
+ (isToolItem && item.call_id ? resolveEntry(item.call_id, event.output_index, "never") : undefined);
732
+ // A duplicate terminal event for an item already finalized (dropped) must
733
+ // not emit a second end event. After finalization the entry is gone from
734
+ // the map, so a second `output_item.done` for the same tool item resolves
735
+ // to no live entry — skip it rather than re-emitting.
736
+ // An orphan terminal event (no preceding `output_item.added`, so no live
737
+ // entry) for a tool item must not synthesize a phantom block at a stale
738
+ // content index. Only finalize tool items that resolved to a live entry.
739
+ if (isToolItem && !entry) continue;
653
740
  if (item.type === "reasoning") {
654
741
  // Prefer the streamed summary buffer only when it carries real text. When it
655
742
  // holds only synthetic separators (e.g. a part.done arrived before/without any
@@ -732,23 +819,59 @@ export async function processResponsesStream<TApi extends Api>(
732
819
  });
733
820
  dropEntry(item.id, event.output_index);
734
821
  } else if (item.type === "function_call") {
735
- // Finalize onto the same block object stored in output.content, reading
736
- // the matching entry's buffered partialJson first and only then the done
737
- // item's arguments — never an adjacent item's buffer.
738
- const rawArguments =
739
- entry?.block.type === "toolCall" && entry.block.partialJson
740
- ? entry.block.partialJson
741
- : item.arguments || "{}";
742
- const args = parseStreamingJson(rawArguments);
743
- // Detected on the raw wire text: after JSON decode an unnecessary
744
- // `\uXXXX` escape is indistinguishable from legitimate literal UTF-8,
745
- // so this is the only point where the defect is still observable.
822
+ // The terminal item is canonical. Some compatible Responses relays put an
823
+ // empty placeholder in output_item.added and only provide real arguments
824
+ // here. When streamed arguments also exist, require agreement rather than
825
+ // silently choosing one source — but compare the decoded payloads, since a
826
+ // relay that re-serializes the terminal item (different key spacing or
827
+ // escaping) is not a disagreement about what the model asked for.
828
+ const streamedArguments = entry?.block.type === "toolCall" ? entry.block.partialJson : "";
829
+ const finalArguments = item.arguments ?? "";
830
+ const hasStreamedArguments = streamedArguments.length > 0;
831
+ const hasFinalArguments = finalArguments.length > 0;
832
+ const conflictingArgumentSources =
833
+ hasStreamedArguments &&
834
+ hasFinalArguments &&
835
+ streamedArguments !== finalArguments &&
836
+ !isEquivalentJsonPayload(streamedArguments, finalArguments);
837
+ // Source precedence: terminal, then streamed deltas, then the `added`
838
+ // snapshot. The last one only matters for relays that never emit deltas
839
+ // and leave the terminal `arguments` empty; without it their real payload
840
+ // would silently degrade to `{}`.
841
+ const rawArguments = hasFinalArguments
842
+ ? finalArguments
843
+ : hasStreamedArguments
844
+ ? streamedArguments
845
+ : (entry?.addedArguments ?? "");
846
+ const decodedArguments =
847
+ conflictingArgumentSources || !isCompleteJson(rawArguments)
848
+ ? undefined
849
+ : parseStreamingJson(rawArguments);
850
+ // Function-call arguments must decode to a JSON object; `null`, arrays and
851
+ // scalars cannot be dispatched against a tool schema, so they fail closed
852
+ // instead of reaching validation as a non-record value. An ambiguous
853
+ // tool-call identity (duplicate call_id, id/call_id collision) also fails
854
+ // closed: attribution of the streamed/terminal payload is unsafe.
855
+ const ambiguousIdentity = entry?.ambiguousIdentity ?? false;
856
+ const incompleteArguments = ambiguousIdentity || !isJsonRecord(decodedArguments);
857
+ const args = incompleteArguments ? {} : (decodedArguments as Record<string, unknown>);
858
+ // Typed reason lets the agent loop give accurate recovery guidance instead
859
+ // of always suggesting "split the work" (truncation-only) for a malformed
860
+ // or conflicting terminal payload, or an ambiguous identity.
861
+ const incompleteArgumentsReason: "malformed" | "conflicting" | "ambiguous" | undefined = incompleteArguments
862
+ ? ambiguousIdentity
863
+ ? "ambiguous"
864
+ : conflictingArgumentSources
865
+ ? "conflicting"
866
+ : "malformed"
867
+ : undefined;
746
868
  const escapedNonAscii = findUnnecessaryUnicodeEscape(rawArguments) !== undefined;
747
869
  const toolCall: ToolCall = {
748
870
  type: "toolCall",
749
871
  id: encodeResponsesToolCallId(item.call_id, item.id),
750
872
  name: item.name,
751
873
  arguments: args,
874
+ ...(incompleteArguments ? { incompleteArguments: true, incompleteArgumentsReason } : {}),
752
875
  ...(escapedNonAscii ? { escapedNonAsciiArguments: true } : {}),
753
876
  };
754
877
  if (entry?.block.type === "toolCall") {
@@ -757,9 +880,16 @@ export async function processResponsesStream<TApi extends Api>(
757
880
  entry.block.arguments = args;
758
881
  if (escapedNonAscii) entry.block.escapedNonAsciiArguments = true;
759
882
  else delete entry.block.escapedNonAsciiArguments;
883
+ if (incompleteArguments) {
884
+ entry.block.incompleteArguments = true;
885
+ entry.block.incompleteArgumentsReason = incompleteArgumentsReason;
886
+ } else {
887
+ delete entry.block.incompleteArguments;
888
+ delete entry.block.incompleteArgumentsReason;
889
+ }
760
890
  }
761
891
  const contentIndex = entry?.blockContentIndex ?? output.content.length - 1;
762
- dropEntry(item.id, event.output_index);
892
+ dropEntry(item.id, event.output_index, item.call_id);
763
893
  stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
764
894
  } else if (item.type === "custom_tool_call") {
765
895
  const rawInput =
@@ -779,7 +909,7 @@ export async function processResponsesStream<TApi extends Api>(
779
909
  entry.block.arguments = { input: rawInput };
780
910
  }
781
911
  const contentIndex = entry?.blockContentIndex ?? output.content.length - 1;
782
- dropEntry(item.id, event.output_index);
912
+ dropEntry(item.id, event.output_index, item.call_id);
783
913
  stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
784
914
  }
785
915
  } else if (event.type === "response.completed") {
@@ -828,6 +958,26 @@ export async function processResponsesStream<TApi extends Api>(
828
958
  }
829
959
  }
830
960
 
961
+ /**
962
+ * Whether two raw JSON argument strings decode to the same value. Used to tell a
963
+ * relay's re-serialization of the same tool arguments apart from a genuine
964
+ * disagreement between the streamed and terminal payloads; anything that does
965
+ * not decode cleanly on both sides is treated as a disagreement (fail closed).
966
+ */
967
+ /** Whether a decoded JSON value is a plain object usable as tool-call arguments. */
968
+ function isJsonRecord(value: unknown): value is Record<string, unknown> {
969
+ return typeof value === "object" && value !== null && !Array.isArray(value);
970
+ }
971
+
972
+ function isEquivalentJsonPayload(left: string, right: string): boolean {
973
+ if (!isCompleteJson(left) || !isCompleteJson(right)) return false;
974
+ try {
975
+ return areJsonValuesEqual(JSON.parse(left), JSON.parse(right));
976
+ } catch {
977
+ return false;
978
+ }
979
+ }
980
+
831
981
  /**
832
982
  * Mark tool-call blocks left incomplete by a length-truncated response so the
833
983
  * agent loop rejects them instead of executing a best-effort partial parse.
@@ -852,13 +1002,17 @@ export function flagTruncatedToolCalls(
852
1002
  if (block.type !== "toolCall") continue;
853
1003
  if (!isFinalized(block)) {
854
1004
  block.incompleteArguments = true;
1005
+ block.incompleteArgumentsReason = "truncated";
855
1006
  continue;
856
1007
  }
857
1008
  // Finalized: custom tools carry raw (non-JSON) input and are complete once
858
1009
  // finalized; only JSON function calls get the parse double-check.
859
1010
  if (!block.customWireName) {
860
1011
  const partial = (block as { partialJson?: string }).partialJson;
861
- if (partial !== undefined && !isCompleteJson(partial)) block.incompleteArguments = true;
1012
+ if (partial !== undefined && !isCompleteJson(partial)) {
1013
+ block.incompleteArguments = true;
1014
+ block.incompleteArgumentsReason = "truncated";
1015
+ }
862
1016
  }
863
1017
  }
864
1018
  }
@@ -339,12 +339,15 @@ function createLazyStream<TApi extends Api>(
339
339
  limits?: LazyStreamLimits,
340
340
  ): (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => EventStreamImpl {
341
341
  return (model, context, options) => {
342
- const outer = new EventStreamImpl();
342
+ let abortTracker: AbortSourceTracker | undefined;
343
+ const outer = new EventStreamImpl(() =>
344
+ abortTracker?.abortLocally(new Error("Provider stream consumer stopped before completion")),
345
+ );
343
346
  const streamOptions = (options ?? {}) as OptionsForApi<TApi>;
344
347
 
345
348
  loadModule()
346
349
  .then(module => {
347
- const abortTracker = createAbortSourceTracker(streamOptions.signal);
350
+ abortTracker = createAbortSourceTracker(streamOptions.signal);
348
351
  const providerOptions = { ...streamOptions, signal: abortTracker.requestSignal } as OptionsForApi<TApi>;
349
352
  const inner = module.stream(model, context, providerOptions);
350
353
  forwardStream(outer, inner, model, streamOptions, abortTracker, limits);