@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.
- package/CHANGELOG.md +45 -2
- package/dist/types/auth-broker/client.d.ts +9 -1
- package/dist/types/auth-broker/redact.d.ts +7 -0
- package/dist/types/auth-broker/remote-store.d.ts +50 -9
- package/dist/types/auth-broker/types.d.ts +14 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +25 -0
- package/dist/types/auth-storage.d.ts +200 -6
- package/dist/types/core.d.ts +1 -0
- package/dist/types/model-cache.d.ts +4 -1
- package/dist/types/model-manager.d.ts +11 -0
- package/dist/types/provider-models/openai-compat.d.ts +5 -0
- package/dist/types/providers/anthropic.d.ts +31 -0
- package/dist/types/providers/cursor.d.ts +9 -1
- package/dist/types/providers/mock.d.ts +7 -1
- package/dist/types/providers/transform-messages.d.ts +18 -0
- package/dist/types/types.d.ts +28 -14
- package/dist/types/usage/grok-cli.d.ts +5 -0
- package/dist/types/usage.d.ts +6 -0
- package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
- package/dist/types/utils/event-stream.d.ts +4 -2
- package/dist/types/utils/fallback-transport.d.ts +10 -0
- package/dist/types/utils/http-inspector.d.ts +1 -0
- package/dist/types/utils/idle-iterator.d.ts +13 -1
- package/dist/types/utils/oauth/callback-server.d.ts +13 -0
- package/dist/types/utils/parse-bind.d.ts +8 -5
- package/dist/types/utils/tool-call-healing.d.ts +7 -0
- package/dist/types/utils/tool-choice-capability.d.ts +11 -0
- package/package.json +3 -2
- package/src/auth-broker/client.ts +30 -0
- package/src/auth-broker/redact.ts +15 -0
- package/src/auth-broker/refresher.ts +4 -2
- package/src/auth-broker/remote-store.ts +693 -70
- package/src/auth-broker/server.ts +57 -12
- package/src/auth-broker/types.ts +16 -0
- package/src/auth-broker/wire-schemas.ts +21 -0
- package/src/auth-gateway/server.ts +84 -19
- package/src/auth-storage.ts +985 -41
- package/src/core.ts +1 -0
- package/src/model-cache.ts +23 -4
- package/src/model-manager.ts +70 -11
- package/src/model-thinking.ts +21 -1
- package/src/models.json +1733 -392
- package/src/provider-models/descriptors.ts +5 -1
- package/src/provider-models/openai-compat.ts +52 -28
- package/src/providers/amazon-bedrock.ts +2 -1
- package/src/providers/anthropic.ts +824 -29
- package/src/providers/cursor.ts +83 -3
- package/src/providers/mock.ts +13 -3
- package/src/providers/ollama.ts +9 -2
- package/src/providers/openai-codex-responses.ts +16 -9
- package/src/providers/openai-completions.ts +5 -3
- package/src/providers/openai-responses-shared.ts +175 -21
- package/src/providers/register-builtins.ts +5 -2
- package/src/providers/transform-messages.ts +64 -1
- package/src/stream.ts +12 -2
- package/src/types.ts +28 -13
- package/src/usage/grok-cli.ts +86 -1
- package/src/usage.ts +7 -0
- package/src/utils/discovery/openai-compatible.ts +89 -4
- package/src/utils/event-stream.ts +11 -2
- package/src/utils/fallback-transport.ts +44 -2
- package/src/utils/http-inspector.ts +1 -0
- package/src/utils/idle-iterator.ts +29 -6
- package/src/utils/oauth/callback-server.ts +31 -1
- package/src/utils/parse-bind.ts +27 -0
- package/src/utils/tool-call-healing.ts +13 -2
- package/src/utils/tool-choice-capability.ts +386 -6
package/src/providers/cursor.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
2009
|
+
arguments: safeArguments,
|
|
1930
2010
|
index,
|
|
1931
2011
|
kind: "native",
|
|
1932
2012
|
};
|
package/src/providers/mock.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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;
|
package/src/providers/ollama.ts
CHANGED
|
@@ -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))
|
|
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
|
|
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(
|
|
1868
|
+
const requestSetup = createRequestSetup(streamOptions);
|
|
1862
1869
|
let processingContext: CodexStreamProcessingContext | undefined;
|
|
1863
1870
|
|
|
1864
1871
|
try {
|
|
1865
|
-
const requestContext = await buildCodexRequestContext(model, context,
|
|
1872
|
+
const requestContext = await buildCodexRequestContext(model, context, streamOptions, output);
|
|
1866
1873
|
let initialTransport: CodexInitialTransport;
|
|
1867
1874
|
try {
|
|
1868
|
-
initialTransport = await openInitialCodexEventStream(model,
|
|
1875
|
+
initialTransport = await openInitialCodexEventStream(model, streamOptions, requestSetup, requestContext);
|
|
1869
1876
|
} catch (error) {
|
|
1870
|
-
if (
|
|
1877
|
+
if (streamOptions.fallbackManaged) throw error;
|
|
1871
1878
|
initialTransport = await retryCodexInitialTransportWithoutToolChoice(
|
|
1872
1879
|
model,
|
|
1873
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
463
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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);
|